Skip to main content

cuttlefish_host/
ledger.rs

1//! Per-job durable checkpoint store. One SQLite file per job
2//! (`$CUTTLEFISH_HOME/jobs/<job_id>/ledger.sqlite`), matching the catalog's
3//! existing one-thing-per-file convention. See
4//! docs/superpowers/specs/2026-08-03-dag-core-design.md's "Durability model"
5//! for the full rationale — this module is purely storage; the resume
6//! decision logic (skip on completed/skipped, run everything else) lives in
7//! `crate::runner`.
8
9use rusqlite::Connection;
10use std::path::Path;
11use std::sync::Mutex;
12
13/// A job's own terminal status, as recorded in the ledger — distinct from
14/// per-node checkpoints, which alone can't tell "still running when the
15/// process died" apart from "finished cleanly."
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum LedgerJobStatus {
18    /// The job has not yet called [`Ledger::finish`].
19    Running,
20    /// The job finished successfully.
21    Completed,
22    /// The job finished with an error.
23    Failed,
24    /// The job was cancelled before it finished.
25    Cancelled,
26}
27
28impl LedgerJobStatus {
29    fn from_str(s: &str) -> Self {
30        match s {
31            "completed" => Self::Completed,
32            "failed" => Self::Failed,
33            "cancelled" => Self::Cancelled,
34            _ => Self::Running,
35        }
36    }
37}
38
39/// A per-job checkpoint ledger, backed by a single SQLite file.
40///
41/// The connection is behind a `Mutex` purely to make `Ledger: Sync` —
42/// `rusqlite::Connection` is `Send` but not `Sync` (its statement cache uses
43/// unsynchronized interior mutability), and `run_job` holds a `&Ledger`
44/// across `.await` points in a task spawned onto a multi-threaded runtime,
45/// which requires the held reference to be `Send`, which in turn requires
46/// `Ledger: Sync`. There is normally only ever one writer (the job that owns
47/// this ledger), so contention is not a real concern; the lock exists to
48/// satisfy the type system's threading rules, not to arbitrate real
49/// concurrent access.
50pub struct Ledger {
51    conn: Mutex<Connection>,
52    job_dir: std::path::PathBuf,
53}
54
55/// Names the job this ledger belongs to without trying to render the SQLite
56/// connection, which is not `Debug`. Exists so callers can use
57/// `Result`-combinators like `expect_err` on [`Ledger::open`].
58impl std::fmt::Debug for Ledger {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("Ledger")
61            .field("job_dir", &self.job_dir)
62            .finish_non_exhaustive()
63    }
64}
65
66/// One thing a job gave up on after its recovery ladder was exhausted.
67///
68/// Carries enough for a session that wasn't there when it happened to act:
69/// which node, which item if any, and *why*. A list of job ids would only
70/// be a second hunt.
71#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
72pub struct Escalation {
73    /// The node that gave up.
74    pub node: String,
75    /// Which fan-out item, or `None` for a whole node.
76    pub item: Option<usize>,
77    /// The failure that exhausted the ladder.
78    pub reason: String,
79    /// What this item was working on, so it can be handed back as a
80    /// manifest line. `None` for a row written before inputs were recorded
81    /// — such an escalation cannot be drained, and callers must say so
82    /// rather than silently emitting one fewer line than they listed.
83    pub input: Option<serde_json::Value>,
84    /// When this was exported by a drain, or `None` while outstanding.
85    pub drained_at: Option<String>,
86    /// When it was recorded.
87    pub at: String,
88}
89
90/// Why a ledger could not be opened.
91#[derive(Debug, thiserror::Error)]
92pub enum LedgerError {
93    /// The underlying SQLite call failed.
94    #[error(transparent)]
95    Sqlite(#[from] rusqlite::Error),
96    /// The file predates per-item checkpoints, so its `checkpoints` table has
97    /// no `item_index` column and its rows cannot be interpreted against the
98    /// current schema.
99    #[error(
100        "this job's ledger predates per-item fan-out checkpoints and cannot be resumed \
101         — re-submit the job to start a fresh one"
102    )]
103    StaleSchema,
104}
105
106impl Ledger {
107    /// Open (creating if absent) a job's ledger at `path`, ensuring both
108    /// tables exist and `job_status` has its single row.
109    ///
110    /// `graph_fingerprint` is recorded only when `job_status` doesn't exist
111    /// yet (a fresh job) — reopening an existing ledger leaves the
112    /// originally-recorded fingerprint untouched, since comparing old vs.
113    /// new fingerprint is a resume endpoint's job, not `open`'s.
114    pub fn open(path: &Path, graph_fingerprint: &str) -> Result<Self, LedgerError> {
115        if let Some(parent) = path.parent() {
116            std::fs::create_dir_all(parent).ok();
117        }
118        let conn = Connection::open(path)?;
119        // A future reader (daemon startup scan, resume endpoint) may open
120        // its own connection to this same file while a running job's
121        // connection is mid-write. Without this, SQLite's default
122        // busy_timeout of 0 means that second connection gets an immediate
123        // SQLITE_BUSY instead of waiting briefly for the lock to clear.
124        conn.busy_timeout(std::time::Duration::from_secs(5))?;
125
126        // A table that already exists keeps its original shape under CREATE
127        // TABLE IF NOT EXISTS, so a ledger written before per-item
128        // checkpoints would survive to here and then fail at the first
129        // INSERT with "no such column: item_index" — an error that says
130        // nothing about what actually happened or what to do. Detect it here
131        // instead. An empty result means the table doesn't exist yet, which
132        // is just a fresh ledger.
133        let existing_columns: Vec<String> = {
134            let mut stmt = conn.prepare("PRAGMA table_info(checkpoints)")?;
135            let rows = stmt.query_map([], |r| r.get::<_, String>(1))?;
136            rows.collect::<rusqlite::Result<_>>()?
137        };
138        if !existing_columns.is_empty() && !existing_columns.iter().any(|c| c == "item_index") {
139            return Err(LedgerError::StaleSchema);
140        }
141
142        // `input_json` and `drained_at` are a different case entirely, and
143        // deliberately not a `StaleSchema` refusal: both are additive and
144        // nullable, and a ledger without them resumes perfectly — it simply
145        // can't be drained. Refusing would break resume for every job that
146        // predates draining in exchange for nothing. Old rows keep NULL,
147        // which is honest: an escalation recorded before inputs were kept
148        // genuinely has no input to hand back.
149        for (column, ddl) in [
150            (
151                "input_json",
152                "ALTER TABLE checkpoints ADD COLUMN input_json TEXT",
153            ),
154            (
155                "drained_at",
156                "ALTER TABLE checkpoints ADD COLUMN drained_at TEXT",
157            ),
158        ] {
159            if !existing_columns.is_empty() && !existing_columns.iter().any(|c| c == column) {
160                conn.execute(ddl, [])?;
161            }
162        }
163
164        conn.execute_batch(
165            "CREATE TABLE IF NOT EXISTS checkpoints (
166                node_name    TEXT NOT NULL,
167                item_index   INTEGER NOT NULL DEFAULT -1,
168                status       TEXT NOT NULL,
169                output_json  TEXT,
170                error_text   TEXT,
171                completed_at TEXT NOT NULL,
172                input_json   TEXT,
173                drained_at   TEXT,
174                PRIMARY KEY (node_name, item_index)
175             );
176             CREATE TABLE IF NOT EXISTS job_status (status TEXT NOT NULL, graph_fingerprint TEXT NOT NULL);
177             CREATE TABLE IF NOT EXISTS fanout_manifests (
178                node_name  TEXT PRIMARY KEY,
179                digest     TEXT NOT NULL,
180                item_count INTEGER NOT NULL
181             );",
182        )?;
183        let count: i64 = conn.query_row("SELECT COUNT(*) FROM job_status", [], |r| r.get(0))?;
184        if count == 0 {
185            conn.execute(
186                "INSERT INTO job_status (status, graph_fingerprint) VALUES ('running', ?1)",
187                [graph_fingerprint],
188            )?;
189        }
190        Ok(Self {
191            conn: Mutex::new(conn),
192            // Fan-out results are materialized beside the ledger. Deriving
193            // the directory from the path we were already given avoids
194            // threading a second, independently-computed notion of "this
195            // job's directory" through `run_job`, which could drift.
196            job_dir: path
197                .parent()
198                .unwrap_or_else(|| Path::new("."))
199                .to_path_buf(),
200        })
201    }
202
203    /// The directory this job's state lives in — the ledger file's own
204    /// parent. Fan-out results are materialized under here.
205    pub fn job_dir(&self) -> &Path {
206        &self.job_dir
207    }
208
209    /// The fingerprint recorded when this job was first submitted — compare
210    /// against a freshly computed one before resuming.
211    pub fn graph_fingerprint(&self) -> rusqlite::Result<String> {
212        self.lock()
213            .query_row("SELECT graph_fingerprint FROM job_status", [], |r| r.get(0))
214    }
215
216    /// The recorded output of `node_name`, if it completed successfully.
217    /// `None` for a node that never ran, is still pending, or was skipped.
218    pub fn get_completed(&self, node_name: &str) -> rusqlite::Result<Option<serde_json::Value>> {
219        let result: Option<(String, Option<String>)> = match self.lock().query_row(
220            "SELECT status, output_json FROM checkpoints WHERE node_name = ?1 AND item_index = -1",
221            [node_name],
222            |r| Ok((r.get(0)?, r.get(1)?)),
223        ) {
224            Ok(row) => Some(row),
225            Err(rusqlite::Error::QueryReturnedNoRows) => None,
226            Err(e) => return Err(e),
227        };
228        match result {
229            Some((status, Some(json))) if status == "completed" => Ok(Some(
230                serde_json::from_str(&json).expect("ledger never stores invalid JSON"),
231            )),
232            _ => Ok(None),
233        }
234    }
235
236    /// Whether `node_name` was recorded as skipped (e.g. excluded by a
237    /// branch decision).
238    pub fn is_skipped(&self, node_name: &str) -> rusqlite::Result<bool> {
239        let status: Option<String> = match self.lock().query_row(
240            "SELECT status FROM checkpoints WHERE node_name = ?1 AND item_index = -1",
241            [node_name],
242            |r| r.get(0),
243        ) {
244            Ok(status) => Some(status),
245            Err(rusqlite::Error::QueryReturnedNoRows) => None,
246            Err(e) => return Err(e),
247        };
248        Ok(status.as_deref() == Some("skipped"))
249    }
250
251    /// Record `node_name` as completed with `output`, overwriting any prior
252    /// checkpoint for that node.
253    pub fn write_completed(
254        &self,
255        node_name: &str,
256        output: &serde_json::Value,
257    ) -> rusqlite::Result<()> {
258        self.lock().execute(
259            "INSERT OR REPLACE INTO checkpoints
260               (node_name, item_index, status, output_json, error_text, completed_at)
261             VALUES (?1, -1, 'completed', ?2, NULL, ?3)",
262            rusqlite::params![node_name, output.to_string(), now_marker()],
263        )?;
264        Ok(())
265    }
266
267    /// Record `node_name` as skipped, overwriting any prior checkpoint for
268    /// that node.
269    pub fn write_skipped(&self, node_name: &str) -> rusqlite::Result<()> {
270        self.lock().execute(
271            "INSERT OR REPLACE INTO checkpoints
272               (node_name, item_index, status, output_json, error_text, completed_at)
273             VALUES (?1, -1, 'skipped', NULL, NULL, ?2)",
274            rusqlite::params![node_name, now_marker()],
275        )?;
276        Ok(())
277    }
278
279    /// Record one fan-out item as completed with `output`.
280    pub fn write_item_completed(
281        &self,
282        node_name: &str,
283        item_index: usize,
284        output: &serde_json::Value,
285        input: Option<&serde_json::Value>,
286    ) -> rusqlite::Result<()> {
287        self.lock().execute(
288            "INSERT OR REPLACE INTO checkpoints
289               (node_name, item_index, status, output_json, error_text, completed_at, input_json)
290             VALUES (?1, ?2, 'completed', ?3, NULL, ?4, ?5)",
291            rusqlite::params![
292                node_name,
293                item_index as i64,
294                output.to_string(),
295                now_marker(),
296                input.map(|v| v.to_string())
297            ],
298        )?;
299        Ok(())
300    }
301
302    /// Record one fan-out item as having *concluded* in failure.
303    ///
304    /// Concluded is the operative word. An item still in flight when the
305    /// process died must leave no row at all, so that resume re-runs it —
306    /// whereas an item whose block genuinely returned `Fail` is recorded
307    /// here and never retried. That distinction is the entire basis of
308    /// fan-out resume semantics: it separates "this chunk is bad" from "we
309    /// were interrupted", without needing to ask which happened.
310    /// `input` is stored so the item can be handed back later — see
311    /// [`Ledger::escalations`] — and so the warehouse can say where a row
312    /// came from. Successes carry it too, for the second reason: a warehouse
313    /// row has to be traceable on its own, and "the input is still in the
314    /// manifest" only helps somebody who has the manifest, the job directory,
315    /// and the knowledge that item 4,013 was line 4,014.
316    pub fn write_item_failed(
317        &self,
318        node_name: &str,
319        item_index: usize,
320        error: &str,
321        input: Option<&serde_json::Value>,
322    ) -> rusqlite::Result<()> {
323        self.lock().execute(
324            "INSERT OR REPLACE INTO checkpoints
325               (node_name, item_index, status, output_json, error_text, completed_at, input_json)
326             VALUES (?1, ?2, 'failed', NULL, ?3, ?4, ?5)",
327            rusqlite::params![
328                node_name,
329                item_index as i64,
330                error,
331                now_marker(),
332                input.map(|v| v.to_string())
333            ],
334        )?;
335        Ok(())
336    }
337
338    /// One item's recorded output, if it completed successfully.
339    pub fn get_item_completed(
340        &self,
341        node_name: &str,
342        item_index: usize,
343    ) -> rusqlite::Result<Option<serde_json::Value>> {
344        let row: Option<(String, Option<String>)> = match self.lock().query_row(
345            "SELECT status, output_json FROM checkpoints
346             WHERE node_name = ?1 AND item_index = ?2",
347            rusqlite::params![node_name, item_index as i64],
348            |r| Ok((r.get(0)?, r.get(1)?)),
349        ) {
350            Ok(row) => Some(row),
351            Err(rusqlite::Error::QueryReturnedNoRows) => None,
352            Err(e) => return Err(e),
353        };
354        Ok(match row {
355            Some((status, Some(json))) if status == "completed" => {
356                Some(serde_json::from_str(&json).expect("ledger never stores invalid JSON"))
357            }
358            _ => None,
359        })
360    }
361
362    /// Whether this item already concluded, either way — the resume check.
363    /// A concluded item is skipped; anything else is (re-)run.
364    pub fn item_concluded(&self, node_name: &str, item_index: usize) -> rusqlite::Result<bool> {
365        let count: i64 = self.lock().query_row(
366            "SELECT COUNT(*) FROM checkpoints WHERE node_name = ?1 AND item_index = ?2",
367            rusqlite::params![node_name, item_index as i64],
368            |r| r.get(0),
369        )?;
370        Ok(count > 0)
371    }
372
373    /// Every concluded item for `node_name`, in index order, as
374    /// `(index, output, error)` — exactly one of `output`/`error` is `Some`.
375    #[allow(clippy::type_complexity)]
376    pub fn concluded_items(
377        &self,
378        node_name: &str,
379    ) -> rusqlite::Result<Vec<(usize, Option<serde_json::Value>, Option<String>)>> {
380        let conn = self.lock();
381        let mut stmt = conn.prepare(
382            "SELECT item_index, status, output_json, error_text FROM checkpoints
383             WHERE node_name = ?1 AND item_index >= 0 ORDER BY item_index",
384        )?;
385        let rows = stmt.query_map([node_name], |r| {
386            let index: i64 = r.get(0)?;
387            let status: String = r.get(1)?;
388            let output: Option<String> = r.get(2)?;
389            let error: Option<String> = r.get(3)?;
390            Ok((
391                index as usize,
392                if status == "completed" {
393                    output.map(|j| {
394                        serde_json::from_str(&j).expect("ledger never stores invalid JSON")
395                    })
396                } else {
397                    None
398                },
399                // Anything not `completed` is a failure of some kind, and
400                // carries its error. Matching on `failed` alone would drop
401                // `escalated` items out of `failures.jsonl` entirely — they
402                // would count as concluded, count toward `failed`, and then
403                // silently vanish from the projection.
404                if status == "completed" { None } else { error },
405            ))
406        })?;
407        rows.collect()
408    }
409
410    /// Every concluded item of `node_name`, with everything the warehouse
411    /// needs to describe it.
412    ///
413    /// Distinct from [`Self::concluded_items`], which exists to project the
414    /// JSONL files and therefore deliberately discards the status once it has
415    /// decided output-or-error. The warehouse keeps the status verbatim: a
416    /// reader auditing bronze needs `escalated` and `failed` to stay
417    /// different, because one means "a human was asked" and the other means
418    /// "it simply did not work".
419    pub fn concluded_rows(&self, node_name: &str) -> rusqlite::Result<Vec<ConcludedRow>> {
420        let conn = self.lock();
421        let mut stmt = conn.prepare(
422            "SELECT item_index, status, output_json, error_text, completed_at, input_json
423             FROM checkpoints
424             WHERE node_name = ?1 AND item_index >= 0 ORDER BY item_index",
425        )?;
426        let rows = stmt.query_map([node_name], |r| {
427            let status: String = r.get(1)?;
428            let output: Option<String> = r.get(2)?;
429            Ok(ConcludedRow {
430                item: r.get::<_, i64>(0)?,
431                output: if status == "completed" {
432                    output.map(|j| {
433                        serde_json::from_str(&j).expect("ledger never stores invalid JSON")
434                    })
435                } else {
436                    None
437                },
438                error: if status == "completed" {
439                    None
440                } else {
441                    r.get(3)?
442                },
443                status,
444                concluded_at: r.get(4)?,
445                // Absent on a ledger predating the column. Left absent rather
446                // than filled with a placeholder: an empty provenance column
447                // is honest, and `{}` would read as "the input was empty".
448                source_input: r.get(5)?,
449            })
450        })?;
451        rows.collect()
452    }
453
454    /// Record that recovery was exhausted for `node_name`, giving up.
455    ///
456    /// Stored as an ordinary concluded failure with a distinct status rather
457    /// than in a table of its own — the composite key already carries node
458    /// and item, and an escalation *is* a kind of concluded failure. Pass
459    /// `None` for a whole node, `Some(i)` for one fan-out item.
460    pub fn write_escalated(
461        &self,
462        node_name: &str,
463        item_index: Option<usize>,
464        reason: &str,
465        input: Option<&serde_json::Value>,
466    ) -> rusqlite::Result<()> {
467        self.lock().execute(
468            "INSERT OR REPLACE INTO checkpoints
469               (node_name, item_index, status, output_json, error_text, completed_at, input_json)
470             VALUES (?1, ?2, 'escalated', NULL, ?3, ?4, ?5)",
471            rusqlite::params![
472                node_name,
473                item_index.map(|i| i as i64).unwrap_or(-1),
474                reason,
475                now_marker(),
476                input.map(|v| v.to_string())
477            ],
478        )?;
479        Ok(())
480    }
481
482    /// Open an existing ledger **read-only**, running no schema statements.
483    ///
484    /// [`Ledger::open`] is a writer: it runs `CREATE TABLE IF NOT EXISTS` and
485    /// `ALTER TABLE` so a fresh or older ledger becomes usable. That is right
486    /// when opening the ledger you are about to write, and wrong for reading
487    /// somebody else's — DDL takes an exclusive lock, so merely *listing*
488    /// escalations across every job on the machine could lock the ledger of a
489    /// job that is currently running and fail it.
490    ///
491    /// This opens with `SQLITE_OPEN_READ_ONLY` and touches no schema. A ledger
492    /// predating the drain columns therefore reads with them absent, which is
493    /// handled rather than migrated: such rows come back with no input, which
494    /// is exactly what they have.
495    pub fn open_read_only(path: &Path) -> Result<Self, LedgerError> {
496        let conn = Connection::open_with_flags(
497            path,
498            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
499        )?;
500        conn.busy_timeout(std::time::Duration::from_secs(5))?;
501        Ok(Self {
502            conn: Mutex::new(conn),
503            job_dir: path
504                .parent()
505                .unwrap_or_else(|| Path::new("."))
506                .to_path_buf(),
507        })
508    }
509
510    /// Whether this ledger has the columns draining needs.
511    ///
512    /// Read-only opens do not migrate, so a caller has to be able to ask.
513    fn has_drain_columns(&self) -> bool {
514        let conn = self.lock();
515        let Ok(mut stmt) = conn.prepare("PRAGMA table_info(checkpoints)") else {
516            return false;
517        };
518        let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(1)) else {
519            return false;
520        };
521        let columns: Vec<String> = rows.filter_map(Result::ok).collect();
522        columns.iter().any(|c| c == "input_json") && columns.iter().any(|c| c == "drained_at")
523    }
524
525    /// What this job gave up on and nobody has handled yet — the queue.
526    pub fn escalations(&self) -> rusqlite::Result<Vec<Escalation>> {
527        self.query_escalations(true)
528    }
529
530    /// Every escalation this job ever recorded, drained or not.
531    ///
532    /// Draining marks rather than deletes, so this is the historical
533    /// record. What went wrong stays worth knowing after it's been handled
534    /// — especially when the retry fails too.
535    pub fn all_escalations(&self) -> rusqlite::Result<Vec<Escalation>> {
536        self.query_escalations(false)
537    }
538
539    fn query_escalations(&self, outstanding_only: bool) -> rusqlite::Result<Vec<Escalation>> {
540        // A ledger opened read-only is never migrated, so it may genuinely
541        // lack these columns. Selecting them would error; reporting the rows
542        // without an input is both correct and what the drain path already
543        // knows how to describe.
544        if !self.has_drain_columns() {
545            let conn = self.lock();
546            let mut stmt = conn.prepare(
547                "SELECT node_name, item_index, error_text, completed_at FROM checkpoints
548                 WHERE status = 'escalated' ORDER BY node_name, item_index",
549            )?;
550            let rows = stmt.query_map([], |r| {
551                let index: i64 = r.get(1)?;
552                Ok(Escalation {
553                    node: r.get(0)?,
554                    item: (index >= 0).then_some(index as usize),
555                    reason: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
556                    at: r.get(3)?,
557                    input: None,
558                    drained_at: None,
559                })
560            })?;
561            return rows.collect();
562        }
563        let conn = self.lock();
564        let mut stmt = conn.prepare(
565            "SELECT node_name, item_index, error_text, completed_at, input_json, drained_at
566             FROM checkpoints
567             WHERE status = 'escalated' AND (?1 = 0 OR drained_at IS NULL)
568             ORDER BY node_name, item_index",
569        )?;
570        let rows = stmt.query_map([i64::from(outstanding_only)], |r| {
571            let index: i64 = r.get(1)?;
572            let input: Option<String> = r.get(4)?;
573            Ok(Escalation {
574                node: r.get(0)?,
575                // -1 is the whole-node sentinel, not a real item.
576                item: (index >= 0).then_some(index as usize),
577                reason: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
578                at: r.get(3)?,
579                // A row written before inputs were recorded parses as
580                // `None` here, which is exactly what it means: there is
581                // nothing to hand back. Callers must report that rather
582                // than quietly skipping the row.
583                input: input.and_then(|j| serde_json::from_str(&j).ok()),
584                drained_at: r.get(5)?,
585            })
586        })?;
587        rows.collect()
588    }
589
590    /// Stamp one escalation as exported.
591    ///
592    /// Called only after the manifest is safely on disk: a row claiming it
593    /// was handled when the write failed is worse than one exported twice.
594    pub fn mark_drained(&self, node_name: &str, item_index: Option<usize>) -> rusqlite::Result<()> {
595        self.lock().execute(
596            "UPDATE checkpoints SET drained_at = ?3
597             WHERE node_name = ?1 AND item_index = ?2 AND status = 'escalated'",
598            rusqlite::params![
599                node_name,
600                item_index.map(|i| i as i64).unwrap_or(-1),
601                now_marker()
602            ],
603        )?;
604        Ok(())
605    }
606
607    /// Record what `node_name` fanned out over, or verify it is unchanged.
608    ///
609    /// `Ok(Err(previous_digest))` means this node previously ran against a
610    /// *different* manifest. Item indices are only meaningful relative to one
611    /// specific manifest, so resuming would quietly pair recorded results
612    /// with entirely different inputs — no graph-level fingerprint can catch
613    /// an edit to the manifest file itself, which is why this exists.
614    ///
615    /// The outer `Result` is storage failure; the inner one is the verdict.
616    pub fn check_or_record_manifest(
617        &self,
618        node_name: &str,
619        digest: &str,
620        item_count: usize,
621    ) -> rusqlite::Result<Result<(), String>> {
622        let conn = self.lock();
623        let existing: Option<String> = match conn.query_row(
624            "SELECT digest FROM fanout_manifests WHERE node_name = ?1",
625            [node_name],
626            |r| r.get(0),
627        ) {
628            Ok(d) => Some(d),
629            Err(rusqlite::Error::QueryReturnedNoRows) => None,
630            Err(e) => return Err(e),
631        };
632        match existing {
633            Some(previous) if previous != digest => Ok(Err(previous)),
634            Some(_) => Ok(Ok(())),
635            None => {
636                conn.execute(
637                    "INSERT INTO fanout_manifests (node_name, digest, item_count)
638                     VALUES (?1, ?2, ?3)",
639                    rusqlite::params![node_name, digest, item_count as i64],
640                )?;
641                Ok(Ok(()))
642            }
643        }
644    }
645
646    /// The job's own terminal status. `Running` until [`Ledger::finish`] is
647    /// called.
648    pub fn job_status(&self) -> rusqlite::Result<LedgerJobStatus> {
649        let s: String = self
650            .lock()
651            .query_row("SELECT status FROM job_status", [], |r| r.get(0))?;
652        Ok(LedgerJobStatus::from_str(&s))
653    }
654
655    /// Record the job's terminal status (e.g. `"completed"`, `"failed"`,
656    /// `"cancelled"`).
657    pub fn finish(&self, status: &str) -> rusqlite::Result<()> {
658        self.lock()
659            .execute("UPDATE job_status SET status = ?1", [status])?;
660        Ok(())
661    }
662
663    /// Lock the connection. The mutex is only ever held for the duration of
664    /// one synchronous rusqlite call, never across an `.await` — so a
665    /// poisoned lock can only mean a prior call panicked mid-query, an
666    /// exceptional situation worth propagating loudly rather than papering
667    /// over.
668    fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
669        self.conn.lock().expect("ledger connection mutex poisoned")
670    }
671}
672
673/// The root directory jobs live under. Checks `$CUTTLEFISH_JOBS_HOME` first
674/// — set by `cuttlefish-run`'s project-scoping so a project's jobs/ledger
675/// state lives under `<project>/.cuttlefish/jobs` without also redirecting
676/// the (deliberately still-global) block catalog, which `$CUTTLEFISH_HOME`
677/// alone continues to control. Falls back to `$CUTTLEFISH_HOME/jobs` (or
678/// `~/.cuttlefish/jobs`) exactly as before when unset, so nothing about
679/// existing single-global-home behavior changes for a caller that never
680/// sets the new variable.
681pub fn jobs_root() -> Option<std::path::PathBuf> {
682    if let Ok(dir) = std::env::var("CUTTLEFISH_JOBS_HOME") {
683        return Some(std::path::PathBuf::from(dir));
684    }
685    crate::catalog::cuttlefish_home().map(|h| h.join("jobs"))
686}
687
688/// A completed_at marker. Plain wall-clock formatting, same as the
689/// catalog's `now_rfc3339` (`crate::catalog`) — this is a diagnostic field,
690/// not consulted by any resume logic, so precision/format choices here
691/// don't affect correctness.
692fn now_marker() -> String {
693    crate::catalog::now_rfc3339()
694}
695
696/// One concluded fan-out item, as the ledger recorded it.
697#[derive(Debug, Clone)]
698pub struct ConcludedRow {
699    /// The item's index in its manifest.
700    pub item: i64,
701    /// `completed`, `failed`, or `escalated`, exactly as stored.
702    pub status: String,
703    /// What the block returned, for a success.
704    pub output: Option<serde_json::Value>,
705    /// Why it did not, for anything else.
706    pub error: Option<String>,
707    /// When the item concluded, RFC 3339.
708    pub concluded_at: String,
709    /// The item's input as JSON text — its provenance. Absent on a ledger
710    /// written before the column existed.
711    pub source_input: Option<String>,
712}