@deksden-com/dd-flow-cli 0.9.0-beta.17 → 0.9.0-beta.19

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.
@@ -2,20 +2,78 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
5
+ import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
6
6
  import { validateSchema } from "./schema-validation.js";
7
7
  import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
8
8
  import { refreshRunSessionProjection } from "./run-projection.js";
9
9
  import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
10
10
  import { nextWorkId, nextWorkIds } from "./ids.js";
11
11
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
+ import { appendAudit } from "./audit.js";
12
13
  import { flowCommand } from "./stage-pause.js";
13
14
  import { assertPortableArtifactRef } from "./portable-refs.js";
14
15
  import { publicSessionIdentity } from "./session-identity.js";
16
+ import { activateRecoveryWork, assertRunMutationAllowed, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
15
17
  const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
16
18
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
19
+ /**
20
+ * A Stage terminal command is allowed only from the Session that owns its
21
+ * running coordinator Work. A hook receipt proves the caller; a WorkSession
22
+ * proves the owner. Either fact alone is insufficient.
23
+ */
24
+ export function assertStageLifecycleOwner(context, input) {
25
+ ensureWorkRegistry(context);
26
+ const projectRoot = resolveProjectRoot(input.projectRoot);
27
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
28
+ if (!project)
29
+ throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
30
+ requireRun(context, project.id, input.runId);
31
+ const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id
32
+ FROM work_sessions ws
33
+ JOIN works w ON w.work_id = ws.work_id
34
+ JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
35
+ WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
36
+ AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
37
+ ${input.workId ? "AND w.work_id = ?" : ""}
38
+ ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
39
+ if (!owner)
40
+ throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
41
+ const identity = claimStageLifecycleHookEvent(context, {
42
+ projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
43
+ runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
44
+ });
45
+ if (identity.sessionId === owner.session_id)
46
+ return identity;
47
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
48
+ const payload = { run_id: input.runId, stage: input.stage, work_id: owner.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
49
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
50
+ appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
51
+ throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
52
+ }
53
+ /** A Work terminal command belongs to the Session that started that Work.
54
+ * Controllers can still use their explicit non-agent operations; they cannot
55
+ * borrow a native worker's authority by naming its Work id. */
56
+ export function assertWorkLifecycleOwner(context, input) {
57
+ ensureWorkRegistry(context);
58
+ const work = requireWork(context, input.workId);
59
+ const run = requireRun(context, work.project_id, work.run_id);
60
+ if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
61
+ throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
62
+ const owner = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
63
+ if (!owner)
64
+ throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
65
+ const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
66
+ if (identity.sessionId === owner.session_id)
67
+ return identity;
68
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
69
+ const payload = { run_id: work.run_id, work_id: work.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
70
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
71
+ appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
72
+ throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
73
+ }
17
74
  export function createChildWork(context, input) {
18
75
  const parent = requireWork(context, input.parentWorkId);
76
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
19
77
  if (parent.status !== "running")
20
78
  throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
21
79
  const id = nextWorkId(context, parent.project_id, input.slug);
@@ -46,6 +104,7 @@ export function validateWorkBatchFile(file) {
46
104
  export function addWorkBatch(context, input) {
47
105
  ensureWorkRegistry(context);
48
106
  const parent = requireWork(context, input.parentWorkId);
107
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
49
108
  if (parent.status !== "running")
50
109
  throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
51
110
  const parsed = readJson(input.file);
@@ -118,6 +177,7 @@ export function mutateWorkDeps(context, input) {
118
177
  const work = requireWork(context, input.workId);
119
178
  if (input.action === "list")
120
179
  return { work_id: work.work_id, depends_on: parseDependencies(work) };
180
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
121
181
  if (work.status !== "created")
122
182
  throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
123
183
  const dependencies = new Set(parseDependencies(work));
@@ -139,7 +199,7 @@ export function mutateWorkDeps(context, input) {
139
199
  refreshRunWorkProjection(context, work.project_id, work.run_id);
140
200
  return { work_id: work.work_id, depends_on: [...dependencies] };
141
201
  }
142
- export function deleteWork(context, id) { const work = requireWork(context, id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
202
+ export function deleteWork(context, id) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
143
203
  throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
144
204
  export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
145
205
  /**
@@ -152,6 +212,7 @@ export function workStartCommand(context, work) { const run = requireRun(context
152
212
  export function startWork(context, id, input) {
153
213
  ensureWorkRegistry(context);
154
214
  const work = requireWork(context, id);
215
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
155
216
  if (work.status !== "created")
156
217
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
157
218
  if (!isReady(context, work)) {
@@ -179,6 +240,7 @@ export function startWork(context, id, input) {
179
240
  /** Binds the already-running coordinator of a stage to its trusted hook Session. */
180
241
  export function bindRunningWorkSession(context, input) {
181
242
  const work = requireWork(context, input.workId);
243
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
182
244
  if (work.status !== "running")
183
245
  throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
184
246
  const run = requireRun(context, work.project_id, work.run_id);
@@ -231,6 +293,7 @@ export function bindRunningWorkSession(context, input) {
231
293
  /** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
232
294
  export function bindStageCoordinatorWork(context, input) {
233
295
  const work = requireWork(context, input.workId);
296
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
234
297
  const run = requireRun(context, work.project_id, work.run_id);
235
298
  claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
236
299
  const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
@@ -242,6 +305,7 @@ export function bindStageCoordinatorWork(context, input) {
242
305
  /** Starts a created coordinator Work from its trusted `stage start` event. */
243
306
  export function startStageCoordinatorWork(context, input) {
244
307
  const work = requireWork(context, input.workId);
308
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
245
309
  if (work.status !== "created")
246
310
  throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
247
311
  const run = requireRun(context, work.project_id, work.run_id);
@@ -256,6 +320,7 @@ export function startStageCoordinatorWork(context, input) {
256
320
  }
257
321
  /** Stage entry points call this only after their own trusted hook claim. */
258
322
  export function startBoundWork(context, work, run, identity, hookEventId) {
323
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
259
324
  if (work.status !== "created")
260
325
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
261
326
  const now = context.now();
@@ -266,10 +331,13 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
266
331
  const resultPath = path.join(directory, "result.json");
267
332
  const contextPath = path.join(directory, "context.json");
268
333
  const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
269
- const prompt = renderWorkerPrompt(context, work, run, dependencyResults);
334
+ const recovery = recoveryPromptForWork(context, work.work_id);
335
+ let prompt = renderWorkerPrompt(context, work, run, dependencyResults);
336
+ if (recovery)
337
+ prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
270
338
  const payload = parsePayload(work);
271
339
  const readOnly = payload?.read_only === true;
272
- const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
340
+ const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
273
341
  const token = crypto.randomUUID();
274
342
  const promptCandidate = `${promptPath}.${token}.tmp`;
275
343
  const contextCandidate = `${contextPath}.${token}.tmp`;
@@ -294,17 +362,19 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
294
362
  fs.rmSync(contextCandidate, { force: true });
295
363
  throw error;
296
364
  }
365
+ if (recovery)
366
+ activateRecoveryWork(context, work.work_id);
297
367
  refreshRunWorkProjection(context, work.project_id, work.run_id);
298
- return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
368
+ return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
299
369
  }
300
370
  export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
301
371
  /** Structured fan-in closes a parent after its Session was handed to a child. */
302
- export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
372
+ export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
303
373
  throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
304
374
  throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
305
375
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
306
376
  export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
307
- export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
377
+ export function retryWork(context, id, reason) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); id = work.work_id; if (work.status !== "failed")
308
378
  throw new AppError("invalid_work_state", "Only failed Work may be retried", 2); const run = requireRun(context, work.project_id, work.run_id); const directory = path.join(requireRunHome(run), "works", id); const attempts = path.join(directory, "attempts"); const number = fs.existsSync(attempts) ? fs.readdirSync(attempts).filter((entry) => /^ATT-\d{3}$/.test(entry)).length + 1 : 1; const archive = path.join(attempts, `ATT-${String(number).padStart(3, "0")}`); fs.mkdirSync(archive, { recursive: true }); for (const file of ["prompt.md", "result.json", "context.json"]) {
309
379
  const source = path.join(directory, file);
310
380
  if (fs.existsSync(source))
@@ -312,6 +382,7 @@ export function retryWork(context, id, reason) { const work = requireWork(contex
312
382
  } context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
313
383
  async function settle(context, id, status, result, progress) {
314
384
  const work = requireWork(context, id);
385
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
315
386
  id = work.work_id;
316
387
  if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
317
388
  throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
@@ -367,6 +438,7 @@ async function settle(context, id, status, result, progress) {
367
438
  context.db.exec("ROLLBACK");
368
439
  throw error;
369
440
  }
441
+ settleRecoveryWork(context, work.work_id, status);
370
442
  refreshRunWorkProjection(context, work.project_id, work.run_id);
371
443
  const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
372
444
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
@@ -374,6 +446,7 @@ async function settle(context, id, status, result, progress) {
374
446
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
375
447
  return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
376
448
  }
449
+ function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
377
450
  function plannedAreaDrift(packet, result) {
378
451
  const changed = JSON.parse(result).changed_paths;
379
452
  if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
@@ -7,6 +7,20 @@ import { AppError } from "../shared/errors.js";
7
7
  const require = createRequire(import.meta.url);
8
8
  const { DatabaseSync } = require("node:sqlite");
9
9
  const resourceDatabases = new Map();
10
+ /** Recovery guards apply to every RUN kind, including custom eval fixtures. */
11
+ export function ensureRecoveryStorage(db) {
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
14
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
15
+ );
16
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
17
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
18
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
19
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
22
+ `);
23
+ }
10
24
  /** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
11
25
  export function ensureVnextWorkStorage(db) {
12
26
  const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
@@ -53,6 +67,16 @@ export function ensureVnextWorkStorage(db) {
53
67
  );
54
68
  CREATE INDEX IF NOT EXISTS idx_work_sessions_work ON work_sessions(work_id, created_at DESC);
55
69
  CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open ON work_sessions(work_id) WHERE status = 'running';
70
+
71
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
72
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
73
+ );
74
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
75
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
76
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
77
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
56
80
  CREATE TABLE IF NOT EXISTS check_receipts (
57
81
  id TEXT PRIMARY KEY,
58
82
  project_id TEXT NOT NULL,
@@ -95,8 +119,10 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
95
119
  configureDatabase(db, mode);
96
120
  // Internal additive schema changes must be available to every write command.
97
121
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
98
- if (mode !== "read_existing")
122
+ if (mode !== "read_existing") {
99
123
  migrate(db, dbPath);
124
+ ensureRecoveryStorage(db);
125
+ }
100
126
  if (mode === "read_existing")
101
127
  db.exec("PRAGMA query_only = ON");
102
128
  db.exec("PRAGMA foreign_keys = ON");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.17",
3
+ "version": "0.9.0-beta.19",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {