Skip to main content

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}