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    ) -> rusqlite::Result<()> {
286        self.lock().execute(
287            "INSERT OR REPLACE INTO checkpoints
288               (node_name, item_index, status, output_json, error_text, completed_at)
289             VALUES (?1, ?2, 'completed', ?3, NULL, ?4)",
290            rusqlite::params![
291                node_name,
292                item_index as i64,
293                output.to_string(),
294                now_marker()
295            ],
296        )?;
297        Ok(())
298    }
299
300    /// Record one fan-out item as having *concluded* in failure.
301    ///
302    /// Concluded is the operative word. An item still in flight when the
303    /// process died must leave no row at all, so that resume re-runs it —
304    /// whereas an item whose block genuinely returned `Fail` is recorded
305    /// here and never retried. That distinction is the entire basis of
306    /// fan-out resume semantics: it separates "this chunk is bad" from "we
307    /// were interrupted", without needing to ask which happened.
308    /// `input` is stored so the item can be handed back later — see
309    /// [`Ledger::escalations`]. Only failures carry it: a successful item's
310    /// input is still in the manifest and nobody needs it returned.
311    pub fn write_item_failed(
312        &self,
313        node_name: &str,
314        item_index: usize,
315        error: &str,
316        input: Option<&serde_json::Value>,
317    ) -> rusqlite::Result<()> {
318        self.lock().execute(
319            "INSERT OR REPLACE INTO checkpoints
320               (node_name, item_index, status, output_json, error_text, completed_at, input_json)
321             VALUES (?1, ?2, 'failed', NULL, ?3, ?4, ?5)",
322            rusqlite::params![
323                node_name,
324                item_index as i64,
325                error,
326                now_marker(),
327                input.map(|v| v.to_string())
328            ],
329        )?;
330        Ok(())
331    }
332
333    /// One item's recorded output, if it completed successfully.
334    pub fn get_item_completed(
335        &self,
336        node_name: &str,
337        item_index: usize,
338    ) -> rusqlite::Result<Option<serde_json::Value>> {
339        let row: Option<(String, Option<String>)> = match self.lock().query_row(
340            "SELECT status, output_json FROM checkpoints
341             WHERE node_name = ?1 AND item_index = ?2",
342            rusqlite::params![node_name, item_index as i64],
343            |r| Ok((r.get(0)?, r.get(1)?)),
344        ) {
345            Ok(row) => Some(row),
346            Err(rusqlite::Error::QueryReturnedNoRows) => None,
347            Err(e) => return Err(e),
348        };
349        Ok(match row {
350            Some((status, Some(json))) if status == "completed" => {
351                Some(serde_json::from_str(&json).expect("ledger never stores invalid JSON"))
352            }
353            _ => None,
354        })
355    }
356
357    /// Whether this item already concluded, either way — the resume check.
358    /// A concluded item is skipped; anything else is (re-)run.
359    pub fn item_concluded(&self, node_name: &str, item_index: usize) -> rusqlite::Result<bool> {
360        let count: i64 = self.lock().query_row(
361            "SELECT COUNT(*) FROM checkpoints WHERE node_name = ?1 AND item_index = ?2",
362            rusqlite::params![node_name, item_index as i64],
363            |r| r.get(0),
364        )?;
365        Ok(count > 0)
366    }
367
368    /// Every concluded item for `node_name`, in index order, as
369    /// `(index, output, error)` — exactly one of `output`/`error` is `Some`.
370    #[allow(clippy::type_complexity)]
371    pub fn concluded_items(
372        &self,
373        node_name: &str,
374    ) -> rusqlite::Result<Vec<(usize, Option<serde_json::Value>, Option<String>)>> {
375        let conn = self.lock();
376        let mut stmt = conn.prepare(
377            "SELECT item_index, status, output_json, error_text FROM checkpoints
378             WHERE node_name = ?1 AND item_index >= 0 ORDER BY item_index",
379        )?;
380        let rows = stmt.query_map([node_name], |r| {
381            let index: i64 = r.get(0)?;
382            let status: String = r.get(1)?;
383            let output: Option<String> = r.get(2)?;
384            let error: Option<String> = r.get(3)?;
385            Ok((
386                index as usize,
387                if status == "completed" {
388                    output.map(|j| {
389                        serde_json::from_str(&j).expect("ledger never stores invalid JSON")
390                    })
391                } else {
392                    None
393                },
394                // Anything not `completed` is a failure of some kind, and
395                // carries its error. Matching on `failed` alone would drop
396                // `escalated` items out of `failures.jsonl` entirely — they
397                // would count as concluded, count toward `failed`, and then
398                // silently vanish from the projection.
399                if status == "completed" { None } else { error },
400            ))
401        })?;
402        rows.collect()
403    }
404
405    /// Record that recovery was exhausted for `node_name`, giving up.
406    ///
407    /// Stored as an ordinary concluded failure with a distinct status rather
408    /// than in a table of its own — the composite key already carries node
409    /// and item, and an escalation *is* a kind of concluded failure. Pass
410    /// `None` for a whole node, `Some(i)` for one fan-out item.
411    pub fn write_escalated(
412        &self,
413        node_name: &str,
414        item_index: Option<usize>,
415        reason: &str,
416        input: Option<&serde_json::Value>,
417    ) -> rusqlite::Result<()> {
418        self.lock().execute(
419            "INSERT OR REPLACE INTO checkpoints
420               (node_name, item_index, status, output_json, error_text, completed_at, input_json)
421             VALUES (?1, ?2, 'escalated', NULL, ?3, ?4, ?5)",
422            rusqlite::params![
423                node_name,
424                item_index.map(|i| i as i64).unwrap_or(-1),
425                reason,
426                now_marker(),
427                input.map(|v| v.to_string())
428            ],
429        )?;
430        Ok(())
431    }
432
433    /// Open an existing ledger **read-only**, running no schema statements.
434    ///
435    /// [`Ledger::open`] is a writer: it runs `CREATE TABLE IF NOT EXISTS` and
436    /// `ALTER TABLE` so a fresh or older ledger becomes usable. That is right
437    /// when opening the ledger you are about to write, and wrong for reading
438    /// somebody else's — DDL takes an exclusive lock, so merely *listing*
439    /// escalations across every job on the machine could lock the ledger of a
440    /// job that is currently running and fail it.
441    ///
442    /// This opens with `SQLITE_OPEN_READ_ONLY` and touches no schema. A ledger
443    /// predating the drain columns therefore reads with them absent, which is
444    /// handled rather than migrated: such rows come back with no input, which
445    /// is exactly what they have.
446    pub fn open_read_only(path: &Path) -> Result<Self, LedgerError> {
447        let conn = Connection::open_with_flags(
448            path,
449            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
450        )?;
451        conn.busy_timeout(std::time::Duration::from_secs(5))?;
452        Ok(Self {
453            conn: Mutex::new(conn),
454            job_dir: path
455                .parent()
456                .unwrap_or_else(|| Path::new("."))
457                .to_path_buf(),
458        })
459    }
460
461    /// Whether this ledger has the columns draining needs.
462    ///
463    /// Read-only opens do not migrate, so a caller has to be able to ask.
464    fn has_drain_columns(&self) -> bool {
465        let conn = self.lock();
466        let Ok(mut stmt) = conn.prepare("PRAGMA table_info(checkpoints)") else {
467            return false;
468        };
469        let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(1)) else {
470            return false;
471        };
472        let columns: Vec<String> = rows.filter_map(Result::ok).collect();
473        columns.iter().any(|c| c == "input_json") && columns.iter().any(|c| c == "drained_at")
474    }
475
476    /// What this job gave up on and nobody has handled yet — the queue.
477    pub fn escalations(&self) -> rusqlite::Result<Vec<Escalation>> {
478        self.query_escalations(true)
479    }
480
481    /// Every escalation this job ever recorded, drained or not.
482    ///
483    /// Draining marks rather than deletes, so this is the historical
484    /// record. What went wrong stays worth knowing after it's been handled
485    /// — especially when the retry fails too.
486    pub fn all_escalations(&self) -> rusqlite::Result<Vec<Escalation>> {
487        self.query_escalations(false)
488    }
489
490    fn query_escalations(&self, outstanding_only: bool) -> rusqlite::Result<Vec<Escalation>> {
491        // A ledger opened read-only is never migrated, so it may genuinely
492        // lack these columns. Selecting them would error; reporting the rows
493        // without an input is both correct and what the drain path already
494        // knows how to describe.
495        if !self.has_drain_columns() {
496            let conn = self.lock();
497            let mut stmt = conn.prepare(
498                "SELECT node_name, item_index, error_text, completed_at FROM checkpoints
499                 WHERE status = 'escalated' ORDER BY node_name, item_index",
500            )?;
501            let rows = stmt.query_map([], |r| {
502                let index: i64 = r.get(1)?;
503                Ok(Escalation {
504                    node: r.get(0)?,
505                    item: (index >= 0).then_some(index as usize),
506                    reason: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
507                    at: r.get(3)?,
508                    input: None,
509                    drained_at: None,
510                })
511            })?;
512            return rows.collect();
513        }
514        let conn = self.lock();
515        let mut stmt = conn.prepare(
516            "SELECT node_name, item_index, error_text, completed_at, input_json, drained_at
517             FROM checkpoints
518             WHERE status = 'escalated' AND (?1 = 0 OR drained_at IS NULL)
519             ORDER BY node_name, item_index",
520        )?;
521        let rows = stmt.query_map([i64::from(outstanding_only)], |r| {
522            let index: i64 = r.get(1)?;
523            let input: Option<String> = r.get(4)?;
524            Ok(Escalation {
525                node: r.get(0)?,
526                // -1 is the whole-node sentinel, not a real item.
527                item: (index >= 0).then_some(index as usize),
528                reason: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
529                at: r.get(3)?,
530                // A row written before inputs were recorded parses as
531                // `None` here, which is exactly what it means: there is
532                // nothing to hand back. Callers must report that rather
533                // than quietly skipping the row.
534                input: input.and_then(|j| serde_json::from_str(&j).ok()),
535                drained_at: r.get(5)?,
536            })
537        })?;
538        rows.collect()
539    }
540
541    /// Stamp one escalation as exported.
542    ///
543    /// Called only after the manifest is safely on disk: a row claiming it
544    /// was handled when the write failed is worse than one exported twice.
545    pub fn mark_drained(&self, node_name: &str, item_index: Option<usize>) -> rusqlite::Result<()> {
546        self.lock().execute(
547            "UPDATE checkpoints SET drained_at = ?3
548             WHERE node_name = ?1 AND item_index = ?2 AND status = 'escalated'",
549            rusqlite::params![
550                node_name,
551                item_index.map(|i| i as i64).unwrap_or(-1),
552                now_marker()
553            ],
554        )?;
555        Ok(())
556    }
557
558    /// Record what `node_name` fanned out over, or verify it is unchanged.
559    ///
560    /// `Ok(Err(previous_digest))` means this node previously ran against a
561    /// *different* manifest. Item indices are only meaningful relative to one
562    /// specific manifest, so resuming would quietly pair recorded results
563    /// with entirely different inputs — no graph-level fingerprint can catch
564    /// an edit to the manifest file itself, which is why this exists.
565    ///
566    /// The outer `Result` is storage failure; the inner one is the verdict.
567    pub fn check_or_record_manifest(
568        &self,
569        node_name: &str,
570        digest: &str,
571        item_count: usize,
572    ) -> rusqlite::Result<Result<(), String>> {
573        let conn = self.lock();
574        let existing: Option<String> = match conn.query_row(
575            "SELECT digest FROM fanout_manifests WHERE node_name = ?1",
576            [node_name],
577            |r| r.get(0),
578        ) {
579            Ok(d) => Some(d),
580            Err(rusqlite::Error::QueryReturnedNoRows) => None,
581            Err(e) => return Err(e),
582        };
583        match existing {
584            Some(previous) if previous != digest => Ok(Err(previous)),
585            Some(_) => Ok(Ok(())),
586            None => {
587                conn.execute(
588                    "INSERT INTO fanout_manifests (node_name, digest, item_count)
589                     VALUES (?1, ?2, ?3)",
590                    rusqlite::params![node_name, digest, item_count as i64],
591                )?;
592                Ok(Ok(()))
593            }
594        }
595    }
596
597    /// The job's own terminal status. `Running` until [`Ledger::finish`] is
598    /// called.
599    pub fn job_status(&self) -> rusqlite::Result<LedgerJobStatus> {
600        let s: String = self
601            .lock()
602            .query_row("SELECT status FROM job_status", [], |r| r.get(0))?;
603        Ok(LedgerJobStatus::from_str(&s))
604    }
605
606    /// Record the job's terminal status (e.g. `"completed"`, `"failed"`,
607    /// `"cancelled"`).
608    pub fn finish(&self, status: &str) -> rusqlite::Result<()> {
609        self.lock()
610            .execute("UPDATE job_status SET status = ?1", [status])?;
611        Ok(())
612    }
613
614    /// Lock the connection. The mutex is only ever held for the duration of
615    /// one synchronous rusqlite call, never across an `.await` — so a
616    /// poisoned lock can only mean a prior call panicked mid-query, an
617    /// exceptional situation worth propagating loudly rather than papering
618    /// over.
619    fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
620        self.conn.lock().expect("ledger connection mutex poisoned")
621    }
622}
623
624/// The root directory jobs live under. Checks `$CUTTLEFISH_JOBS_HOME` first
625/// — set by `cuttlefish-run`'s project-scoping so a project's jobs/ledger
626/// state lives under `<project>/.cuttlefish/jobs` without also redirecting
627/// the (deliberately still-global) block catalog, which `$CUTTLEFISH_HOME`
628/// alone continues to control. Falls back to `$CUTTLEFISH_HOME/jobs` (or
629/// `~/.cuttlefish/jobs`) exactly as before when unset, so nothing about
630/// existing single-global-home behavior changes for a caller that never
631/// sets the new variable.
632pub fn jobs_root() -> Option<std::path::PathBuf> {
633    if let Ok(dir) = std::env::var("CUTTLEFISH_JOBS_HOME") {
634        return Some(std::path::PathBuf::from(dir));
635    }
636    crate::catalog::cuttlefish_home().map(|h| h.join("jobs"))
637}
638
639/// A completed_at marker. Plain wall-clock formatting, same as the
640/// catalog's `now_rfc3339` (`crate::catalog`) — this is a diagnostic field,
641/// not consulted by any resume logic, so precision/format choices here
642/// don't affect correctness.
643fn now_marker() -> String {
644    crate::catalog::now_rfc3339()
645}