agentmux_launcher\saga\log/
schema.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// LSD-1 — launcher saga log schema migration.
5//
6// See `docs/specs/SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md` §3.2 for
7// the canonical schema. Mirrors srv's `run_saga_log_migrations` in
8// `agentmux-srv/src/backend/storage/migrations.rs` but with two
9// launcher-specific deltas:
10//
11//   1. A `target` column on the step table. Launcher sagas dispatch
12//      to multiple peers (self / host / srv); srv sagas only ever
13//      target the srv reducer so srv's schema can omit it.
14//   2. A `failed_compensation` saga state. Launcher sagas don't
15//      auto-compensate (LSD spec §3.5); recovery marks unresolved
16//      sagas as `failed_compensation` for operator review. Srv has
17//      a separate `compensated` terminal state instead.
18//
19// Schema lifecycle policy (LSD spec §5 risk #2): only additive changes
20// via `ALTER TABLE` in future migration versions. No in-place rewrites.
21
22use rusqlite::Connection;
23
24use super::LogError;
25
26/// DDL applied on every `LauncherSagaLog::open()`. Idempotent:
27/// `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` make
28/// reopening the same DB a no-op. Schema mirrors LSD spec §3.2 verbatim
29/// (timestamps as RFC3339 TEXT — easier to grep in SQLite shells than
30/// epoch ms; PR LSD-2's coordinator wiring serializes via
31/// `chrono::DateTime<Utc>::to_rfc3339`).
32pub(super) const DDL: &str = "
33CREATE TABLE IF NOT EXISTS launcher_saga (
34    saga_id        INTEGER PRIMARY KEY,
35    name           TEXT NOT NULL,
36    state          TEXT NOT NULL CHECK (state IN ('running', 'completed', 'failed', 'compensating', 'failed_compensation')),
37    started_at     TEXT NOT NULL,
38    ended_at       TEXT,
39    input_json     TEXT NOT NULL,
40    failure_reason TEXT
41);
42
43CREATE TABLE IF NOT EXISTS launcher_saga_step (
44    saga_id        INTEGER NOT NULL REFERENCES launcher_saga(saga_id) ON DELETE CASCADE,
45    step_index     INTEGER NOT NULL,
46    name           TEXT NOT NULL,
47    state          TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed', 'compensated')),
48    cmd_json       TEXT,
49    target         TEXT,
50    started_at     TEXT NOT NULL,
51    ended_at       TEXT,
52    output_json    TEXT,
53    failure_reason TEXT,
54    PRIMARY KEY (saga_id, step_index)
55);
56
57CREATE INDEX IF NOT EXISTS idx_launcher_saga_state
58    ON launcher_saga(state);
59CREATE INDEX IF NOT EXISTS idx_launcher_saga_step_state
60    ON launcher_saga_step(saga_id, state);
61";
62
63/// Apply `DDL` to a fresh or existing connection.
64pub(super) fn run_migrations(conn: &Connection) -> Result<(), LogError> {
65    conn.execute_batch(DDL)?;
66    Ok(())
67}
68
69/// `user_version` value stamped into `launcher-sagas.db`. Mirrors srv's
70/// `stamp_and_check_version` tripwire (AUDIT_SQLITE_SYSTEMS §8.5). The
71/// launcher is a separate crate from srv, so the helper is duplicated
72/// here rather than shared.
73pub(super) const LAUNCHER_SAGA_SCHEMA_VERSION: i64 = 1;
74
75/// Read `PRAGMA user_version`; warn loudly if the file was written by a
76/// newer launcher build (downgrade tripwire), then stamp the current
77/// version. The idempotent DDL above remains the schema mechanism — this
78/// only records the version.
79pub(super) fn stamp_and_check_version(conn: &Connection) -> Result<(), LogError> {
80    let found: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
81    if found > LAUNCHER_SAGA_SCHEMA_VERSION {
82        eprintln!(
83            "[launcher-saga-log] launcher-sagas.db user_version={found} > \
84             {LAUNCHER_SAGA_SCHEMA_VERSION} — written by a newer launcher \
85             build; proceeding read-compatible"
86        );
87    }
88    conn.execute_batch(&format!(
89        "PRAGMA user_version = {LAUNCHER_SAGA_SCHEMA_VERSION};"
90    ))?;
91    Ok(())
92}