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}