ai-foreman 1.7.12 → 1.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +15 -0
  2. package/dist/activity.d.ts +5 -1
  3. package/dist/activity.js +1 -1
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +133 -7
  6. package/dist/adapters/codex.d.ts +5 -0
  7. package/dist/adapters/codex.js +121 -11
  8. package/dist/adapters/recovering.d.ts +2 -0
  9. package/dist/adapters/recovering.js +11 -1
  10. package/dist/adapters/types.d.ts +47 -1
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +7 -1
  13. package/dist/branch/resume.js +11 -1
  14. package/dist/branch/runner.d.ts +11 -3
  15. package/dist/branch/runner.js +114 -60
  16. package/dist/buildRuns.d.ts +6 -2
  17. package/dist/buildRuns.js +76 -32
  18. package/dist/cli/doctor.d.ts +7 -0
  19. package/dist/cli/doctor.js +84 -1
  20. package/dist/cli/manager.d.ts +17 -0
  21. package/dist/cli/manager.js +135 -0
  22. package/dist/cli/recovery.d.ts +4 -0
  23. package/dist/cli/recovery.js +80 -0
  24. package/dist/cli/start.js +289 -53
  25. package/dist/cli/status.js +13 -2
  26. package/dist/cli/tickets.js +26 -8
  27. package/dist/config.d.ts +2 -0
  28. package/dist/config.js +23 -0
  29. package/dist/continuity.js +14 -9
  30. package/dist/diagnostics.d.ts +25 -0
  31. package/dist/diagnostics.js +428 -0
  32. package/dist/foreman.d.ts +16 -6
  33. package/dist/foreman.js +70 -82
  34. package/dist/handoffs.d.ts +5 -1
  35. package/dist/handoffs.js +4 -1
  36. package/dist/index.js +6 -0
  37. package/dist/log.d.ts +2 -2
  38. package/dist/log.js +4 -1
  39. package/dist/managerPacket.d.ts +25 -0
  40. package/dist/managerPacket.js +83 -0
  41. package/dist/markers.d.ts +1 -1
  42. package/dist/markers.js +4 -1
  43. package/dist/observability.d.ts +248 -0
  44. package/dist/observability.js +1018 -0
  45. package/dist/planChanges.d.ts +4 -0
  46. package/dist/planChanges.js +16 -0
  47. package/dist/projectDiagnostics.d.ts +27 -0
  48. package/dist/projectDiagnostics.js +407 -0
  49. package/dist/qaRecovery.d.ts +140 -0
  50. package/dist/qaRecovery.js +595 -0
  51. package/dist/qaReview.d.ts +128 -14
  52. package/dist/qaReview.js +974 -57
  53. package/dist/qaSnapshot.d.ts +31 -5
  54. package/dist/qaSnapshot.js +209 -134
  55. package/dist/recoveryPolicy.d.ts +37 -0
  56. package/dist/recoveryPolicy.js +251 -0
  57. package/dist/roles.js +1 -0
  58. package/dist/supervisor.d.ts +40 -0
  59. package/dist/supervisor.js +102 -0
  60. package/dist/ticketHistoryReader.d.ts +19 -0
  61. package/dist/ticketHistoryReader.js +58 -0
  62. package/dist/tickets/details.d.ts +8 -0
  63. package/dist/tickets/details.js +59 -11
  64. package/dist/tickets/output.d.ts +5 -0
  65. package/dist/tickets/output.js +73 -0
  66. package/dist/tickets/stateDb.d.ts +1 -0
  67. package/dist/tickets/stateDb.js +5 -0
  68. package/dist/workflowDb.d.ts +31 -2
  69. package/dist/workflowDb.js +192 -23
  70. package/dist/workflowReader.d.ts +42 -0
  71. package/dist/workflowReader.js +171 -0
  72. package/foreman.yaml +9 -0
  73. package/package.json +51 -3
