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}