fusillade_core/response_step.rs
1//! Response step storage primitives for multi-step Open Responses orchestration.
2//!
3//! A response step is a discrete unit of work inside a multi-step response: a
4//! single upstream model call or tool call. Steps are linked into a linear chain
5//! via `prev_step_id` and may be nested under a `parent_step_id` to express
6//! sub-agent recursion. The orchestration loop (in `onwards`) decides what the
7//! next step is given the chain so far; the storage layer here is purely
8//! infrastructure — no Open Responses domain knowledge.
9//!
10//! See `docs/plans/2026-04-28-multi-step-responses.md`.
11
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17use crate::error::Result;
18use crate::request::RequestId;
19
20/// Identifier of a single response step.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct StepId(pub Uuid);
24
25impl std::fmt::Display for StepId {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 write!(f, "{}", &self.0.to_string()[..8])
28 }
29}
30
31impl From<Uuid> for StepId {
32 fn from(uuid: Uuid) -> Self {
33 StepId(uuid)
34 }
35}
36
37impl std::ops::Deref for StepId {
38 type Target = Uuid;
39 fn deref(&self) -> &Self::Target {
40 &self.0
41 }
42}
43
44/// Discrete kinds of work a response step can represent.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum StepKind {
48 /// Upstream LLM invocation.
49 ModelCall,
50 /// Server-side tool invocation (HTTP tool or sub-agent dispatch).
51 ToolCall,
52}
53
54impl StepKind {
55 pub fn as_str(&self) -> &'static str {
56 match self {
57 StepKind::ModelCall => "model_call",
58 StepKind::ToolCall => "tool_call",
59 }
60 }
61
62 pub fn parse(s: &str) -> Option<Self> {
63 match s {
64 "model_call" => Some(StepKind::ModelCall),
65 "tool_call" => Some(StepKind::ToolCall),
66 _ => None,
67 }
68 }
69}
70
71/// Lifecycle state of a step.
72///
73/// Mirrors a subset of the `requests.state` lifecycle. `claimed` is omitted
74/// because steps do not carry their own lease — serialized access is provided
75/// by the parent request's lease, held by the worker driving the chain.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum StepState {
79 Pending,
80 Processing,
81 Completed,
82 Failed,
83 Canceled,
84}
85
86impl StepState {
87 pub fn as_str(&self) -> &'static str {
88 match self {
89 StepState::Pending => "pending",
90 StepState::Processing => "processing",
91 StepState::Completed => "completed",
92 StepState::Failed => "failed",
93 StepState::Canceled => "canceled",
94 }
95 }
96
97 pub fn parse(s: &str) -> Option<Self> {
98 match s {
99 "pending" => Some(StepState::Pending),
100 "processing" => Some(StepState::Processing),
101 "completed" => Some(StepState::Completed),
102 "failed" => Some(StepState::Failed),
103 "canceled" => Some(StepState::Canceled),
104 _ => None,
105 }
106 }
107
108 pub fn is_terminal(&self) -> bool {
109 matches!(
110 self,
111 StepState::Completed | StepState::Failed | StepState::Canceled
112 )
113 }
114}
115
116/// A row from the `response_steps` table.
117///
118/// `request_id` is `Some` for `model_call` steps (each model_call has a
119/// dedicated `requests` row created for its upstream HTTP fire) and `None`
120/// for `tool_call` steps (tool dispatch lives outside `requests`; the
121/// per-tool analytics live in `tool_call_analytics`).
122///
123/// `parent_step_id` is the chain identifier — it points at the head
124/// (root) step of the user-visible response. It is `None` only on the
125/// head itself.
126///
127/// `prev_step_id` is a tree edge: the step that immediately precedes
128/// this one. Multiple steps may share a `prev_step_id` (parallel
129/// tool_calls; or, when sub-agent dispatch is wired, the sub-agent's
130/// head + the outer continuation after a tool_call).
131#[derive(Debug, Clone, Serialize)]
132pub struct ResponseStep {
133 pub id: StepId,
134 pub request_id: Option<RequestId>,
135 pub prev_step_id: Option<StepId>,
136 pub parent_step_id: Option<StepId>,
137 pub step_kind: StepKind,
138 pub step_sequence: i64,
139 pub request_payload: serde_json::Value,
140 pub response_payload: Option<serde_json::Value>,
141 pub state: StepState,
142 pub started_at: Option<DateTime<Utc>>,
143 pub completed_at: Option<DateTime<Utc>>,
144 pub failed_at: Option<DateTime<Utc>>,
145 pub canceled_at: Option<DateTime<Utc>>,
146 pub retry_attempt: i32,
147 pub error: Option<serde_json::Value>,
148 pub created_at: DateTime<Utc>,
149 pub updated_at: DateTime<Utc>,
150}
151
152/// Input to [`ResponseStepStore::create_step`].
153///
154/// `step_sequence` is monotonic per chain (head step) across the whole
155/// response tree. Callers (the orchestration loop) are responsible for
156/// picking the next sequence value.
157///
158/// Idempotency under crash recovery is the caller's responsibility:
159/// walk the existing chain via [`ResponseStepStore::list_chain`] and
160/// only emit successors that aren't already present. There is no
161/// database-side unique constraint on `(parent_step_id, prev_step_id,
162/// step_kind)` — branching is intrinsic to the data model (a model_call
163/// returning multiple `tool_calls` emits sibling rows with that exact
164/// tuple identical), so any uniqueness on those columns rejects
165/// ordinary parallel tool dispatch.
166#[derive(Debug, Clone)]
167pub struct CreateStepInput {
168 /// Optional pre-generated step UUID. When `Some`, becomes the step's
169 /// primary key — useful when the caller needs to reference the id
170 /// before the row is committed (e.g., emitting an SSE event with the
171 /// step id while the row is still being inserted).
172 pub id: Option<Uuid>,
173 /// FK to `requests.id` for the upstream HTTP fire this step
174 /// represents. `None` on `tool_call` steps.
175 pub request_id: Option<RequestId>,
176 pub prev_step_id: Option<StepId>,
177 pub parent_step_id: Option<StepId>,
178 pub step_kind: StepKind,
179 pub step_sequence: i64,
180 pub request_payload: serde_json::Value,
181}
182
183/// Storage trait for response step persistence.
184///
185/// Mirrors the shape of [`crate::Storage`] for requests but scoped to the
186/// `response_steps` table. Implementations must be safe to call from multiple
187/// concurrent tasks within a single worker (tool fan-out within a single
188/// response holds the parent request lease, so no cross-worker coordination
189/// is required).
190#[async_trait]
191pub trait ResponseStepStore: Send + Sync {
192 /// Insert a new step in `pending` state.
193 ///
194 /// No database-side idempotency constraint. Callers that need
195 /// idempotent recovery should walk the chain first via
196 /// [`ResponseStepStore::list_chain`] and skip emission of
197 /// successors that already exist.
198 async fn create_step(&self, input: CreateStepInput) -> Result<StepId>;
199
200 /// Fetch a single step by id. Returns `None` if not present.
201 async fn get_step(&self, id: StepId) -> Result<Option<ResponseStep>>;
202
203 /// Fetch the step (if any) whose `request_id` matches the given
204 /// fusillade request id. Used by analytics + outlet plumbing to
205 /// resolve "which step does this upstream HTTP fire belong to".
206 /// Returns `None` for `tool_call` steps (which don't carry a
207 /// `request_id`) and for any non-multi-step fusillade row.
208 async fn get_step_by_request(&self, request_id: RequestId) -> Result<Option<ResponseStep>>;
209
210 /// List every step in a response chain identified by its head step.
211 ///
212 /// Includes the head itself + every descendant (parallel tool_calls
213 /// and any future sub-agent recursion via `prev_step_id` branching).
214 /// Ordered by `step_sequence`.
215 async fn list_chain(&self, head_step_id: StepId) -> Result<Vec<ResponseStep>>;
216
217 /// Mark a `pending` step as `processing`, recording `started_at`.
218 ///
219 /// Idempotent: if the step is already in a non-pending state the call
220 /// returns `Ok(())` without modifying the row, so crash recovery can
221 /// resume safely.
222 async fn mark_step_processing(&self, id: StepId) -> Result<()>;
223
224 /// Mark a step as `completed` with the given `response_payload`.
225 async fn complete_step(&self, id: StepId, response: serde_json::Value) -> Result<()>;
226
227 /// Mark a step as `failed` with the given structured error payload.
228 async fn fail_step(&self, id: StepId, error: serde_json::Value) -> Result<()>;
229
230 /// Mark a step as `canceled`.
231 async fn cancel_step(&self, id: StepId) -> Result<()>;
232
233 /// Increment a step's `retry_attempt` and reset it to `pending` for re-
234 /// firing under crash recovery. Used when a worker picks up a step
235 /// that was left in `processing` by a dead worker.
236 async fn requeue_step_for_retry(&self, id: StepId) -> Result<()>;
237}