import { DatabaseSync } from 'node:sqlite' import type { StrongRef } from '@radial/core' /** * A check run has no `awaiting_input`/`fulfilled` distinction the way a turn does — a check either * finished (`done`, a checkrun record was written) or it did not (`crashed`, retryable until * `gave_up`). `running` is the in-flight marker orphan reconciliation keys off after a restart. */ export type CheckState = 'running' | 'done' | 'crashed' | 'gave_up' export interface CheckRow { artifactUri: string artifactCid: string commit: string state: CheckState attempts: number nextEligibleAt?: string containerLabel?: string checkrunRef?: StrongRef updatedAt: string } export interface CheckLedgerOptions { retryBound?: number cooldownMs?: number now?: () => string } interface Row { artifact_uri: string artifact_cid: string commit_sha: string state: string | null attempts: number next_eligible_at: string | null container_label: string | null checkrun_ref_uri: string | null checkrun_ref_cid: string | null updated_at: string | null } function toRow(row: Row): CheckRow { return { artifactUri: row.artifact_uri, artifactCid: row.artifact_cid, commit: row.commit_sha, state: (row.state ?? 'running') as CheckState, attempts: row.attempts, ...(row.next_eligible_at !== null ? { nextEligibleAt: row.next_eligible_at } : {}), ...(row.container_label !== null ? { containerLabel: row.container_label } : {}), ...(row.checkrun_ref_uri !== null && row.checkrun_ref_cid !== null ? { checkrunRef: { uri: row.checkrun_ref_uri, cid: row.checkrun_ref_cid } } : {}), updatedAt: row.updated_at ?? '', } } /** Composite key for a check run: the exact artifact version (uri#cid) plus the commit it links. */ export interface CheckKey { artifactUri: string artifactCid: string commit: string } /** * Durable record of every check run the daemon has attempted, keyed by the artifact version * (uri#cid) and the commit it linked. Deliberately a *separate* table from `TurnLedger`'s `turns` * (its own DB file/table `checkruns_ledger`): checks and turns are independent workstreams with * their own retry budgets, and the two must never share or clobber each other's rows. Together with * the index-side dedup gate (a trusted checkrun already authored by one of this daemon's DIDs), this * covers the in-flight / ingestion-lag window — the span after we launch a container but before its * checkrun record has been ingested back into the index. */ export class CheckLedger { readonly #database: DatabaseSync readonly #retryBound: number readonly #cooldownMs: number readonly #now: () => string constructor(path = ':memory:', options: CheckLedgerOptions = {}) { this.#database = new DatabaseSync(path) this.#database.exec(` CREATE TABLE IF NOT EXISTS checkruns_ledger ( artifact_uri TEXT NOT NULL, artifact_cid TEXT NOT NULL, commit_sha TEXT NOT NULL, state TEXT, attempts INTEGER NOT NULL DEFAULT 0, next_eligible_at TEXT, container_label TEXT, checkrun_ref_uri TEXT, checkrun_ref_cid TEXT, updated_at TEXT, PRIMARY KEY (artifact_uri, artifact_cid, commit_sha) ) STRICT; `) this.#retryBound = options.retryBound ?? 3 this.#cooldownMs = options.cooldownMs ?? 300_000 this.#now = options.now ?? (() => new Date().toISOString()) } get(key: CheckKey): CheckRow | undefined { const row = this.#database .prepare('SELECT * FROM checkruns_ledger WHERE artifact_uri = ? AND artifact_cid = ? AND commit_sha = ?') .get(key.artifactUri, key.artifactCid, key.commit) as Row | undefined return row ? toRow(row) : undefined } eligible(key: CheckKey, now: string = this.#now()): boolean { const row = this.get(key) if (!row) return true switch (row.state) { case 'running': case 'done': case 'gave_up': return false case 'crashed': return row.nextEligibleAt !== undefined && now >= row.nextEligibleAt default: return true } } markRunning(key: CheckKey, fields: { containerLabel: string }): void { const now = this.#now() this.#database .prepare(` INSERT INTO checkruns_ledger (artifact_uri, artifact_cid, commit_sha, state, attempts, next_eligible_at, container_label, updated_at) VALUES (?, ?, ?, 'running', 0, NULL, ?, ?) ON CONFLICT (artifact_uri, artifact_cid, commit_sha) DO UPDATE SET state = 'running', next_eligible_at = NULL, container_label = excluded.container_label, updated_at = excluded.updated_at `) .run(key.artifactUri, key.artifactCid, key.commit, fields.containerLabel, now) } markDone(key: CheckKey, checkrunRef?: StrongRef): void { const now = this.#now() const existing = this.get(key) this.#database .prepare(` INSERT INTO checkruns_ledger (artifact_uri, artifact_cid, commit_sha, state, attempts, checkrun_ref_uri, checkrun_ref_cid, updated_at) VALUES (?, ?, ?, 'done', ?, ?, ?, ?) ON CONFLICT (artifact_uri, artifact_cid, commit_sha) DO UPDATE SET state = 'done', checkrun_ref_uri = excluded.checkrun_ref_uri, checkrun_ref_cid = excluded.checkrun_ref_cid, updated_at = excluded.updated_at `) .run( key.artifactUri, key.artifactCid, key.commit, existing?.attempts ?? 0, checkrunRef?.uri ?? null, checkrunRef?.cid ?? null, now, ) } /** Increments attempts; past `retryBound` the check is given up on for good. Caller logs loudly on `gave_up`. */ markCrashed(key: CheckKey): { state: CheckState; attempts: number } { const now = this.#now() const existing = this.get(key) const attempts = (existing?.attempts ?? 0) + 1 const gaveUp = attempts >= this.#retryBound const state: CheckState = gaveUp ? 'gave_up' : 'crashed' const nextEligibleAt = gaveUp ? null : new Date(Date.parse(now) + this.#cooldownMs).toISOString() this.#database .prepare(` INSERT INTO checkruns_ledger (artifact_uri, artifact_cid, commit_sha, state, attempts, next_eligible_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (artifact_uri, artifact_cid, commit_sha) DO UPDATE SET state = excluded.state, attempts = excluded.attempts, next_eligible_at = excluded.next_eligible_at, updated_at = excluded.updated_at `) .run(key.artifactUri, key.artifactCid, key.commit, state, attempts, nextEligibleAt, now) return { state, attempts } } /** Clears every row for an artifact uri (all versions/commits) so its checks re-run — the * operator-facing `radiald check reset ` escape hatch, mirroring `turn reset`. */ reset(artifactUri: string): void { this.#database.prepare('DELETE FROM checkruns_ledger WHERE artifact_uri = ?').run(artifactUri) } /** Rows left in `running` — orphans to reconcile if the daemon restarted mid-check. */ running(): CheckRow[] { const rows = this.#database.prepare("SELECT * FROM checkruns_ledger WHERE state = 'running'").all() as Row[] return rows.map(toRow) } close(): void { this.#database.close() } }