Skip to main content

agentmux_srv/sagas/
log.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Saga durability — durable on-disk log of saga lifecycle.
5//
6// See `docs/specs/SPEC_SAGA_DURABILITY_2026-05-01.md` for the full
7// design. This module ships PR 1 of the spec: schema + log API +
8// `SagaCtx` instrumentation. Resume-on-startup, `--diag sagas`, and
9// crash-recovery integration tests follow in PR 2.
10//
11// Why an isolated SQLite file (`sagas.db`) rather than co-locating
12// inside `objects.db`: we want saga writes to commit independently
13// of the Store migration / connection. A separate connection
14// also keeps the saga log's mutex contention isolated from the
15// reducer's persistence path. (The two-store atomicity concern from
16// spec §2.1 is not load-bearing for PR 1; saga steps are written
17// after the reducer-emitted event has already been applied to
18// mstore by `apply_event_to_mstore`. Compensate-on-restart in PR 2
19// will reconcile any divergence by walking succeeded steps in
20// reverse.)
21//
22// Concurrency: a single `Mutex<Connection>` serializes writes. Each
23// `start_step` / `finish_step` call holds the lock for <1ms. If
24// profiling shows the mutex becomes hot under load, switch to a
25// connection pool — defer until measurement justifies it (spec §5).
26
27use std::path::Path;
28use std::sync::Mutex;
29
30use agentmux_common::ipc::{Command, Event};
31use rusqlite::{params, Connection, OptionalExtension};
32use serde::{Deserialize, Serialize};
33
34use crate::backend::storage::error::StoreError;
35use crate::backend::storage::migrations::{
36    check_schema_compat, run_saga_log_migrations, stamp_version, SAGA_LOG_SCHEMA_VERSION,
37};
38
39/// Outcome of a saga, written by `terminate`.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SagaOutcome {
42    /// Saga ran to completion successfully.
43    Completed,
44    /// Saga failed without compensation completing (or before
45    /// compensation began). `reason` is operator-readable.
46    Failed { reason: String },
47    /// Saga failed and compensation completed cleanly. `reason` is
48    /// the original failure that triggered compensation.
49    Compensated { reason: String },
50}
51
52impl SagaOutcome {
53    fn state_str(&self) -> &'static str {
54        match self {
55            SagaOutcome::Completed => "completed",
56            SagaOutcome::Failed { .. } => "failed",
57            SagaOutcome::Compensated { .. } => "compensated",
58        }
59    }
60
61    fn reason(&self) -> Option<&str> {
62        match self {
63            SagaOutcome::Completed => None,
64            SagaOutcome::Failed { reason } | SagaOutcome::Compensated { reason } => {
65                Some(reason.as_str())
66            }
67        }
68    }
69}
70
71/// A saga in `running` or `compensating` state at startup. Returned
72/// by `unresolved_sagas`; consumed by PR 2's `compensate_unresolved`
73/// to walk succeeded steps in reverse and dispatch compensation.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[allow(dead_code)] // Fields consumed by PR 2's compensate_unresolved.
76pub struct UnresolvedSaga {
77    pub saga_id: u64,
78    pub name: String,
79    pub state: String,
80    pub started_at: i64,
81    pub input_json: String,
82    pub steps: Vec<UnresolvedStep>,
83}
84
85/// A step row attached to an `UnresolvedSaga`. Steps are returned in
86/// `step_index` ascending order; PR 2's reverse-walker iterates over
87/// the `succeeded` entries.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[allow(dead_code)] // Fields consumed by PR 2's compensate_unresolved.
90pub struct UnresolvedStep {
91    pub step_index: u32,
92    pub name: String,
93    pub state: String,
94    pub cmd_json: String,
95    pub output_json: Option<String>,
96    pub started_at: i64,
97    pub ended_at: Option<i64>,
98}
99
100/// Operator-facing snapshot of a recent saga, for `--diag sagas`.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[allow(dead_code)] // Fields consumed by PR 2's `--diag sagas`.
103pub struct SagaSnapshot {
104    pub saga_id: u64,
105    pub name: String,
106    pub state: String,
107    pub started_at: i64,
108    pub terminal_at: Option<i64>,
109    pub failure_reason: Option<String>,
110    pub step_count: u32,
111    /// JSON serialization of the saga's input args, captured by the
112    /// caller in `emit_saga_started` (reagent P1 PR #631 — was being
113    /// stubbed `null`). Operator-readable provenance for `--diag sagas`.
114    pub input_json: String,
115}
116
117/// SQLite-backed saga log. Owned by `AppState` as `Arc<SagaLog>`;
118/// every `SagaCtx::dispatch` and `compensate` call writes through
119/// it. See module-level docs for design notes.
120pub struct SagaLog {
121    conn: Mutex<Connection>,
122}
123
124impl SagaLog {
125    /// Open a saga log backed by the given SQLite file. Configures
126    /// WAL mode + 5s busy timeout (mirroring `Store::open`) and
127    /// applies the schema migration.
128    pub fn open(path: &Path) -> Result<Self, StoreError> {
129        let conn = Connection::open(path)?;
130        Self::configure_and_migrate(conn)
131    }
132
133    /// Open an in-memory saga log for testing.
134    #[allow(dead_code)]
135    pub fn open_in_memory() -> Result<Self, StoreError> {
136        let conn = Connection::open_in_memory()?;
137        Self::configure_and_migrate(conn)
138    }
139
140    fn configure_and_migrate(conn: Connection) -> Result<Self, StoreError> {
141        // `foreign_keys=ON` enforces the `saga_step.saga_id REFERENCES
142        // saga(saga_id)` declaration; SQLite defaults this to OFF
143        // (codex P2 PR #631). Without it, orphan step rows can be
144        // written silently — corrupts diagnostics + PR 2's resume
145        // logic which reconstructs state from saga + saga_step joins.
146        conn.execute_batch(
147            "PRAGMA journal_mode=WAL;
148             PRAGMA busy_timeout=5000;
149             PRAGMA synchronous=NORMAL;
150             PRAGMA temp_store=MEMORY;
151             PRAGMA foreign_keys=ON;",
152        )?;
153        // Safety lock BEFORE migrations — same discipline as mstore /
154        // filestore: refuse to touch a newer-schema DB on disk before
155        // any mutating step runs. See `check_schema_compat` doc.
156        check_schema_compat(&conn, SAGA_LOG_SCHEMA_VERSION, "sagas.db")?;
157        run_saga_log_migrations(&conn)?;
158        stamp_version(&conn, SAGA_LOG_SCHEMA_VERSION)?;
159        Ok(Self {
160            conn: Mutex::new(conn),
161        })
162    }
163
164    /// Highest existing `saga_id` in the durable log, or 0 if the log
165    /// is empty. Used at startup to seed `state.saga_id_alloc` so new
166    /// sagas don't reuse IDs from prior srv-process runs (reagent P1
167    /// PR #631 — `INSERT` would fail on collision but the saga itself
168    /// would have already started, so we seed defensively).
169    pub fn max_saga_id(&self) -> Result<u64, StoreError> {
170        let conn = self.conn.lock().unwrap();
171        // Propagate query errors. (codex P2 PR #631 round 2.) The
172        // earlier `.ok().flatten()` swallowed SQLite errors and
173        // returned 0 as if the log were empty — a transient read
174        // failure would then reseed `saga_id_alloc=0` on the next
175        // restart, sagas would reuse IDs 1, 2, 3..., and `start_saga`
176        // would reject them (no OR REPLACE), leaving live sagas with
177        // no durable lifecycle row. Better to surface the error and
178        // let the startup hook log the explicit warning + accept the
179        // collision risk knowingly.
180        let max: Option<i64> = conn.query_row("SELECT MAX(saga_id) FROM saga", [], |r| r.get(0))?;
181        Ok(max.unwrap_or(0).max(0) as u64)
182    }
183
184    /// Insert a fresh saga row in `running` state. Called by the
185    /// coordinator immediately after `alloc_saga_id` (and before any
186    /// per-step writes).
187    pub fn start_saga(
188        &self,
189        saga_id: u64,
190        name: &str,
191        input: &serde_json::Value,
192    ) -> Result<(), StoreError> {
193        let now = now_ms();
194        let input_json = serde_json::to_string(input)?;
195        let conn = self.conn.lock().unwrap();
196        // Plain INSERT (not OR REPLACE) so saga_id collisions surface
197        // as errors instead of silently overwriting prior runs'
198        // history. The allocator is seeded from MAX(saga_id) at
199        // startup (see `max_saga_id` + `main.rs`), so collisions
200        // shouldn't happen in practice — if they do, that's a bug
201        // worth surfacing. (codex P1 + reagent P1 PR #631.)
202        conn.execute(
203            "INSERT INTO saga
204             (saga_id, name, state, started_at, terminal_at, failure_reason, input_json)
205             VALUES (?1, ?2, 'running', ?3, NULL, NULL, ?4)",
206            params![saga_id as i64, name, now, input_json],
207        )?;
208        Ok(())
209    }
210
211    /// Insert a `pending` step row before dispatching the command.
212    /// `name` is a short discriminant string (e.g. "MoveTab"); `cmd`
213    /// is serialized as JSON for replay/debugging.
214    pub fn start_step(
215        &self,
216        saga_id: u64,
217        step_index: u32,
218        name: &str,
219        cmd: &Command,
220    ) -> Result<(), StoreError> {
221        let now = now_ms();
222        let cmd_json = serde_json::to_string(cmd)?;
223        let conn = self.conn.lock().unwrap();
224        // Plain INSERT — same rationale as `start_saga`.
225        conn.execute(
226            "INSERT INTO saga_step
227             (saga_id, step_index, name, state, cmd_json, output_json, started_at, ended_at)
228             VALUES (?1, ?2, ?3, 'pending', ?4, NULL, ?5, NULL)",
229            params![saga_id as i64, step_index, name, cmd_json, now],
230        )?;
231        Ok(())
232    }
233
234    /// Mark a step `succeeded` and store its emitted events as
235    /// JSON, used by compensation to reconstruct context if needed.
236    pub fn finish_step(
237        &self,
238        saga_id: u64,
239        step_index: u32,
240        output_events: &[Event],
241    ) -> Result<(), StoreError> {
242        let now = now_ms();
243        let output_json = serde_json::to_string(output_events)?;
244        let conn = self.conn.lock().unwrap();
245        conn.execute(
246            "UPDATE saga_step
247             SET state = 'succeeded', output_json = ?1, ended_at = ?2
248             WHERE saga_id = ?3 AND step_index = ?4",
249            params![output_json, now, saga_id as i64, step_index],
250        )?;
251        Ok(())
252    }
253
254    /// Mark a step `failed`. Stores the reducer's error message in
255    /// `output_json` as `{"error": ...}` so PR 2's `--diag sagas`
256    /// can surface it without a separate column.
257    pub fn fail_step(
258        &self,
259        saga_id: u64,
260        step_index: u32,
261        reason: &str,
262    ) -> Result<(), StoreError> {
263        let now = now_ms();
264        let output_json = serde_json::to_string(&serde_json::json!({ "error": reason }))
265            ?;
266        let conn = self.conn.lock().unwrap();
267        conn.execute(
268            "UPDATE saga_step
269             SET state = 'failed', output_json = ?1, ended_at = ?2
270             WHERE saga_id = ?3 AND step_index = ?4",
271            params![output_json, now, saga_id as i64, step_index],
272        )?;
273        Ok(())
274    }
275
276    /// Mark a compensation step `compensated`. Same shape as
277    /// `finish_step` but distinct state. Called from
278    /// `SagaCtx::compensate` after the reducer applies the
279    /// compensating command successfully.
280    pub fn compensate_step(
281        &self,
282        saga_id: u64,
283        step_index: u32,
284        output_events: &[Event],
285    ) -> Result<(), StoreError> {
286        let now = now_ms();
287        let output_json = serde_json::to_string(output_events)?;
288        let conn = self.conn.lock().unwrap();
289        conn.execute(
290            "INSERT OR REPLACE INTO saga_step
291             (saga_id, step_index, name, state, cmd_json, output_json, started_at, ended_at)
292             VALUES (
293                 ?1, ?2,
294                 COALESCE((SELECT name FROM saga_step WHERE saga_id=?1 AND step_index=?2), 'compensate'),
295                 'compensated',
296                 COALESCE((SELECT cmd_json FROM saga_step WHERE saga_id=?1 AND step_index=?2), ''),
297                 ?3,
298                 COALESCE((SELECT started_at FROM saga_step WHERE saga_id=?1 AND step_index=?2), ?4),
299                 ?4
300             )",
301            params![saga_id as i64, step_index, output_json, now],
302        )?;
303        Ok(())
304    }
305
306    /// Highest existing `step_index` for a saga, +1, or 0 if no steps
307    /// exist yet. Used by PR 2's resume-on-startup so recovery
308    /// compensation rows are appended AFTER the saga's original step
309    /// indices (rather than overwriting in place via `compensate_step`,
310    /// which would lose the original step's `output_json` provenance).
311    pub fn next_step_index(&self, saga_id: u64) -> Result<u32, StoreError> {
312        let conn = self.conn.lock().unwrap();
313        let max: Option<i64> = conn.query_row(
314            "SELECT MAX(step_index) FROM saga_step WHERE saga_id = ?1",
315            params![saga_id as i64],
316            |r| r.get(0),
317        )?;
318        Ok((max.unwrap_or(-1) + 1) as u32)
319    }
320
321    /// Append a new compensation step row at `step_index`. Distinct
322    /// from `compensate_step()` (which overwrites an existing step row
323    /// by index): used by PR 2's resume-on-startup to record a
324    /// recovery dispatch as a NEW row so the original succeeded
325    /// step's provenance is preserved.
326    ///
327    /// `state='compensated'` if `error.is_none()`, else `'failed'`
328    /// (with the reason in `output_json`).
329    pub fn append_recovery_step(
330        &self,
331        saga_id: u64,
332        step_index: u32,
333        name: &str,
334        cmd: &Command,
335        events: &[Event],
336        error: Option<&str>,
337    ) -> Result<(), StoreError> {
338        let now = now_ms();
339        let cmd_json = serde_json::to_string(cmd)?;
340        let (state, output_json) = match error {
341            None => ("compensated", serde_json::to_string(events)?),
342            Some(err) => (
343                "failed",
344                serde_json::to_string(&serde_json::json!({ "error": err }))?,
345            ),
346        };
347        let conn = self.conn.lock().unwrap();
348        conn.execute(
349            "INSERT INTO saga_step
350             (saga_id, step_index, name, state, cmd_json, output_json, started_at, ended_at)
351             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
352            params![saga_id as i64, step_index, name, state, cmd_json, output_json, now],
353        )?;
354        Ok(())
355    }
356
357    /// Mark a saga as `compensating` — used by PR 2's resume-on-startup
358    /// to flag a saga as undergoing recovery before walking its
359    /// succeeded steps in reverse. After all compensating dispatches
360    /// run, the caller invokes `terminate()` with the appropriate
361    /// outcome (`Compensated` / `Failed`).
362    ///
363    /// (codex follow-up.) Distinct from `terminate(state='compensated')`
364    /// because the saga is only **mid-compensation** here — its
365    /// terminal_at and failure_reason are not known yet.
366    pub fn mark_compensating(&self, saga_id: u64) -> Result<(), StoreError> {
367        let conn = self.conn.lock().unwrap();
368        conn.execute(
369            "UPDATE saga SET state = 'compensating' WHERE saga_id = ?1",
370            params![saga_id as i64],
371        )?;
372        Ok(())
373    }
374
375    /// Mark ALL of a saga's remaining `succeeded` forward steps as
376    /// `compensated` in one shot. (codex P1 PR #636 round 5.) Used
377    /// at `emit_terminal` time when the outcome is `Compensated` —
378    /// the saga author is asserting "I've unwound everything," so
379    /// any forward step still in `succeeded` is by definition done.
380    /// Catches the case where one compensating command undoes
381    /// multiple forward steps (e.g. `tear_off_block`'s single
382    /// `DeleteWorkspace` undoes both `CreateWorkspace` and
383    /// `CreateTab`) — `SagaCtx::compensate`'s per-call stack pop
384    /// only marks one, leaving the other as still-`succeeded` and
385    /// triggering double-compensation on restart.
386    pub fn mark_all_succeeded_steps_compensated(
387        &self,
388        saga_id: u64,
389    ) -> Result<(), StoreError> {
390        let now = now_ms();
391        let conn = self.conn.lock().unwrap();
392        conn.execute(
393            "UPDATE saga_step
394             SET state = 'compensated', ended_at = ?1
395             WHERE saga_id = ?2 AND state = 'succeeded'",
396            params![now, saga_id as i64],
397        )?;
398        Ok(())
399    }
400
401    /// Mark an *original* forward step as `compensated`. (codex P1
402    /// PR #636 round 2.) Without this, a second crash-recovery
403    /// startup re-reads the still-`succeeded` original steps and
404    /// replays their inverses again — flipping a previously-recovered
405    /// saga into `failed_compensation` (or worse, applying duplicate
406    /// side effects like a second delete). The recovery rows
407    /// (`append_recovery_step`) live at fresh indices and don't
408    /// affect the original step's state on their own; this update
409    /// is the explicit "this forward step has been compensated"
410    /// marker that the next recovery scan needs to see.
411    pub fn mark_step_compensated(
412        &self,
413        saga_id: u64,
414        step_index: u32,
415    ) -> Result<(), StoreError> {
416        let now = now_ms();
417        let conn = self.conn.lock().unwrap();
418        conn.execute(
419            "UPDATE saga_step
420             SET state = 'compensated', ended_at = ?1
421             WHERE saga_id = ?2 AND step_index = ?3 AND state = 'succeeded'",
422            params![now, saga_id as i64, step_index],
423        )?;
424        Ok(())
425    }
426
427    /// Mark a saga as `failed_compensation` — used by PR 2's resume-
428    /// on-startup when at least one compensating dispatch errored. The
429    /// saga is left in this terminal state for operator review;
430    /// `--diag sagas` surfaces it.
431    pub fn mark_failed_compensation(
432        &self,
433        saga_id: u64,
434        reason: &str,
435    ) -> Result<(), StoreError> {
436        let now = now_ms();
437        let conn = self.conn.lock().unwrap();
438        conn.execute(
439            "UPDATE saga
440             SET state = 'failed_compensation', terminal_at = ?1, failure_reason = ?2
441             WHERE saga_id = ?3",
442            params![now, reason, saga_id as i64],
443        )?;
444        Ok(())
445    }
446
447    /// Write the saga's terminal lifecycle row. Called from
448    /// `emit_terminal` after the inner future returns.
449    pub fn terminate(&self, saga_id: u64, outcome: SagaOutcome) -> Result<(), StoreError> {
450        let now = now_ms();
451        let state = outcome.state_str();
452        let reason = outcome.reason();
453        let conn = self.conn.lock().unwrap();
454        conn.execute(
455            "UPDATE saga
456             SET state = ?1, terminal_at = ?2, failure_reason = ?3
457             WHERE saga_id = ?4",
458            params![state, now, reason, saga_id as i64],
459        )?;
460        Ok(())
461    }
462
463    /// Return all sagas still in `running`, `compensating`, or
464    /// `failed` state, each with its full step list (for PR 2's
465    /// compensate-on-restart reverse walk). Used at startup before
466    /// the API server begins accepting requests.
467    ///
468    /// (codex P1 PR #631 round 2.) `failed` is included because
469    /// `classify_run_saga_result` records timeout/cancel paths as
470    /// `failed` — those are exactly the partial-apply cases this
471    /// durability layer exists to recover. A `failed` saga's step
472    /// list may have `succeeded` rows whose effects need
473    /// compensation; if we filtered them out here, recovery would
474    /// silently leave that state in place.
475    #[allow(dead_code)] // Used by PR 2's resume-on-startup; tests exercise it now.
476    pub fn unresolved_sagas(&self) -> Result<Vec<UnresolvedSaga>, StoreError> {
477        let conn = self.conn.lock().unwrap();
478        let mut stmt = conn.prepare(
479            "SELECT saga_id, name, state, started_at, input_json
480             FROM saga
481             WHERE state IN ('running', 'compensating', 'failed')
482             ORDER BY saga_id ASC",
483        )?;
484        let saga_rows: Vec<(i64, String, String, i64, String)> = stmt
485            .query_map([], |row| {
486                Ok((
487                    row.get::<_, i64>(0)?,
488                    row.get::<_, String>(1)?,
489                    row.get::<_, String>(2)?,
490                    row.get::<_, i64>(3)?,
491                    row.get::<_, String>(4)?,
492                ))
493            })?
494            .collect::<Result<Vec<_>, _>>()?;
495        drop(stmt);
496
497        let mut out = Vec::with_capacity(saga_rows.len());
498        for (saga_id, name, state, started_at, input_json) in saga_rows {
499            let mut step_stmt = conn.prepare(
500                "SELECT step_index, name, state, cmd_json, output_json, started_at, ended_at
501                 FROM saga_step
502                 WHERE saga_id = ?1
503                 ORDER BY step_index ASC",
504            )?;
505            let steps: Vec<UnresolvedStep> = step_stmt
506                .query_map(params![saga_id], |row| {
507                    Ok(UnresolvedStep {
508                        step_index: row.get::<_, i64>(0)? as u32,
509                        name: row.get::<_, String>(1)?,
510                        state: row.get::<_, String>(2)?,
511                        cmd_json: row.get::<_, String>(3)?,
512                        output_json: row.get::<_, Option<String>>(4)?,
513                        started_at: row.get::<_, i64>(5)?,
514                        ended_at: row.get::<_, Option<i64>>(6)?,
515                    })
516                })?
517                .collect::<Result<Vec<_>, _>>()?;
518            out.push(UnresolvedSaga {
519                saga_id: saga_id as u64,
520                name,
521                state,
522                started_at,
523                input_json,
524                steps,
525            });
526        }
527        Ok(out)
528    }
529
530    /// Return up to `limit` recent sagas for `--diag sagas`. Sorted
531    /// most-recent-first. `step_count` is the count of `succeeded`
532    /// or `compensated` steps (i.e. progress through the saga).
533    #[allow(dead_code)] // Used by PR 2's `--diag sagas`; tests exercise it now.
534    pub fn snapshot_recent(&self, limit: u32) -> Result<Vec<SagaSnapshot>, StoreError> {
535        let conn = self.conn.lock().unwrap();
536        let mut stmt = conn.prepare(
537            "SELECT saga_id, name, state, started_at, terminal_at, failure_reason, input_json
538             FROM saga
539             ORDER BY COALESCE(terminal_at, started_at) DESC
540             LIMIT ?1",
541        )?;
542        let rows: Vec<(i64, String, String, i64, Option<i64>, Option<String>, String)> = stmt
543            .query_map(params![limit], |row| {
544                Ok((
545                    row.get::<_, i64>(0)?,
546                    row.get::<_, String>(1)?,
547                    row.get::<_, String>(2)?,
548                    row.get::<_, i64>(3)?,
549                    row.get::<_, Option<i64>>(4)?,
550                    row.get::<_, Option<String>>(5)?,
551                    row.get::<_, String>(6)?,
552                ))
553            })?
554            .collect::<Result<Vec<_>, _>>()?;
555        drop(stmt);
556
557        let mut out = Vec::with_capacity(rows.len());
558        for (saga_id, name, state, started_at, terminal_at, failure_reason, input_json) in rows {
559            let count: Option<i64> = conn
560                .query_row(
561                    "SELECT COUNT(*) FROM saga_step
562                     WHERE saga_id = ?1 AND state IN ('succeeded', 'compensated')",
563                    params![saga_id],
564                    |row| row.get(0),
565                )
566                .optional()?;
567            out.push(SagaSnapshot {
568                saga_id: saga_id as u64,
569                name,
570                state,
571                started_at,
572                terminal_at,
573                failure_reason,
574                step_count: count.unwrap_or(0) as u32,
575                input_json,
576            });
577        }
578        Ok(out)
579    }
580}
581
582use agentmux_common::time::now_ms;
583
584/// Discriminant name for a `Command`. Uses the serde tag (the
585/// `cmd` field of the snake_case-tagged enum) so the saga log row
586/// is easy to read in `--diag sagas`.
587pub(crate) fn command_discriminant_name(cmd: &Command) -> String {
588    match serde_json::to_value(cmd) {
589        Ok(serde_json::Value::Object(map)) => map
590            .get("cmd")
591            .and_then(|v| v.as_str())
592            .map(|s| s.to_string())
593            .unwrap_or_else(|| "unknown".to_string()),
594        _ => "unknown".to_string(),
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use agentmux_common::ipc::{ClientKind, ErrorCode};
602    use tempfile::NamedTempFile;
603
604    fn temp_log() -> (NamedTempFile, SagaLog) {
605        let f = NamedTempFile::new().expect("tempfile");
606        let log = SagaLog::open(f.path()).expect("open");
607        (f, log)
608    }
609
610    fn ping(nonce: u64) -> Command {
611        Command::Ping { nonce }
612    }
613
614    fn pong(nonce: u64) -> Event {
615        Event::Pong { nonce, version: 0 }
616    }
617
618    #[test]
619    fn schema_migration_clean_db() {
620        let f = NamedTempFile::new().unwrap();
621        // First open creates schema.
622        let _log = SagaLog::open(f.path()).unwrap();
623        // Second open is idempotent (CREATE TABLE IF NOT EXISTS).
624        let _log = SagaLog::open(f.path()).unwrap();
625    }
626
627    #[test]
628    fn round_trip_completed() {
629        let (_f, log) = temp_log();
630        log.start_saga(1, "tear_off_tab", &serde_json::json!({"tab_id": "abc"}))
631            .unwrap();
632        log.start_step(1, 0, "Ping", &ping(7)).unwrap();
633        log.finish_step(1, 0, &[pong(7)]).unwrap();
634        log.start_step(1, 1, "Ping", &ping(8)).unwrap();
635        log.finish_step(1, 1, &[pong(8)]).unwrap();
636        log.terminate(1, SagaOutcome::Completed).unwrap();
637
638        let snap = log.snapshot_recent(10).unwrap();
639        assert_eq!(snap.len(), 1);
640        assert_eq!(snap[0].saga_id, 1);
641        assert_eq!(snap[0].name, "tear_off_tab");
642        assert_eq!(snap[0].state, "completed");
643        assert!(snap[0].terminal_at.is_some());
644        assert!(snap[0].failure_reason.is_none());
645        assert_eq!(snap[0].step_count, 2);
646    }
647
648    #[test]
649    fn round_trip_failed_with_compensation() {
650        let (_f, log) = temp_log();
651        log.start_saga(2, "tear_off_block", &serde_json::json!({}))
652            .unwrap();
653        log.start_step(2, 0, "Ping", &ping(1)).unwrap();
654        log.finish_step(2, 0, &[pong(1)]).unwrap();
655        log.start_step(2, 1, "Ping", &ping(2)).unwrap();
656        log.fail_step(2, 1, "boom").unwrap();
657        // Compensation: walk the one succeeded step in reverse.
658        log.compensate_step(2, 0, &[pong(99)]).unwrap();
659        log.terminate(
660            2,
661            SagaOutcome::Compensated {
662                reason: "boom".to_string(),
663            },
664        )
665        .unwrap();
666
667        let snap = log.snapshot_recent(10).unwrap();
668        assert_eq!(snap.len(), 1);
669        assert_eq!(snap[0].state, "compensated");
670        assert_eq!(snap[0].failure_reason.as_deref(), Some("boom"));
671    }
672
673    #[test]
674    fn unresolved_sagas_returns_only_in_flight() {
675        let (_f, log) = temp_log();
676
677        // Saga 1: completed.
678        log.start_saga(1, "saga_a", &serde_json::json!({})).unwrap();
679        log.terminate(1, SagaOutcome::Completed).unwrap();
680
681        // Saga 2: still running.
682        log.start_saga(2, "saga_b", &serde_json::json!({"x": 1}))
683            .unwrap();
684        log.start_step(2, 0, "Ping", &ping(0)).unwrap();
685        log.finish_step(2, 0, &[pong(0)]).unwrap();
686
687        // Saga 3: failed (terminal). NOW INCLUDED in unresolved
688        // (codex P1 round 2): classify_run_saga_result records
689        // timeout/cancel paths as `failed`, and those are exactly
690        // the partial-apply cases recovery needs to handle.
691        log.start_saga(3, "saga_c", &serde_json::json!({})).unwrap();
692        log.terminate(
693            3,
694            SagaOutcome::Failed {
695                reason: "oops".to_string(),
696            },
697        )
698        .unwrap();
699
700        // Saga 4: compensating (terminal not reached).
701        log.start_saga(4, "saga_d", &serde_json::json!({})).unwrap();
702        // Manually flip to compensating without going through
703        // terminate (mirrors what PR 2's compensate path will do).
704        log.conn
705            .lock()
706            .unwrap()
707            .execute(
708                "UPDATE saga SET state = 'compensating' WHERE saga_id = 4",
709                [],
710            )
711            .unwrap();
712
713        // Saga 5: compensated (terminal — recovery already ran).
714        log.start_saga(5, "saga_e", &serde_json::json!({})).unwrap();
715        log.terminate(
716            5,
717            SagaOutcome::Compensated {
718                reason: "rolled back".to_string(),
719            },
720        )
721        .unwrap();
722
723        let unresolved = log.unresolved_sagas().unwrap();
724        let mut ids: Vec<u64> = unresolved.iter().map(|u| u.saga_id).collect();
725        ids.sort();
726        // 2 (running), 3 (failed), 4 (compensating). Excludes 1
727        // (completed) and 5 (compensated).
728        assert_eq!(ids, vec![2, 3, 4]);
729
730        // Saga 2 carries its succeeded step.
731        let saga2 = unresolved.iter().find(|u| u.saga_id == 2).unwrap();
732        assert_eq!(saga2.state, "running");
733        assert_eq!(saga2.steps.len(), 1);
734        assert_eq!(saga2.steps[0].state, "succeeded");
735        assert_eq!(saga2.steps[0].name, "Ping");
736
737        // Saga 3 is failed but eligible for recovery.
738        let saga3 = unresolved.iter().find(|u| u.saga_id == 3).unwrap();
739        assert_eq!(saga3.state, "failed");
740
741        // Saga 4 has no steps yet (started but never dispatched).
742        let saga4 = unresolved.iter().find(|u| u.saga_id == 4).unwrap();
743        assert_eq!(saga4.state, "compensating");
744        assert!(saga4.steps.is_empty());
745    }
746
747    #[test]
748    fn fail_step_records_reason_in_output_json() {
749        let (_f, log) = temp_log();
750        log.start_saga(5, "saga_fail", &serde_json::json!({})).unwrap();
751        log.start_step(5, 0, "Ping", &ping(0)).unwrap();
752        log.fail_step(5, 0, "reducer rejected").unwrap();
753
754        // Inspect via unresolved (saga is still 'running' since
755        // terminate wasn't called).
756        let unresolved = log.unresolved_sagas().unwrap();
757        assert_eq!(unresolved.len(), 1);
758        let step = &unresolved[0].steps[0];
759        assert_eq!(step.state, "failed");
760        let parsed: serde_json::Value =
761            serde_json::from_str(step.output_json.as_ref().unwrap()).unwrap();
762        assert_eq!(parsed["error"], "reducer rejected");
763    }
764
765    #[test]
766    fn snapshot_recent_orders_most_recent_first_and_respects_limit() {
767        let (_f, log) = temp_log();
768        for i in 1..=5 {
769            log.start_saga(i, &format!("saga_{i}"), &serde_json::json!({}))
770                .unwrap();
771            log.terminate(i, SagaOutcome::Completed).unwrap();
772            // Tiny pause so terminal_at differs (millisecond resolution
773            // on most platforms is enough but not guaranteed in
774            // tight loops).
775            std::thread::sleep(std::time::Duration::from_millis(2));
776        }
777
778        let snap = log.snapshot_recent(3).unwrap();
779        assert_eq!(snap.len(), 3);
780        // Most recent first → ids 5, 4, 3.
781        assert_eq!(snap[0].saga_id, 5);
782        assert_eq!(snap[1].saga_id, 4);
783        assert_eq!(snap[2].saga_id, 3);
784    }
785
786    #[test]
787    fn command_discriminant_name_uses_serde_tag() {
788        assert_eq!(command_discriminant_name(&ping(0)), "ping");
789        assert_eq!(
790            command_discriminant_name(&Command::Goodbye),
791            "goodbye"
792        );
793        assert_eq!(
794            command_discriminant_name(&Command::Register {
795                kind: ClientKind::Tool,
796                pid: 1,
797                version: "v".to_string()
798            }),
799            "register"
800        );
801    }
802
803    // Touch the imported types so test compilation surfaces any
804    // future enum-shape drift early; these symbols are otherwise
805    // referenced only by saga authors.
806    #[test]
807    fn imports_are_live() {
808        let _ = ErrorCode::InvalidCommand;
809    }
810
811    // codex P1 PR #631 — start_saga must NOT silently overwrite an
812    // existing saga_id row. With the seed-from-MAX(saga_id) startup
813    // logic, collisions should never happen in practice; if one ever
814    // does, it's a bug worth surfacing.
815    #[test]
816    fn start_saga_rejects_duplicate_id() {
817        let (_tmp, log) = temp_log();
818        log.start_saga(42, "tear_off_tab", &serde_json::json!({"a": 1}))
819            .unwrap();
820        // A second start_saga with the same id must fail (no OR REPLACE).
821        let err = log
822            .start_saga(42, "tear_off_tab", &serde_json::json!({"a": 2}))
823            .unwrap_err();
824        // Surfaces as a SQLite UNIQUE constraint violation.
825        let msg = format!("{}", err);
826        assert!(
827            msg.to_lowercase().contains("unique") || msg.to_lowercase().contains("constraint"),
828            "expected unique-constraint error, got: {msg}",
829        );
830    }
831
832    // reagent P1 + codex P1 PR #631 — max_saga_id seeds the
833    // allocator at startup; without this, restarts would reuse
834    // saga_ids 1, 2, 3... and silently overwrite prior runs.
835    #[test]
836    fn max_saga_id_returns_highest_persisted() {
837        let (_tmp, log) = temp_log();
838        assert_eq!(log.max_saga_id().unwrap(), 0); // empty
839        log.start_saga(7, "a", &serde_json::Value::Null).unwrap();
840        log.start_saga(42, "b", &serde_json::Value::Null).unwrap();
841        log.start_saga(13, "c", &serde_json::Value::Null).unwrap();
842        assert_eq!(log.max_saga_id().unwrap(), 42);
843    }
844
845    // codex P2 PR #631 — foreign keys enabled means a saga_step
846    // referencing a non-existent saga_id is rejected.
847    #[test]
848    fn foreign_keys_enforced_on_saga_step() {
849        let (_tmp, log) = temp_log();
850        // Try to insert a step for a saga that was never started.
851        let err = log
852            .start_step(999, 0, "MoveTab", &ping(1))
853            .unwrap_err();
854        let msg = format!("{}", err);
855        assert!(
856            msg.to_lowercase().contains("foreign key")
857                || msg.to_lowercase().contains("constraint"),
858            "expected foreign-key error, got: {msg}",
859        );
860    }
861
862    // reagent P1 PR #631 — input_json must round-trip the saga's
863    // actual input args (was being stubbed with Value::Null).
864    #[test]
865    fn start_saga_persists_input_json() {
866        let (_tmp, log) = temp_log();
867        let input = serde_json::json!({
868            "tab_id": "tab-abc",
869            "source_workspace_id": "ws-1",
870        });
871        log.start_saga(1, "tear_off_tab", &input).unwrap();
872        let snapshot = log.snapshot_recent(1).unwrap();
873        assert_eq!(snapshot.len(), 1);
874        let parsed: serde_json::Value =
875            serde_json::from_str(&snapshot[0].input_json).unwrap();
876        assert_eq!(parsed, input);
877    }
878
879    // Step 7 — E.7 recovery-from-crash scaffold.
880    //
881    // Asserts the API surface PR 2's `compensate_unresolved` will
882    // need to walk back partially-applied sagas. The full
883    // compensate-on-restart logic ships in PR 2; this test pins the
884    // shape of `unresolved_sagas()` so PR 2 can wire against a
885    // stable contract.
886    //
887    // Scenario: a saga went through TWO succeeded forward steps,
888    // then the srv process crashed mid-third-step (no terminal row).
889    // After restart, `unresolved_sagas()` must:
890    //   1. Return the saga in `running` state.
891    //   2. Surface its succeeded steps in `step_index ASC` order so
892    //      PR 2's reverse walker can iterate `.iter().rev()` and
893    //      dispatch compensation in LIFO order (most-recently-applied
894    //      first, matching standard saga compensation semantics).
895    //   3. Preserve `cmd_json` per step so PR 2 can reconstruct the
896    //      compensating command shape (e.g., `MoveTab src↔dst` swap).
897    //   4. Carry the saga's `input_json` for `--diag sagas` triage.
898    #[test]
899    fn unresolved_saga_exposes_succeeded_steps_in_index_order_for_reverse_walk() {
900        let (_f, log) = temp_log();
901
902        // A saga that got through two forward steps (CreateWorkspace
903        // + MoveTab from a tear_off_tab run), then "crashed" before
904        // terminate(). No fail/compensate writes — the lifecycle row
905        // stays in `running`, the steps stay `succeeded`.
906        let input = serde_json::json!({
907            "tab_id": "tab-abc",
908            "source_workspace_id": "ws-1",
909        });
910        log.start_saga(101, "tear_off_tab", &input).unwrap();
911
912        let create_cmd = Command::Ping { nonce: 1 }; // stand-in: CreateWorkspace
913        let move_cmd = Command::Ping { nonce: 2 }; // stand-in: MoveTab
914        log.start_step(101, 0, "CreateWorkspace", &create_cmd)
915            .unwrap();
916        log.finish_step(101, 0, &[pong(1)]).unwrap();
917        log.start_step(101, 1, "MoveTab", &move_cmd).unwrap();
918        log.finish_step(101, 1, &[pong(2)]).unwrap();
919
920        // Imagine srv crashed here — no terminate(). On restart we
921        // open a fresh SagaLog over the same DB; saga 101 should
922        // appear in unresolved_sagas with both succeeded steps.
923        let unresolved = log.unresolved_sagas().unwrap();
924        assert_eq!(unresolved.len(), 1);
925        let saga = &unresolved[0];
926
927        // (1) Saga is in `running` state — PR 2 picks these up first.
928        assert_eq!(saga.saga_id, 101);
929        assert_eq!(saga.name, "tear_off_tab");
930        assert_eq!(saga.state, "running");
931
932        // (2) Steps are returned in ascending step_index order — PR 2's
933        // reverse-walker iterates `.iter().rev()` to compensate LIFO.
934        assert_eq!(saga.steps.len(), 2);
935        assert_eq!(saga.steps[0].step_index, 0);
936        assert_eq!(saga.steps[0].name, "CreateWorkspace");
937        assert_eq!(saga.steps[0].state, "succeeded");
938        assert_eq!(saga.steps[1].step_index, 1);
939        assert_eq!(saga.steps[1].name, "MoveTab");
940        assert_eq!(saga.steps[1].state, "succeeded");
941
942        // (3) cmd_json round-trips so PR 2 can re-deserialize and
943        // construct the compensating command shape.
944        let parsed_create: Command =
945            serde_json::from_str(&saga.steps[0].cmd_json).unwrap();
946        let parsed_move: Command = serde_json::from_str(&saga.steps[1].cmd_json).unwrap();
947        assert!(matches!(parsed_create, Command::Ping { nonce: 1 }));
948        assert!(matches!(parsed_move, Command::Ping { nonce: 2 }));
949
950        // (4) input_json survives the restart for operator triage.
951        let parsed_input: serde_json::Value =
952            serde_json::from_str(&saga.input_json).unwrap();
953        assert_eq!(parsed_input["tab_id"], "tab-abc");
954        assert_eq!(parsed_input["source_workspace_id"], "ws-1");
955
956        // Sanity check: walking succeeded-only in reverse (the actual
957        // PR 2 algorithm) yields step indices [1, 0] — most-recently-
958        // applied first.
959        let reverse_succeeded: Vec<u32> = saga
960            .steps
961            .iter()
962            .rev()
963            .filter(|s| s.state == "succeeded")
964            .map(|s| s.step_index)
965            .collect();
966        assert_eq!(reverse_succeeded, vec![1, 0]);
967    }
968
969    // Step 7 — E.7 recovery scaffold continued.
970    //
971    // Failed-mid-step is a different recovery shape: the lifecycle
972    // is still `running`, but one step is `failed` and earlier steps
973    // are `succeeded`. PR 2's reverse-walker must skip the failed
974    // step (its effects didn't apply by definition) and compensate
975    // the prior succeeded ones.
976    #[test]
977    fn unresolved_saga_with_mid_step_failure_exposes_succeeded_prefix() {
978        let (_f, log) = temp_log();
979        log.start_saga(202, "tear_off_block", &serde_json::json!({})).unwrap();
980        log.start_step(202, 0, "Ping", &ping(1)).unwrap();
981        log.finish_step(202, 0, &[pong(1)]).unwrap();
982        log.start_step(202, 1, "Ping", &ping(2)).unwrap();
983        log.fail_step(202, 1, "reducer rejected").unwrap();
984        // Imagine crash before compensation could finish — saga
985        // lifecycle row stays in `running`.
986
987        let unresolved = log.unresolved_sagas().unwrap();
988        let saga = unresolved.iter().find(|s| s.saga_id == 202).unwrap();
989        assert_eq!(saga.state, "running");
990        // Both step rows present, in index order, with the failure
991        // distinguishable so PR 2's recovery skips it.
992        assert_eq!(saga.steps.len(), 2);
993        assert_eq!(saga.steps[0].state, "succeeded");
994        assert_eq!(saga.steps[1].state, "failed");
995        // PR 2 will iterate succeeded prefix for compensation —
996        // demonstrate the iteration shape.
997        let to_compensate: Vec<u32> = saga
998            .steps
999            .iter()
1000            .rev()
1001            .filter(|s| s.state == "succeeded")
1002            .map(|s| s.step_index)
1003            .collect();
1004        assert_eq!(to_compensate, vec![0]);
1005    }
1006}