@@ -0,0 +1,73 @@
1
+ import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, statSync, } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
4
+ import { DELIVERY_FILE } from "./delivery.js";
5
+ export function appendTicketOutput(projectDir, outputFile, payload) {
6
+ const target = resolve(outputFile);
7
+ assertSafeOutputPath(projectDir, target);
8
+ const existed = existsSync(target);
9
+ if (existed && statSync(target).isDirectory()) {
10
+ throw new Error(`output destination is a directory: ${target}`);
11
+ }
12
+ mkdirSync(dirname(target), { recursive: true });
13
+ const separator = existed ? separatorForExistingFile(target) : "";
14
+ appendFileSync(target, `${separator}${payload}`, {
15
+ encoding: "utf8",
16
+ flag: existed ? "a" : "wx",
17
+ mode: 0o600,
18
+ });
19
+ return { disposition: existed ? "appended" : "created", path: target };
20
+ }
21
+ function assertSafeOutputPath(projectDir, target) {
22
+ const root = resolve(projectDir);
23
+ const fixedProtectedPaths = [
24
+ join(root, ".tickets", "config.yaml"),
25
+ join(root, DELIVERY_FILE),
26
+ ];
27
+ rejectProtectedIdentity(target, fixedProtectedPaths);
28
+ const config = loadTicketsConfig(root);
29
+ const paths = resolveTicketPaths(config, root);
30
+ rejectProtectedIdentity(target, [paths.tickets, paths.stateDb]);
31
+ }
32
+ function rejectProtectedIdentity(target, protectedPaths) {
33
+ const targetIdentity = resolvedIdentity(target);
34
+ const protectedPath = protectedPaths.find((candidate) => resolvedIdentity(candidate) === targetIdentity);
35
+ if (protectedPath) {
36
+ throw new Error(`refusing to write ticket output to protected tracker input: ${resolve(protectedPath)}`);
37
+ }
38
+ }
39
+ function resolvedIdentity(path) {
40
+ const absolute = resolve(path);
41
+ try {
42
+ lstatSync(absolute);
43
+ return realpathSync(absolute);
44
+ }
45
+ catch {
46
+ let ancestor = dirname(absolute);
47
+ while (!existsSync(ancestor)) {
48
+ const parent = dirname(ancestor);
49
+ if (parent === ancestor)
50
+ return absolute;
51
+ ancestor = parent;
52
+ }
53
+ const suffix = relative(ancestor, absolute);
54
+ return resolve(realpathSync(ancestor), ...suffix.split(sep).filter(Boolean));
55
+ }
56
+ }
57
+ function separatorForExistingFile(path) {
58
+ const size = statSync(path).size;
59
+ if (size === 0)
60
+ return "";
61
+ const length = Math.min(2, size);
62
+ const tail = Buffer.alloc(length);
63
+ const fd = openSync(path, "r");
64
+ try {
65
+ readSync(fd, tail, 0, length, size - length);
66
+ }
67
+ finally {
68
+ closeSync(fd);
69
+ }
70
+ if (tail.length >= 2 && tail.at(-1) === 0x0a && tail.at(-2) === 0x0a)
71
+ return "";
72
+ return tail.at(-1) === 0x0a ? "\n" : "\n\n";
73
+ }
@@ -111,6 +111,7 @@ export declare class StateDb {
111
111
  deleteEvents(ids: number[]): void;
112
112
  getRecentEvents(limit: number): TicketEvent[];
113
113
  getTicketEvents(ticketId: string): TicketEvent[];
114
+ getAllTicketEvents(): TicketEvent[];
114
115
  insertValidationSnapshot(snap: Omit<ValidationSnapshot, "id">): void;
115
116
  getRecentValidationSnapshot(): ValidationSnapshot | undefined;
116
117
  getValidationSnapshots(scope?: string): ValidationSnapshot[];
@@ -287,6 +287,11 @@ export class StateDb {
287
287
  .prepare("SELECT * FROM ticket_events WHERE ticket_id = ? ORDER BY timestamp, id")
288
288
  .all(ticketId);
289
289
  }
290
+ getAllTicketEvents() {
291
+ return this.db
292
+ .prepare("SELECT * FROM ticket_events WHERE ticket_id IS NOT NULL ORDER BY timestamp, id")
293
+ .all();
294
+ }
290
295
  // ── validation_snapshots ──────────────────────────────────────────────────
291
296
  insertValidationSnapshot(snap) {
292
297
  this.db.prepare(`
@@ -1,4 +1,5 @@
1
- import type { BuildRecoveryDecisionReceipt, ContextSample, ContinuityCheckpoint, ContinuityDelta, ContinuityEvent, ContinuityHead, ContinuityHeadState, HandoffLineage, HandoffManifestV1, LiveSettingsAcknowledgment, OperationLifecycle, ProviderSessionRefV1, ResolvedAgentSettings, SessionUsageSample, WorkflowIssue } from "rafi-spec";
1
+ import type { BuildRecoveryDecisionReceipt, ContextSample, ContinuityCheckpoint, ContinuityDelta, ContinuityEvent, ContinuityHead, ContinuityHeadState, HandoffLineage, HandoffManifestV1, LiveSettingsAcknowledgment, OperationLifecycle, PendingHumanDecision, ProviderSessionRefV1, RecoveryAttemptOutcome, RecoveryAttemptReceipt, ResolvedAutonomyPolicy, ResolvedAgentSettings, SessionUsageSample, StructuredInterruption, SupervisorState, WorkflowIssue } from "rafi-spec";
2
+ import type { BranchResumeSession } from "./branch/resume.js";
2
3
  export declare const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
3
4
  export type WorkflowKind = "plan" | "ticket-plan" | "ticket-populate" | "uninstall" | "build" | "qa-remediation" | "recovery" | "legacy";
4
5
  export type WorkflowRunStatus = "running" | "paused" | "blocked" | "completed" | "failed" | "cancelled" | "superseded";
@@ -38,6 +39,8 @@ export interface ProjectLease {
38
39
  }
39
40
  /** Read the current project lease without creating or migrating recovery state. */
40
41
  export declare function readCurrentWorkflowLease(projectDir: string): ProjectLease | undefined;
42
+ /** Heartbeat-only writer: one conditional UPDATE and no migrations, imports, events, or snapshots. */
43
+ export declare function heartbeatCurrentWorkflowLease(projectDir: string, lease: ProjectLease, now?: Date): ProjectLease;
41
44
  export interface PublicationTransaction {
42
45
  transactionId: string;
43
46
  runId: string;
@@ -146,6 +149,27 @@ export declare class WorkflowDb {
146
149
  providerSessionBindings(sessionId?: string, role?: string): ProviderSessionRefV1[];
147
150
  recordIssue(runId: string, issue: WorkflowIssue): number;
148
151
  issues(runId: string): WorkflowIssue[];
152
+ freezeAutonomyPolicy(runId: string, policy: ResolvedAutonomyPolicy, now?: Date): ResolvedAutonomyPolicy;
153
+ autonomyPolicy(runId: string): ResolvedAutonomyPolicy | undefined;
154
+ recordInterruption(interruption: StructuredInterruption): StructuredInterruption;
155
+ interruptions(runId: string): StructuredInterruption[];
156
+ recoveryAttemptCount(runId: string, ticket: string | undefined, phase: string, cause: string, operationKey: string): number;
157
+ recordRecoveryAttempt(receipt: RecoveryAttemptReceipt): RecoveryAttemptReceipt;
158
+ updateRecoveryAttempt(attemptId: string, outcome: RecoveryAttemptOutcome, detail?: string, now?: Date): RecoveryAttemptReceipt;
159
+ recoveryAttempts(runId: string): RecoveryAttemptReceipt[];
160
+ ensureHumanDecision(input: {
161
+ decisionKey: string;
162
+ runId: string;
163
+ interruptionId: string;
164
+ prompt: string;
165
+ choices: PendingHumanDecision["choices"];
166
+ evidence?: PendingHumanDecision["evidence"];
167
+ }, now?: Date): PendingHumanDecision;
168
+ humanDecision(decisionId: string): PendingHumanDecision | undefined;
169
+ pendingHumanDecisions(runId: string): PendingHumanDecision[];
170
+ answerHumanDecision(runId: string, decisionId: string, choiceId: string, now?: Date): PendingHumanDecision;
171
+ putSupervisorState(runId: string, state: SupervisorState, now?: Date): SupervisorState;
172
+ supervisorState(runId: string): SupervisorState | undefined;
149
173
  planOperation(input: {
150
174
  runId: string;
151
175
  idempotencyKey: string;
@@ -159,7 +183,7 @@ export declare class WorkflowDb {
159
183
  }, now?: Date): OperationRecord;
160
184
  operation(idempotencyKey: string): OperationRecord | undefined;
161
185
  operations(runId: string): OperationRecord[];
162
- putEvidence(kind: "handoff" | "diff" | "test", value: string | Buffer, now?: Date): string;
186
+ putEvidence(kind: "handoff" | "diff" | "test" | "qa", value: string | Buffer, now?: Date): string;
163
187
  getEvidence(digest: string): Buffer | undefined;
164
188
  stageHandoff(manifest: HandoffManifestV1, markdown: string): HandoffLineage;
165
189
  acceptHandoff(runId: string, generation: number, successor: string | ProviderSessionRefV1, now?: Date): HandoffLineage;
@@ -212,6 +236,11 @@ export declare class WorkflowDb {
212
236
  heartbeatLease(lease: ProjectLease, now?: Date): ProjectLease;
213
237
  releaseLease(lease: ProjectLease, now?: Date): void;
214
238
  currentLease(): ProjectLease | undefined;
239
+ recordBranchResumeSession(runId: string, session: BranchResumeSession, now?: Date): void;
240
+ completeBranchResumeSession(runId: string, ticket: string, status?: "completed" | "superseded", now?: Date): void;
241
+ branchResumeSessions(activeOnly?: boolean): BranchResumeSession[];
242
+ pruneTerminalTelemetry(retentionDays: number, now?: Date): number;
243
+ compactStorage(): void;
215
244
  importLegacyOnce(now?: Date): number;
216
245
  private upsertContinuityHead;
217
246
  private refreshRunContinuityHead;
@@ -25,6 +25,24 @@ export function readCurrentWorkflowLease(projectDir) {
25
25
  db?.close();
26
26
  }
27
27
  }
28
+ /** Heartbeat-only writer: one conditional UPDATE and no migrations, imports, events, or snapshots. */
29
+ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date()) {
30
+ const path = join(resolve(projectDir), WORKFLOW_DB_FILE);
31
+ if (!existsSync(path))
32
+ throw new Error("workflow recovery database not found");
33
+ const db = new Database(path, { fileMustExist: true });
34
+ try {
35
+ const at = now.toISOString();
36
+ const result = db.prepare("UPDATE project_lease SET heartbeat_at=? WHERE singleton=1 AND owner=? AND generation=? AND run_id=?")
37
+ .run(at, lease.owner, lease.generation, lease.runId);
38
+ if (result.changes !== 1)
39
+ throw new Error("workflow lease ownership changed");
40
+ return { ...lease, heartbeatAt: at };
41
+ }
42
+ finally {
43
+ db.close();
44
+ }
45
+ }
28
46
  /** Authoritative persistence for every resumable workflow in a project. */
29
47
  export class WorkflowDb {
30
48
  projectDir;
@@ -226,6 +244,11 @@ export class WorkflowDb {
226
244
  const scoped = sessionParts(typeof session === "object" ? session : undefined, typeof session === "string" ? session : undefined);
227
245
  if (scoped.ref)
228
246
  this.recordProviderSessionBinding(scoped.ref, now);
247
+ const duplicate = this.db.prepare(`SELECT 1 FROM provider_sessions WHERE run_id=? AND role=? AND stream=? AND provider=? AND model=?
248
+ AND COALESCE(session_id,'')=COALESCE(?,'') AND COALESCE(session_key,'')=COALESCE(?,'') AND transition=? AND settings_revision=? ORDER BY id DESC LIMIT 1`)
249
+ .get(runId, role, stream, settings.make, settings.model, scoped.id, scoped.key, transition, settings.settings_revision);
250
+ if (duplicate)
251
+ return;
229
252
  this.db.prepare(`INSERT INTO provider_sessions(run_id,role,stream,provider,model,session_id,session_key,session_ref_json,transition,settings_revision,created_at)
230
253
  VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(runId, role, stream, settings.make, settings.model, scoped.id, scoped.key, scoped.refJson, transition, settings.settings_revision, now.toISOString());
231
254
  }
@@ -266,6 +289,105 @@ export class WorkflowDb {
266
289
  issues(runId) {
267
290
  return this.db.prepare("SELECT issue_json FROM workflow_issues WHERE run_id=? ORDER BY issue_id").all(runId).map((row) => parseJson(row.issue_json));
268
291
  }
292
+ freezeAutonomyPolicy(runId, policy, now = new Date()) {
293
+ this.ensureRun(runId, "build", now);
294
+ this.db.prepare("INSERT INTO run_autonomy_policy(run_id,digest,policy_json,frozen_at) VALUES(?,?,?,?) ON CONFLICT(run_id) DO NOTHING")
295
+ .run(runId, policy.digest, json(policy), now.toISOString());
296
+ return this.autonomyPolicy(runId);
297
+ }
298
+ autonomyPolicy(runId) {
299
+ const row = this.db.prepare("SELECT policy_json FROM run_autonomy_policy WHERE run_id=?").get(runId);
300
+ return row ? parseJson(row.policy_json) : undefined;
301
+ }
302
+ recordInterruption(interruption) {
303
+ this.ensureRun(interruption.runId);
304
+ this.db.transaction(() => {
305
+ this.db.prepare(`INSERT INTO recovery_interruptions(interruption_id,run_id,code,domain,phase,cause,dispatch_state,operation_key,interruption_json,created_at)
306
+ VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(interruption_id) DO NOTHING`).run(interruption.id, interruption.runId, interruption.code, interruption.domain, interruption.phase, interruption.cause, interruption.dispatchState, interruption.operation?.idempotencyKey ?? null, json(interruption), interruption.occurredAt);
307
+ this.insertEvent(interruption.runId, "recovery_interruption", interruption.phase, { interruptionId: interruption.id, code: interruption.code, domain: interruption.domain }, interruption.occurredAt);
308
+ })();
309
+ return interruption;
310
+ }
311
+ interruptions(runId) {
312
+ return this.db.prepare("SELECT interruption_json FROM recovery_interruptions WHERE run_id=? ORDER BY created_at,interruption_id").all(runId)
313
+ .map((row) => parseJson(row.interruption_json));
314
+ }
315
+ recoveryAttemptCount(runId, ticket, phase, cause, operationKey) {
316
+ const row = this.db.prepare(`SELECT COUNT(*) AS count FROM recovery_attempts
317
+ WHERE run_id=? AND ticket IS ? AND phase=? AND cause=? AND operation_key=? AND outcome IN ('intended','started','succeeded','failed')`)
318
+ .get(runId, ticket ?? null, phase, cause, operationKey);
319
+ return row.count;
320
+ }
321
+ recordRecoveryAttempt(receipt) {
322
+ this.ensureRun(receipt.runId);
323
+ this.db.transaction(() => {
324
+ this.db.prepare(`INSERT INTO recovery_attempts(attempt_id,run_id,ticket,phase,cause,operation_key,attempt,disposition,action,outcome,receipt_json,intended_at,updated_at)
325
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(receipt.attemptId, receipt.runId, receipt.ticket ?? null, receipt.phase, receipt.cause, receipt.operationKey, receipt.attempt, receipt.disposition, receipt.action, receipt.outcome, json(receipt), receipt.intendedAt, receipt.intendedAt);
326
+ this.insertEvent(receipt.runId, "recovery_attempt_intended", receipt.phase, { attemptId: receipt.attemptId, attempt: receipt.attempt, action: receipt.action }, receipt.intendedAt);
327
+ })();
328
+ return receipt;
329
+ }
330
+ updateRecoveryAttempt(attemptId, outcome, detail, now = new Date()) {
331
+ const row = this.db.prepare("SELECT receipt_json FROM recovery_attempts WHERE attempt_id=?").get(attemptId);
332
+ if (!row)
333
+ throw new Error(`recovery attempt not found: ${attemptId}`);
334
+ const prior = parseJson(row.receipt_json);
335
+ const at = now.toISOString();
336
+ const next = { ...prior, outcome, ...(outcome === "started" ? { startedAt: at } : {}), ...(["succeeded", "failed", "cancelled"].includes(outcome) ? { completedAt: at } : {}), ...(detail ? { detail } : {}) };
337
+ this.db.prepare("UPDATE recovery_attempts SET outcome=?,receipt_json=?,updated_at=? WHERE attempt_id=?").run(outcome, json(next), at, attemptId);
338
+ this.insertEvent(next.runId, `recovery_attempt_${outcome}`, next.phase, { attemptId, detail }, at);
339
+ return next;
340
+ }
341
+ recoveryAttempts(runId) {
342
+ return this.db.prepare("SELECT receipt_json FROM recovery_attempts WHERE run_id=? ORDER BY intended_at,attempt_id").all(runId)
343
+ .map((row) => parseJson(row.receipt_json));
344
+ }
345
+ ensureHumanDecision(input, now = new Date()) {
346
+ this.ensureRun(input.runId);
347
+ const existing = this.db.prepare("SELECT decision_json FROM human_decisions WHERE decision_key=?").get(input.decisionKey);
348
+ if (existing)
349
+ return parseJson(existing.decision_json);
350
+ const decision = { decisionId: randomUUID(), runId: input.runId, interruptionId: input.interruptionId, prompt: input.prompt, choices: input.choices, status: "pending", createdAt: now.toISOString(), ...(input.evidence ? { evidence: input.evidence } : {}) };
351
+ this.db.transaction(() => {
352
+ this.db.prepare("INSERT INTO human_decisions(decision_id,decision_key,run_id,status,decision_json,created_at,updated_at) VALUES(?,?,?,'pending',?,?,?)")
353
+ .run(decision.decisionId, input.decisionKey, input.runId, json(decision), decision.createdAt, decision.createdAt);
354
+ this.insertEvent(input.runId, "human_decision_pending", "waiting-for-human", { decisionId: decision.decisionId, choices: decision.choices.map((choice) => choice.id) }, decision.createdAt);
355
+ })();
356
+ return decision;
357
+ }
358
+ humanDecision(decisionId) {
359
+ const row = this.db.prepare("SELECT decision_json FROM human_decisions WHERE decision_id=?").get(decisionId);
360
+ return row ? parseJson(row.decision_json) : undefined;
361
+ }
362
+ pendingHumanDecisions(runId) {
363
+ return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId)
364
+ .map((row) => parseJson(row.decision_json));
365
+ }
366
+ answerHumanDecision(runId, decisionId, choiceId, now = new Date()) {
367
+ const decision = this.humanDecision(decisionId);
368
+ if (!decision || decision.runId !== runId)
369
+ throw new Error(`pending decision not found for run ${runId}: ${decisionId}`);
370
+ if (decision.status !== "pending")
371
+ throw new Error(`decision ${decisionId} has already been answered`);
372
+ if (!decision.choices.some((choice) => choice.id === choiceId))
373
+ throw new Error(`invalid choice ${choiceId} for decision ${decisionId}`);
374
+ const at = now.toISOString();
375
+ const next = { ...decision, status: "answered", answeredAt: at, selectedChoiceId: choiceId };
376
+ this.db.prepare("UPDATE human_decisions SET status='answered',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json(next), at, decisionId);
377
+ this.insertEvent(runId, "human_decision_answered", "decision-received", { decisionId, choiceId }, at);
378
+ return next;
379
+ }
380
+ putSupervisorState(runId, state, now = new Date()) {
381
+ this.ensureRun(runId);
382
+ this.db.prepare(`INSERT INTO supervisor_leases(run_id,status,pid,generation,heartbeat_at,state_json,updated_at)
383
+ VALUES(?,?,?,?,?,?,?) ON CONFLICT(run_id) DO UPDATE SET status=excluded.status,pid=excluded.pid,generation=excluded.generation,heartbeat_at=excluded.heartbeat_at,state_json=excluded.state_json,updated_at=excluded.updated_at`)
384
+ .run(runId, state.status, state.pid ?? null, state.generation, state.heartbeatAt ?? null, json(state), now.toISOString());
385
+ return state;
386
+ }
387
+ supervisorState(runId) {
388
+ const row = this.db.prepare("SELECT state_json FROM supervisor_leases WHERE run_id=?").get(runId);
389
+ return row ? parseJson(row.state_json) : undefined;
390
+ }
269
391
  planOperation(input, now = new Date()) {
270
392
  const at = now.toISOString();
271
393
  this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
@@ -571,33 +693,70 @@ export class WorkflowDb {
571
693
  const row = this.db.prepare("SELECT * FROM project_lease WHERE singleton=1").get();
572
694
  return row ? { owner: row.owner, generation: row.generation, pid: row.pid, host: row.host, processStart: row.process_start, heartbeatAt: row.heartbeat_at, runId: row.run_id } : undefined;
573
695
  }
696
+ recordBranchResumeSession(runId, session, now = new Date()) {
697
+ this.ensureRun(runId, "build", now);
698
+ this.db.prepare(`INSERT INTO branch_resume_sessions(run_id,ticket,status,session_json,updated_at)
699
+ VALUES(?,?,'active',?,?) ON CONFLICT(run_id,ticket) DO UPDATE SET status='active',session_json=excluded.session_json,updated_at=excluded.updated_at`)
700
+ .run(runId, session.ticket, json(sanitizeContinuityValue(session)), now.toISOString());
701
+ }
702
+ completeBranchResumeSession(runId, ticket, status = "completed", now = new Date()) {
703
+ this.db.prepare("UPDATE branch_resume_sessions SET status=?,updated_at=? WHERE run_id=? AND ticket=?").run(status, now.toISOString(), runId, ticket);
704
+ }
705
+ branchResumeSessions(activeOnly = true) {
706
+ const rows = this.db.prepare(`SELECT session_json FROM branch_resume_sessions${activeOnly ? " WHERE status='active'" : ""} ORDER BY updated_at,ticket`).all();
707
+ return rows.map(row => parseJson(row.session_json));
708
+ }
709
+ pruneTerminalTelemetry(retentionDays, now = new Date()) {
710
+ const cutoff = new Date(now.getTime() - Math.max(1, retentionDays) * 86_400_000).toISOString();
711
+ const result = this.db.prepare(`DELETE FROM workflow_telemetry
712
+ WHERE run_id IN (SELECT run_id FROM workflow_runs WHERE status IN ('completed','failed','cancelled','superseded') AND updated_at<?)
713
+ AND id NOT IN (SELECT MAX(id) FROM workflow_telemetry GROUP BY run_id)`).run(cutoff);
714
+ this.db.pragma("incremental_vacuum(200)");
715
+ return result.changes;
716
+ }
717
+ compactStorage() {
718
+ const lease = this.currentLease();
719
+ if (lease && Date.now() - new Date(lease.heartbeatAt).getTime() <= 45_000)
720
+ throw new Error("refusing recovery database compaction while a live project lease exists");
721
+ this.db.pragma("wal_checkpoint(TRUNCATE)");
722
+ this.db.exec("VACUUM");
723
+ }
574
724
  importLegacyOnce(now = new Date()) {
725
+ const migration = "legacy-files-v1";
726
+ if (this.db.prepare("SELECT 1 FROM recovery_schema_migrations WHERE migration=?").get(migration))
727
+ return 0;
575
728
  const sources = [join(this.projectDir, ".foreman", "runs"), join(this.projectDir, ".rafi", "interviews"), join(this.projectDir, ".tickets", "delivery-sessions")];
576
729
  let count = 0;
577
- for (const directory of sources) {
578
- if (!existsSync(directory) || !statSync(directory).isDirectory())
579
- continue;
580
- for (const name of readdirSync(directory).filter((entry) => entry.endsWith(".json"))) {
581
- const path = join(directory, name);
582
- const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
583
- if (this.db.prepare("SELECT 1 FROM legacy_imports WHERE source_path=? AND digest=?").get(path, digest))
730
+ this.db.transaction(() => {
731
+ for (const directory of sources) {
732
+ if (!existsSync(directory) || !statSync(directory).isDirectory())
584
733
  continue;
585
- let parsed;
586
- try {
587
- parsed = JSON.parse(readFileSync(path, "utf8"));
588
- }
589
- catch {
590
- parsed = { unreadable: true };
591
- }
592
- const runId = `legacy_${digest.slice(0, 24)}`;
593
- if (!this.getRun(runId)) {
594
- this.createRun({ runId, kind: "legacy", checkpoint: "legacy-imported", originalWork: { source: path }, remainingWork: {}, state: { source: path, record: parsed }, legacy: true }, now);
595
- this.transition(runId, { status: "superseded", checkpoint: "legacy-imported", remainingWork: {}, event: "legacy_record_preserved" }, now);
734
+ for (const name of readdirSync(directory).filter((entry) => entry.endsWith(".json"))) {
735
+ const path = join(directory, name);
736
+ const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
737
+ if (this.db.prepare("SELECT 1 FROM legacy_imports WHERE source_path=? AND digest=?").get(path, digest))
738
+ continue;
739
+ let parsed;
740
+ try {
741
+ parsed = JSON.parse(readFileSync(path, "utf8"));
742
+ }
743
+ catch {
744
+ parsed = { unreadable: true };
745
+ }
746
+ const record = parsed && typeof parsed === "object" ? parsed : undefined;
747
+ const isBuild = (record?.version === 1 || record?.version === 2) && typeof record.runId === "string";
748
+ const runId = isBuild ? String(record.runId) : `legacy_${digest.slice(0, 24)}`;
749
+ if (!this.getRun(runId)) {
750
+ this.createRun({ runId, kind: isBuild ? "build" : "legacy", checkpoint: isBuild && typeof record?.checkpoint === "string" ? record.checkpoint : "legacy-imported", originalWork: isBuild ? { tickets: record?.tickets ?? [] } : { source: path }, remainingWork: isBuild ? { tickets: record?.tickets ?? [] } : {}, state: isBuild ? record : { source: path, record: parsed }, legacy: true }, now);
751
+ if (!isBuild)
752
+ this.transition(runId, { status: "superseded", checkpoint: "legacy-imported", remainingWork: {}, event: "legacy_record_preserved" }, now);
753
+ }
754
+ this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
755
+ count += 1;
596
756
  }
597
- this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
598
- count += 1;
599
757
  }
600
- }
758
+ this.db.prepare("INSERT INTO recovery_schema_migrations(migration,completed_at) VALUES(?,?)").run(migration, now.toISOString());
759
+ })();
601
760
  return count;
602
761
  }
603
762
  upsertContinuityHead(head) {
@@ -622,6 +781,13 @@ export class WorkflowDb {
622
781
  migrate() {
623
782
  this.db.exec(`
624
783
  CREATE TABLE IF NOT EXISTS workflow_runs(run_id TEXT PRIMARY KEY,kind TEXT NOT NULL,status TEXT NOT NULL,checkpoint TEXT NOT NULL,original_work_json TEXT NOT NULL,remaining_work_json TEXT NOT NULL,state_json TEXT NOT NULL,lease_generation INTEGER,legacy INTEGER NOT NULL DEFAULT 0,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
784
+ CREATE TABLE IF NOT EXISTS recovery_schema_migrations(migration TEXT PRIMARY KEY,completed_at TEXT NOT NULL);
785
+ CREATE TABLE IF NOT EXISTS branch_resume_sessions(run_id TEXT NOT NULL,ticket TEXT NOT NULL,status TEXT NOT NULL,session_json TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(run_id,ticket));
786
+ CREATE TABLE IF NOT EXISTS run_autonomy_policy(run_id TEXT PRIMARY KEY REFERENCES workflow_runs(run_id),digest TEXT NOT NULL,policy_json TEXT NOT NULL,frozen_at TEXT NOT NULL);
787
+ CREATE TABLE IF NOT EXISTS recovery_interruptions(interruption_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),code TEXT NOT NULL,domain TEXT NOT NULL,phase TEXT NOT NULL,cause TEXT NOT NULL,dispatch_state TEXT NOT NULL,operation_key TEXT,interruption_json TEXT NOT NULL,created_at TEXT NOT NULL);
788
+ CREATE TABLE IF NOT EXISTS recovery_attempts(attempt_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket TEXT,phase TEXT NOT NULL,cause TEXT NOT NULL,operation_key TEXT NOT NULL,attempt INTEGER NOT NULL,disposition TEXT NOT NULL,action TEXT NOT NULL,outcome TEXT NOT NULL,receipt_json TEXT NOT NULL,intended_at TEXT NOT NULL,updated_at TEXT NOT NULL);
789
+ CREATE TABLE IF NOT EXISTS human_decisions(decision_id TEXT PRIMARY KEY,decision_key TEXT NOT NULL UNIQUE,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),status TEXT NOT NULL,decision_json TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
790
+ CREATE TABLE IF NOT EXISTS supervisor_leases(run_id TEXT PRIMARY KEY REFERENCES workflow_runs(run_id),status TEXT NOT NULL,pid INTEGER,generation INTEGER NOT NULL,heartbeat_at TEXT,state_json TEXT NOT NULL,updated_at TEXT NOT NULL);
625
791
  CREATE TABLE IF NOT EXISTS workflow_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),event_type TEXT NOT NULL,checkpoint TEXT NOT NULL,payload_json TEXT NOT NULL,created_at TEXT NOT NULL);
626
792
  CREATE TABLE IF NOT EXISTS role_settings(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,boundary INTEGER NOT NULL,revision INTEGER NOT NULL,settings_json TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,role,boundary));
627
793
  CREATE TABLE IF NOT EXISTS project_settings_revisions(revision INTEGER PRIMARY KEY,defaults_json TEXT NOT NULL,created_at TEXT NOT NULL);
@@ -646,6 +812,8 @@ export class WorkflowDb {
646
812
  CREATE TABLE IF NOT EXISTS recovery_decisions(decision_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),decided_at TEXT NOT NULL,mode TEXT NOT NULL,digest TEXT NOT NULL UNIQUE,session_key TEXT,session_ref_json TEXT,receipt_json TEXT NOT NULL);
647
813
  CREATE INDEX IF NOT EXISTS workflow_events_run ON workflow_events(run_id,sequence);
648
814
  CREATE INDEX IF NOT EXISTS operations_run ON operation_journal(run_id,status);
815
+ CREATE INDEX IF NOT EXISTS recovery_attempt_scope ON recovery_attempts(run_id,ticket,phase,cause,operation_key,outcome);
816
+ CREATE INDEX IF NOT EXISTS human_decisions_run ON human_decisions(run_id,status,created_at);
649
817
  CREATE INDEX IF NOT EXISTS context_samples_run_role ON context_samples(run_id,role,sample_id);
650
818
  CREATE INDEX IF NOT EXISTS continuity_events_run ON continuity_events(run_id,sequence);
651
819
  CREATE INDEX IF NOT EXISTS continuity_checkpoints_run_role ON continuity_checkpoints(run_id,role,checkpoint_id);
@@ -676,8 +844,9 @@ export class WorkflowDb {
676
844
  }
677
845
  ensureColumn(table, column, definition) {
678
846
  const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
679
- if (!columns.some((entry) => entry.name === column))
680
- this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
847
+ if (!columns.some((existing) => existing.name === column)) {
848
+ this.db.exec(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${definition}`);
849
+ }
681
850
  }
682
851
  }
683
852
  function rowToRun(row) { return { runId: row.run_id, kind: row.kind, status: row.status, checkpoint: row.checkpoint, originalWork: parseJson(row.original_work_json), remainingWork: parseJson(row.remaining_work_json), state: parseJson(row.state_json), ...(row.lease_generation === null ? {} : { leaseGeneration: row.lease_generation }), legacy: Boolean(row.legacy), createdAt: row.created_at, updatedAt: row.updated_at }; }
@@ -0,0 +1,42 @@
1
+ import type { PendingHumanDecision, RecoveryAttemptReceipt, SupervisorState, WorkflowIssue } from "rafi-spec";
2
+ import { type OperationRecord, type ProjectLease, type WorkflowRunSnapshot } from "./workflowDb.js";
3
+ import type { BranchResumeSession } from "./branch/resume.js";
4
+ /** Inspection-only recovery access. It never creates `.rafi`, migrates, imports, checkpoints WAL, or updates timestamps. */
5
+ export declare class WorkflowReader {
6
+ readonly path: string;
7
+ private readonly db?;
8
+ constructor(projectDir: string, path?: string);
9
+ close(): void;
10
+ available(): boolean;
11
+ getRun(runId: string): WorkflowRunSnapshot | undefined;
12
+ activeRuns(): WorkflowRunSnapshot[];
13
+ buildRuns(): WorkflowRunSnapshot[];
14
+ events(runId: string): Array<{
15
+ sequence: number;
16
+ type: string;
17
+ checkpoint: string;
18
+ payload: unknown;
19
+ at: string;
20
+ }>;
21
+ issues(runId: string): WorkflowIssue[];
22
+ operations(runId: string): OperationRecord[];
23
+ currentLease(): ProjectLease | undefined;
24
+ continuityHeads(runId: string): Array<{
25
+ role: string;
26
+ state: string;
27
+ sequence: number;
28
+ digest: string;
29
+ updatedAt: string;
30
+ }>;
31
+ branchResumeSessions(activeOnly?: boolean): BranchResumeSession[];
32
+ recoveryAttempts(runId: string): RecoveryAttemptReceipt[];
33
+ pendingHumanDecisions(runId: string): PendingHumanDecision[];
34
+ supervisorState(runId: string): SupervisorState | undefined;
35
+ /** Bounded bulk evidence read. Uses one query per table, never one connection or query per run. */
36
+ runEvidence(runIds: readonly string[], perKindLimit?: number): Record<string, {
37
+ events: ReturnType<WorkflowReader["events"]>;
38
+ issues: WorkflowIssue[];
39
+ operations: OperationRecord[];
40
+ continuity: ReturnType<WorkflowReader["continuityHeads"]>;
41
+ }>;
42
+ }
@@ -0,0 +1,171 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { WORKFLOW_DB_FILE } from "./workflowDb.js";
5
+ /** Inspection-only recovery access. It never creates `.rafi`, migrates, imports, checkpoints WAL, or updates timestamps. */
6
+ export class WorkflowReader {
7
+ path;
8
+ db;
9
+ constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE)) {
10
+ this.path = path;
11
+ if (!existsSync(path))
12
+ return;
13
+ this.db = new Database(path, { readonly: true, fileMustExist: true });
14
+ this.db.pragma("query_only = ON");
15
+ }
16
+ close() { this.db?.close(); }
17
+ available() { return Boolean(this.db); }
18
+ getRun(runId) {
19
+ if (!this.db)
20
+ return undefined;
21
+ try {
22
+ const row = this.db.prepare("SELECT * FROM workflow_runs WHERE run_id=?").get(runId);
23
+ return row ? toRun(row) : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ activeRuns() {
30
+ if (!this.db)
31
+ return [];
32
+ try {
33
+ return this.db.prepare("SELECT * FROM workflow_runs WHERE status IN ('running','paused','blocked') ORDER BY created_at").all().map(toRun);
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }
39
+ buildRuns() {
40
+ if (!this.db)
41
+ return [];
42
+ try {
43
+ return this.db.prepare("SELECT * FROM workflow_runs WHERE kind='build' ORDER BY updated_at DESC").all().map(toRun);
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ events(runId) {
50
+ if (!this.db)
51
+ return [];
52
+ try {
53
+ return this.db.prepare("SELECT sequence,event_type,checkpoint,payload_json,created_at FROM workflow_events WHERE run_id=? ORDER BY sequence").all(runId).map(row => ({ sequence: Number(row.sequence), type: String(row.event_type), checkpoint: String(row.checkpoint), payload: JSON.parse(String(row.payload_json)), at: String(row.created_at) }));
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ issues(runId) {
60
+ if (!this.db)
61
+ return [];
62
+ try {
63
+ return this.db.prepare("SELECT issue_json FROM workflow_issues WHERE run_id=? ORDER BY issue_id").all(runId).map(row => JSON.parse(row.issue_json));
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ operations(runId) {
70
+ if (!this.db)
71
+ return [];
72
+ try {
73
+ return this.db.prepare("SELECT * FROM operation_journal WHERE run_id=? ORDER BY created_at,idempotency_key").all(runId).map(row => ({ idempotencyKey: String(row.idempotency_key), runId: String(row.run_id), kind: String(row.kind), status: String(row.status), intent: JSON.parse(String(row.intent_json)), ...(row.result_json ? { result: JSON.parse(String(row.result_json)) } : {}), ...(row.external_id ? { externalId: String(row.external_id) } : {}), ...(row.error ? { error: String(row.error) } : {}), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }));
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ currentLease() {
80
+ if (!this.db)
81
+ return undefined;
82
+ try {
83
+ const row = this.db.prepare("SELECT * FROM project_lease WHERE singleton=1").get();
84
+ return row ? { owner: String(row.owner), generation: Number(row.generation), pid: Number(row.pid), host: String(row.host), processStart: String(row.process_start), heartbeatAt: String(row.heartbeat_at), runId: String(row.run_id) } : undefined;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
90
+ continuityHeads(runId) {
91
+ if (!this.db)
92
+ return [];
93
+ try {
94
+ return this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? ORDER BY role").all(runId).map(row => ({ role: String(row.role), state: String(row.state), sequence: Number(row.event_sequence), digest: String(row.digest), updatedAt: String(row.updated_at) }));
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ }
100
+ branchResumeSessions(activeOnly = true) {
101
+ if (!this.db)
102
+ return [];
103
+ try {
104
+ return this.db.prepare(`SELECT session_json FROM branch_resume_sessions${activeOnly ? " WHERE status='active'" : ""} ORDER BY updated_at,ticket`).all().map(row => JSON.parse(row.session_json));
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ }
110
+ recoveryAttempts(runId) {
111
+ if (!this.db)
112
+ return [];
113
+ try {
114
+ return this.db.prepare("SELECT receipt_json FROM recovery_attempts WHERE run_id=? ORDER BY intended_at,attempt_id").all(runId).map((row) => JSON.parse(row.receipt_json));
115
+ }
116
+ catch {
117
+ return [];
118
+ }
119
+ }
120
+ pendingHumanDecisions(runId) {
121
+ if (!this.db)
122
+ return [];
123
+ try {
124
+ return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId).map((row) => JSON.parse(row.decision_json));
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ }
130
+ supervisorState(runId) {
131
+ if (!this.db)
132
+ return undefined;
133
+ try {
134
+ const row = this.db.prepare("SELECT state_json FROM supervisor_leases WHERE run_id=?").get(runId);
135
+ return row ? JSON.parse(row.state_json) : undefined;
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ /** Bounded bulk evidence read. Uses one query per table, never one connection or query per run. */
142
+ runEvidence(runIds, perKindLimit = 100) {
143
+ const result = {};
144
+ if (!this.db || !runIds.length)
145
+ return result;
146
+ const ids = [...new Set(runIds)].slice(0, 5);
147
+ for (const id of ids)
148
+ result[id] = { events: [], issues: [], operations: [], continuity: [] };
149
+ const marks = ids.map(() => "?").join(",");
150
+ const limit = Math.max(1, Math.min(500, perKindLimit));
151
+ try {
152
+ const events = this.db.prepare(`SELECT * FROM (SELECT run_id,sequence,event_type,checkpoint,payload_json,created_at,ROW_NUMBER() OVER(PARTITION BY run_id ORDER BY sequence DESC) rn FROM workflow_events WHERE run_id IN (${marks})) WHERE rn<=? ORDER BY run_id,sequence`).all(...ids, limit);
153
+ for (const row of events)
154
+ result[String(row.run_id)]?.events.push({ sequence: Number(row.sequence), type: String(row.event_type), checkpoint: String(row.checkpoint), payload: JSON.parse(String(row.payload_json)), at: String(row.created_at) });
155
+ const issues = this.db.prepare(`SELECT * FROM (SELECT run_id,issue_json,ROW_NUMBER() OVER(PARTITION BY run_id ORDER BY issue_id DESC) rn FROM workflow_issues WHERE run_id IN (${marks})) WHERE rn<=? ORDER BY run_id,rn DESC`).all(...ids, limit);
156
+ for (const row of issues)
157
+ result[String(row.run_id)]?.issues.push(JSON.parse(String(row.issue_json)));
158
+ const operations = this.db.prepare(`SELECT * FROM (SELECT *,ROW_NUMBER() OVER(PARTITION BY run_id ORDER BY created_at DESC,idempotency_key) rn FROM operation_journal WHERE run_id IN (${marks})) WHERE rn<=? ORDER BY run_id,created_at,idempotency_key`).all(...ids, limit);
159
+ for (const row of operations)
160
+ result[String(row.run_id)]?.operations.push({ idempotencyKey: String(row.idempotency_key), runId: String(row.run_id), kind: String(row.kind), status: String(row.status), intent: JSON.parse(String(row.intent_json)), ...(row.result_json ? { result: JSON.parse(String(row.result_json)) } : {}), ...(row.external_id ? { externalId: String(row.external_id) } : {}), ...(row.error ? { error: String(row.error) } : {}), createdAt: String(row.created_at), updatedAt: String(row.updated_at) });
161
+ const continuity = this.db.prepare(`SELECT * FROM continuity_heads WHERE run_id IN (${marks}) ORDER BY run_id,role`).all(...ids);
162
+ for (const row of continuity)
163
+ result[String(row.run_id)]?.continuity.push({ role: String(row.role), state: String(row.state), sequence: Number(row.event_sequence), digest: String(row.digest), updatedAt: String(row.updated_at) });
164
+ }
165
+ catch { /* an older recovery schema exposes only the legacy per-run accessors */ }
166
+ return result;
167
+ }
168
+ }
169
+ function toRun(row) {
170
+ return { runId: row.run_id, kind: row.kind, status: row.status, checkpoint: row.checkpoint, originalWork: JSON.parse(row.original_work_json), remainingWork: JSON.parse(row.remaining_work_json), state: JSON.parse(row.state_json), ...(row.lease_generation === null ? {} : { leaseGeneration: row.lease_generation }), legacy: Boolean(row.legacy), createdAt: row.created_at, updatedAt: row.updated_at };
171
+ }