@deksden-com/dd-flow-cli 0.9.0-beta.37 → 0.9.0-beta.39

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 (72) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +42 -1
  4. package/dist/cli/run-cli.js +145 -17
  5. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  6. package/dist/harness-runtime/bin/dd-codex.mjs +4 -3
  7. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  8. package/dist/harness-runtime/bin/dd-grok.mjs +4 -3
  9. package/dist/harness-runtime/bin/dd-opencode.mjs +4 -3
  10. package/dist/harness-runtime/bin/dd-zcode.mjs +4 -3
  11. package/dist/harness-runtime/lib/daemon-operations.mjs +48 -9
  12. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +73 -20
  13. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +57 -16
  14. package/dist/harness-runtime/lib/dd-codex.mjs +10 -2
  15. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +3 -2
  16. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -6
  17. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +4 -3
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +8 -5
  19. package/dist/harness-runtime/lib/managed-daemon.mjs +125 -6
  20. package/dist/harness-runtime/lib/process-json.mjs +1 -1
  21. package/dist/harness-runtime/lib/session-settlement.mjs +4 -2
  22. package/dist/schemas/run-control-receipt.schema.json +99 -0
  23. package/dist/schemas/run-control-request.schema.json +36 -0
  24. package/dist/services/cleanup.js +7 -4
  25. package/dist/services/cli-operation-classifier.js +3 -1
  26. package/dist/services/code-checks.js +33 -7
  27. package/dist/services/controller-fanout.js +105 -0
  28. package/dist/services/engines.js +2 -0
  29. package/dist/services/eval-snapshots.js +347 -36
  30. package/dist/services/execution-policy.js +122 -6
  31. package/dist/services/external-work-launch.js +33 -12
  32. package/dist/services/harness-adapter.js +49 -2
  33. package/dist/services/managed-daemon-binding.js +38 -0
  34. package/dist/services/managed-processes.js +54 -7
  35. package/dist/services/merge-server.js +53 -30
  36. package/dist/services/native-daemon-history.js +49 -0
  37. package/dist/services/native-session-control.js +31 -0
  38. package/dist/services/protocols.js +9 -0
  39. package/dist/services/recovery-snapshot-database.js +103 -0
  40. package/dist/services/run-control-receipt.js +56 -0
  41. package/dist/services/run-control-worker.js +297 -0
  42. package/dist/services/run-control.js +752 -0
  43. package/dist/services/run-controller-adapter.js +44 -0
  44. package/dist/services/run-controller-capture.js +117 -0
  45. package/dist/services/run-controller-process.js +178 -0
  46. package/dist/services/run-controller-recovery.js +219 -0
  47. package/dist/services/run-controller-state.js +31 -0
  48. package/dist/services/run-controller.js +538 -0
  49. package/dist/services/run-recovery-runtime.js +69 -0
  50. package/dist/services/run-recovery.js +33 -9
  51. package/dist/services/runs.js +65 -13
  52. package/dist/services/runtime-budget.js +362 -0
  53. package/dist/services/runtime-scope-capture.js +50 -0
  54. package/dist/services/runtime-scope-control.js +431 -0
  55. package/dist/services/runtime-scope-resume.js +543 -0
  56. package/dist/services/runtime-scope-stop.js +95 -0
  57. package/dist/services/runtime-scope-worker.js +165 -0
  58. package/dist/services/runtime-service.js +4 -0
  59. package/dist/services/vnext-code-review.js +1 -1
  60. package/dist/services/vnext-code.js +1 -1
  61. package/dist/services/vnext-execution-profile.js +2 -35
  62. package/dist/services/vnext-fanout.js +23 -7
  63. package/dist/services/vnext-plan-review.js +12 -9
  64. package/dist/services/vnext-plan.js +1 -1
  65. package/dist/services/vnext-protocolize.js +10 -4
  66. package/dist/services/vnext-specify.js +12 -5
  67. package/dist/services/work-registry.js +26 -8
  68. package/dist/services/workspace-bootstrap.js +10 -1
  69. package/dist/storage/database.js +160 -13
  70. package/dist/storage/writer-contract.js +76 -0
  71. package/dist/storage/writer-migration.js +102 -0
  72. package/package.json +1 -1
@@ -9,6 +9,7 @@ import { resolveProjectRoot, writeJsonAtomic } from "../storage/paths.js";
9
9
  import { preflightMemoryPermissions } from "./memory-permissions.js";
10
10
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
11
  import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
12
+ import { resolveStageTransition } from "./execution-policy.js";
12
13
  import { validateSchema } from "./schema-validation.js";
13
14
  import { bindRunningWorkSession, refreshRunWorkProjection } from "./work-registry.js";
14
15
  import { nextWorkId } from "./ids.js";
@@ -65,7 +66,7 @@ export function launchVnextSpecify(context, input) {
65
66
  if (preflight.ok !== true)
66
67
  throw new AppError("permission_preflight_failed", "SPECIFY workspace is not writable", 1, { preflight });
67
68
  const grounding = projectGrounding(projectRoot);
68
- const handoff = executionSnapshot(context, run.id);
69
+ const handoff = executionSnapshot(context, run.project_id, run.id);
69
70
  const workContext = {
70
71
  schema_id: "dd-flow/work-context@1",
71
72
  system: { run_id: run.id, work_id: workId, stage: stageId },
@@ -187,7 +188,7 @@ export function submitVnextSpecify(context, input) {
187
188
  appendFlowRunTimelineEvent(context, run.project_id, run.id, { type: "work_completed", work_id: work.work_id, outcome, stage: stageId });
188
189
  if (continuesToProtocolize) {
189
190
  const nextWork = requireWork(context, work.work_id);
190
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "specified", nextAction: "start_protocolize" });
191
+ advanceFlowRun(context, { settlementStage: "specify", projectRoot, runId: run.id, status: "running", verdict: "specified", nextAction: "start_protocolize" });
191
192
  refreshRunWorkProjection(context, run.project_id, run.id);
192
193
  const nextCommand = `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`;
193
194
  return {
@@ -562,12 +563,18 @@ export function readVnextFlowDefinition(projectRoot) {
562
563
  }
563
564
  return null;
564
565
  }
565
- function executionSnapshot(context, runId) {
566
- const row = context.db.get("SELECT index_json FROM runs WHERE id = ?", [runId]);
566
+ function executionSnapshot(context, projectId, runId) {
567
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
567
568
  const snapshot = row ? JSON.parse(row.index_json) : null;
568
- const handoff = snapshot?.execution_profile?.settings?.stage_session_mode;
569
+ const profile = snapshot?.execution_profile;
570
+ let handoff = profile?.settings.stage_session_mode;
569
571
  if (handoff !== "same_session" && handoff !== "new_session")
570
572
  throw new AppError("execution_profile_invalid", "RUN has no frozen stage session mode", 1, { run_id: runId });
573
+ if (profile?.settings.execution) {
574
+ if (!profile.agent_profiles)
575
+ throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
576
+ handoff = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, fromStage: "specify", stage: "protocolize", stageSessionMode: handoff, mergeMode: profile.settings.merge_mode }).session_mode;
577
+ }
571
578
  return { stage_handoff: { effective: handoff, source: "run_override" } };
572
579
  }
573
580
  function executionSnapshotFromPath(contextPath, workId) {
@@ -9,11 +9,13 @@ 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 { resolveStageTransition } from "./execution-policy.js";
12
13
  import { appendAudit } from "./audit.js";
13
14
  import { flowCommand } from "./stage-pause.js";
14
15
  import { assertPortableArtifactRef } from "./portable-refs.js";
15
16
  import { publicSessionIdentity } from "./session-identity.js";
16
- import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
17
+ import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
18
+ import { runControlDirective } from "./run-control.js";
17
19
  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";
18
20
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
19
21
  function ownsPhysicalWorkSession(context, projectId, owner, identity) {
@@ -304,7 +306,7 @@ export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.rep
304
306
  * same captured runtime as their parent stage.
305
307
  */
306
308
  export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`; }
307
- export function startWork(context, id, input) {
309
+ export function assertWorkLaunchReady(context, id) {
308
310
  ensureWorkRegistry(context);
309
311
  const work = requireWork(context, id);
310
312
  assertRunMutationAllowed(context, work.project_id, work.run_id);
@@ -315,6 +317,10 @@ export function startWork(context, id, input) {
315
317
  const ready = 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, start_command: workStartCommand(context, candidate) }));
316
318
  throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping planned coordination areas are free", 2, { work_id: id, blockers, ready });
317
319
  }
320
+ return work;
321
+ }
322
+ export function startWork(context, id, input) {
323
+ const work = assertWorkLaunchReady(context, id);
318
324
  const run = requireRun(context, work.project_id, work.run_id);
319
325
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
320
326
  throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
@@ -403,9 +409,20 @@ export function bindStageCoordinatorWork(context, input) {
403
409
  const work = requireWork(context, input.workId);
404
410
  assertRunMutationAllowed(context, work.project_id, work.run_id);
405
411
  const run = requireRun(context, work.project_id, work.run_id);
406
- 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 } : {}) });
407
412
  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) ?? "{}");
408
- const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: snapshot.execution_profile?.settings?.stage_session_mode === "new_session" });
413
+ const profile = snapshot.execution_profile;
414
+ const previous = context.db.get("SELECT ws.session_id, s.current_stage FROM work_sessions ws JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? AND ws.status = 'running' ORDER BY ws.created_at DESC LIMIT 1", [work.project_id, work.work_id]);
415
+ let sessionMode = profile?.settings.stage_session_mode ?? "same_session";
416
+ if (profile?.settings.execution) {
417
+ if (!profile.agent_profiles)
418
+ throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
419
+ sessionMode = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(previous?.current_stage ? { fromStage: previous.current_stage } : {}), stage: input.stage, stageSessionMode: sessionMode, mergeMode: profile.settings.merge_mode }).session_mode;
420
+ }
421
+ const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
422
+ if (previous?.current_stage && previous.current_stage !== input.stage && sessionMode === "new_session" && previous.session_id === identity.sessionId)
423
+ throw new AppError("stage_handoff_required", "This stage requires a fresh coordinator Session under its frozen execution policy", 1, { from_stage: previous.current_stage, stage: input.stage });
424
+ 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 } : {}) });
425
+ const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: sessionMode === "new_session" });
409
426
  context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, binding.session_id]);
410
427
  refreshRunSessionProjection(context, work.project_id, work.run_id);
411
428
  return binding;
@@ -556,7 +573,7 @@ export function retryWork(context, id, reason) {
556
573
  }
557
574
  async function settle(context, id, status, result, progress) {
558
575
  const work = requireWork(context, id);
559
- assertRunMutationAllowed(context, work.project_id, work.run_id);
576
+ assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
560
577
  id = work.work_id;
561
578
  if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
562
579
  throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
@@ -597,7 +614,7 @@ async function settle(context, id, status, result, progress) {
597
614
  const now = context.now();
598
615
  context.db.exec("BEGIN IMMEDIATE");
599
616
  try {
600
- assertRunMutationAllowed(context, work.project_id, work.run_id);
617
+ assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
601
618
  if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
602
619
  throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
603
620
  if (link?.result_path && work.parent_work_id !== null)
@@ -618,11 +635,12 @@ async function settle(context, id, status, result, progress) {
618
635
  materializePendingWorkResults(context, work.project_id, work.run_id);
619
636
  settleRecoveryWork(context, work.work_id, status);
620
637
  refreshRunWorkProjection(context, work.project_id, work.run_id);
621
- 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) }));
638
+ const control = runControlDirective(context, work.project_id, work.run_id);
639
+ const newlyReady = control ? [] : 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) }));
622
640
  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 } : {}) });
623
641
  for (const ready of newlyReady)
624
642
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
625
- 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" } };
643
+ return { ok: true, ...(control ? { control } : {}), 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" } };
626
644
  }
627
645
  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"); }
628
646
  function plannedAreaDrift(packet, result) {
@@ -2,6 +2,7 @@ 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 { assertRunMutationAllowed } from "./run-recovery.js";
5
6
  import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
6
7
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
7
8
  /** CODE and MERGE prepare their own checkout through the same managed executor.
@@ -24,13 +25,21 @@ export async function runWorkspaceBootstrap(context, input) {
24
25
  finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
25
26
  return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
26
27
  }
28
+ const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
27
29
  const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
28
30
  fs.mkdirSync(directory, { recursive: true });
29
31
  const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
30
32
  const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: operationId, ownerPid: process.pid, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
31
33
  const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
32
34
  try {
33
- input.progress?.(`workspace bootstrap started: ${input.command}; logs: ${directory}`);
35
+ try {
36
+ input.progress?.(`workspace bootstrap preparing: ${input.command}; logs: ${directory}`);
37
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
38
+ }
39
+ catch (error) {
40
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: "failed", reason: "dispatch_fenced_before_spawn" });
41
+ throw error;
42
+ }
34
43
  const completed = await runCheck(input.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_CHECK_COMPLETION_FILE: completionPath }, stdout, stderr, elapsed => input.progress?.(`workspace bootstrap still running (${elapsed}s); logs: ${directory}; next report in 15s`), pid => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
35
44
  const current = managedProcessStatus(context).find(item => item.id === managed.id);
36
45
  if (processTreeIsAlive(current))
@@ -4,9 +4,86 @@ import crypto from "node:crypto";
4
4
  import { createRequire } from "node:module";
5
5
  import { ensureDir } from "./paths.js";
6
6
  import { AppError } from "../shared/errors.js";
7
+ import { executeStoreSchema, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
7
8
  const require = createRequire(import.meta.url);
8
9
  const { DatabaseSync } = require("node:sqlite");
9
10
  const resourceDatabases = new Map();
11
+ /** RUN authority stays in its existing store; process leases stay in runtime.sqlite. */
12
+ export function ensureRunControlStorage(db) {
13
+ db.execSchema(`
14
+ CREATE TABLE IF NOT EXISTS run_controls (
15
+ control_id TEXT PRIMARY KEY,
16
+ project_id TEXT NOT NULL, run_id TEXT NOT NULL,
17
+ request_id TEXT NOT NULL, request_hash TEXT NOT NULL, request_json TEXT NOT NULL,
18
+ recovery_id TEXT NOT NULL REFERENCES run_recovery_guards(recovery_id),
19
+ action TEXT NOT NULL, status TEXT NOT NULL,
20
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
21
+ UNIQUE(project_id, run_id, request_id),
22
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
23
+ );
24
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_control_active
25
+ ON run_controls(project_id, run_id) WHERE status IN ('requested','settled','resuming');
26
+ CREATE TABLE IF NOT EXISTS run_control_admissions (
27
+ control_id TEXT NOT NULL REFERENCES run_controls(control_id),
28
+ work_session_id TEXT NOT NULL REFERENCES work_sessions(id),
29
+ work_id TEXT NOT NULL REFERENCES works(work_id),
30
+ session_id TEXT NOT NULL, admitted_at TEXT NOT NULL,
31
+ PRIMARY KEY(control_id, work_session_id)
32
+ );
33
+ CREATE TABLE IF NOT EXISTS run_control_stage_admissions (
34
+ control_id TEXT NOT NULL REFERENCES run_controls(control_id),
35
+ stage TEXT NOT NULL, attempt TEXT NOT NULL,
36
+ work_session_id TEXT NOT NULL REFERENCES work_sessions(id),
37
+ PRIMARY KEY(control_id, stage)
38
+ );
39
+ CREATE TABLE IF NOT EXISTS run_control_operations (
40
+ operation_id TEXT PRIMARY KEY, control_id TEXT NOT NULL REFERENCES run_controls(control_id),
41
+ target_json TEXT NOT NULL, status TEXT NOT NULL, receipt_json TEXT, error_json TEXT,
42
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
43
+ );
44
+ CREATE TABLE IF NOT EXISTS run_control_workers (
45
+ control_id TEXT PRIMARY KEY REFERENCES run_controls(control_id),
46
+ owner_token TEXT NOT NULL, owner_pid INTEGER,
47
+ status TEXT NOT NULL, process_id TEXT, process_lease_token TEXT,
48
+ resource_home TEXT NOT NULL, state_dir TEXT NOT NULL,
49
+ snapshot_json TEXT, error_json TEXT,
50
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
51
+ );
52
+ `);
53
+ }
54
+ export function ensureRunControllerStorage(db) {
55
+ db.execSchema(`
56
+ CREATE TABLE IF NOT EXISTS run_controllers (
57
+ controller_id TEXT PRIMARY KEY,
58
+ project_id TEXT NOT NULL, run_id TEXT NOT NULL,
59
+ request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
60
+ generation INTEGER NOT NULL, status TEXT NOT NULL,
61
+ owner_token TEXT NOT NULL, process_id TEXT, process_lease_token TEXT,
62
+ owner_pid INTEGER, manifest_json TEXT NOT NULL, state_json TEXT NOT NULL,
63
+ state_dir TEXT NOT NULL, last_error_json TEXT,
64
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
65
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id),
66
+ UNIQUE(project_id, run_id, request_id)
67
+ );
68
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_controller_owner
69
+ ON run_controllers(project_id, run_id)
70
+ WHERE status NOT IN ('completed', 'cancelled', 'superseded');
71
+ CREATE TABLE IF NOT EXISTS run_controller_events (
72
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ controller_id TEXT NOT NULL REFERENCES run_controllers(controller_id),
74
+ event_type TEXT NOT NULL, data_json TEXT NOT NULL, created_at TEXT NOT NULL
75
+ );
76
+ CREATE INDEX IF NOT EXISTS idx_run_controller_events
77
+ ON run_controller_events(controller_id, sequence);
78
+ CREATE TABLE IF NOT EXISTS run_controller_operations (
79
+ operation_id TEXT PRIMARY KEY,
80
+ controller_id TEXT NOT NULL REFERENCES run_controllers(controller_id),
81
+ generation INTEGER NOT NULL, input_hash TEXT NOT NULL,
82
+ input_json TEXT NOT NULL, status TEXT NOT NULL,
83
+ receipt_json TEXT, error_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
84
+ );
85
+ `);
86
+ }
10
87
  /** Recovery guards apply to every RUN kind, including custom eval fixtures. */
11
88
  export function ensureRecoveryStorage(db) {
12
89
  db.exec(`
@@ -29,6 +106,7 @@ export function ensureRecoveryStorage(db) {
29
106
  `);
30
107
  ensureColumn(db, "run_recovery_bindings", "packet_json", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_json TEXT");
31
108
  ensureColumn(db, "run_recovery_bindings", "packet_sha256", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_sha256 TEXT");
109
+ ensureColumn(db, "run_recovery_bindings", "runtime_dispatch_json", "ALTER TABLE run_recovery_bindings ADD COLUMN runtime_dispatch_json TEXT");
32
110
  }
33
111
  /** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
34
112
  export function ensureVnextWorkStorage(db) {
@@ -39,7 +117,7 @@ export function ensureVnextWorkStorage(db) {
39
117
  if (existing && (!/task TEXT NOT NULL/.test(existing.sql) || /launch_token|adapter_session_id/.test(existing.sql))) {
40
118
  throw new AppError("beta_storage_recreate_required", "This beta requires a fresh DD_FLOW_HOME; the existing works schema is not compatible", 1);
41
119
  }
42
- db.exec(`
120
+ db.execSchema(`
43
121
  CREATE TABLE IF NOT EXISTS works (
44
122
  work_id TEXT PRIMARY KEY,
45
123
  project_id TEXT NOT NULL,
@@ -119,7 +197,7 @@ export function ensureVnextWorkStorage(db) {
119
197
  /** Durable intent for a provider-root external Work. It is deliberately
120
198
  * separate from `work_sessions`: only a trusted hook may bind that Session. */
121
199
  export function ensureWorkLaunchStorage(db) {
122
- db.exec(`
200
+ db.execSchema(`
123
201
  CREATE TABLE IF NOT EXISTS work_launches (
124
202
  operation_id TEXT PRIMARY KEY,
125
203
  work_id TEXT NOT NULL UNIQUE,
@@ -146,12 +224,40 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
146
224
  if (!exists || mode === "migrate")
147
225
  ensureDir(ddFlowHome);
148
226
  const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
149
- configureDatabase(db, mode);
227
+ db.exec("PRAGMA busy_timeout = 4000");
228
+ if (mode !== "read_existing") {
229
+ try {
230
+ registerStoreWriter(db);
231
+ }
232
+ catch (error) {
233
+ db.close();
234
+ throw error;
235
+ }
236
+ }
237
+ try {
238
+ configureDatabase(db, mode);
239
+ }
240
+ catch (error) {
241
+ db.close();
242
+ throw error;
243
+ }
150
244
  // Internal additive schema changes must be available to every write command.
151
245
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
152
246
  if (mode !== "read_existing") {
153
- migrate(db, dbPath);
154
- ensureRecoveryStorage(db);
247
+ try {
248
+ const initialize = () => { migrate(db); ensureRecoveryStorage(db); };
249
+ const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
250
+ if (fresh || readStoreWriterContract(db) !== null)
251
+ executeStoreSchema(db, initialize, fresh);
252
+ else
253
+ initialize();
254
+ // This migration owns a VACUUM backup and a separate data transaction.
255
+ migrateSessionStorageKeys(db, dbPath);
256
+ }
257
+ catch (error) {
258
+ db.close();
259
+ throw error;
260
+ }
155
261
  }
156
262
  if (mode === "read_existing")
157
263
  db.exec("PRAGMA query_only = ON");
@@ -161,6 +267,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
161
267
  writable: mode !== "read_existing",
162
268
  close: () => db.close?.(),
163
269
  exec: (sql) => db.exec(sql),
270
+ execSchema: (sql) => executeStoreSchema(db, sql),
164
271
  run: (sql, params = []) => db.prepare(sql).run(...params),
165
272
  get: (sql, params = []) => db.prepare(sql).get(...params),
166
273
  all: (sql, params = []) => db.prepare(sql).all(...params)
@@ -178,9 +285,25 @@ export function getResourceDatabase(resourceHome) {
178
285
  if (cached)
179
286
  return cached;
180
287
  const db = new DatabaseSync(dbPath);
181
- configureDatabase(db, "initialize");
288
+ db.exec("PRAGMA busy_timeout = 4000");
289
+ try {
290
+ registerStoreWriter(db);
291
+ }
292
+ catch (error) {
293
+ db.close();
294
+ throw error;
295
+ }
296
+ try {
297
+ configureDatabase(db, "initialize");
298
+ }
299
+ catch (error) {
300
+ db.close();
301
+ throw error;
302
+ }
182
303
  db.exec("PRAGMA foreign_keys = ON");
183
- db.exec(`
304
+ const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
305
+ try {
306
+ executeStoreSchema(db, `
184
307
  CREATE TABLE IF NOT EXISTS managed_processes (
185
308
  id TEXT PRIMARY KEY,
186
309
  kind TEXT NOT NULL,
@@ -219,12 +342,18 @@ export function getResourceDatabase(resourceHome) {
219
342
  );
220
343
  CREATE INDEX IF NOT EXISTS idx_managed_resources_lease
221
344
  ON managed_resources(lease_expires_at, updated_at);
222
- `);
345
+ `, fresh);
346
+ }
347
+ catch (error) {
348
+ db.close();
349
+ throw error;
350
+ }
223
351
  const database = {
224
352
  path: dbPath,
225
353
  writable: true,
226
354
  close: () => { resourceDatabases.delete(dbPath); db.close?.(); },
227
355
  exec: (sql) => db.exec(sql),
356
+ execSchema: (sql) => executeStoreSchema(db, sql),
228
357
  run: (sql, params = []) => db.prepare(sql).run(...params),
229
358
  get: (sql, params = []) => db.prepare(sql).get(...params),
230
359
  all: (sql, params = []) => db.prepare(sql).all(...params)
@@ -237,19 +366,34 @@ function emptyReadOnlyDatabase(dbPath) {
237
366
  path: dbPath,
238
367
  writable: false,
239
368
  exec: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
369
+ execSchema: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
240
370
  run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
241
371
  get: () => undefined,
242
372
  all: () => []
243
373
  };
244
374
  }
245
375
  function configureDatabase(db, mode) {
246
- db.exec("PRAGMA busy_timeout = 4000");
247
376
  if (mode !== "read_existing") {
248
- db.exec("PRAGMA journal_mode = WAL");
377
+ // Concurrent first openers can collide while changing journal mode, even
378
+ // with busy_timeout set. Retrying this configuration does not replay writes.
379
+ const deadline = performance.now() + 4000, pause = new Int32Array(new SharedArrayBuffer(4));
380
+ for (;;) {
381
+ try {
382
+ const current = db.prepare("PRAGMA journal_mode").get();
383
+ if (current.journal_mode !== "wal")
384
+ db.exec("PRAGMA journal_mode = WAL");
385
+ break;
386
+ }
387
+ catch (error) {
388
+ if ((error.errcode ?? 0) % 256 !== 5 || performance.now() >= deadline)
389
+ throw error;
390
+ Atomics.wait(pause, 0, 0, Math.min(25, Math.max(1, deadline - performance.now())));
391
+ }
392
+ }
249
393
  db.exec("PRAGMA synchronous = NORMAL");
250
394
  }
251
395
  }
252
- function migrate(db, dbPath) {
396
+ function migrate(db) {
253
397
  db.exec(`
254
398
  CREATE TABLE IF NOT EXISTS projects (
255
399
  id TEXT PRIMARY KEY,
@@ -855,7 +999,6 @@ function migrate(db, dbPath) {
855
999
  ensureColumn(db, "sessions", "plan_item_id", "ALTER TABLE sessions ADD COLUMN plan_item_id TEXT");
856
1000
  ensureColumn(db, "sessions", "session_kind", "ALTER TABLE sessions ADD COLUMN session_kind TEXT");
857
1001
  ensureColumn(db, "sessions", "coverage_units_json", "ALTER TABLE sessions ADD COLUMN coverage_units_json TEXT NOT NULL DEFAULT '[]'");
858
- migrateSessionStorageKeys(db, dbPath);
859
1002
  ensureColumn(db, "usage", "cache_read_input_tokens", "ALTER TABLE usage ADD COLUMN cache_read_input_tokens INTEGER");
860
1003
  ensureColumn(db, "usage", "cache_write_input_tokens", "ALTER TABLE usage ADD COLUMN cache_write_input_tokens INTEGER");
861
1004
  ensureColumn(db, "usage", "uncached_input_tokens", "ALTER TABLE usage ADD COLUMN uncached_input_tokens INTEGER");
@@ -871,7 +1014,11 @@ function migrate(db, dbPath) {
871
1014
  ensureColumn(db, "merge_requests", "replacement_of_merge_request_id", "ALTER TABLE merge_requests ADD COLUMN replacement_of_merge_request_id TEXT");
872
1015
  // A failed integration gate may create a new immutable source candidate in
873
1016
  // the same RUN. The historical unique index made that repair path impossible.
874
- db.exec("DROP INDEX IF EXISTS idx_merge_requests_run");
1017
+ // Rebuilding an already-current index changes schema/data_version on every
1018
+ // CLI observation and prevents a controller's writer barrier from settling.
1019
+ const mergeRunIndex = db.prepare("PRAGMA index_list(merge_requests)").all().find(index => index.name === "idx_merge_requests_run");
1020
+ if (mergeRunIndex?.unique)
1021
+ db.exec("DROP INDEX idx_merge_requests_run");
875
1022
  db.exec("CREATE INDEX IF NOT EXISTS idx_merge_requests_run ON merge_requests(project_id, run_id, created_at)");
876
1023
  ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
877
1024
  ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
@@ -0,0 +1,76 @@
1
+ import crypto from "node:crypto";
2
+ import { AppError } from "../shared/errors.js";
3
+ const version = 1;
4
+ const table = "dd_flow_writer_contract";
5
+ const quote = (name) => `"${name.replaceAll('"', '""')}"`;
6
+ /** Publish additive schema and its writer fences in the same transaction. */
7
+ export function executeStoreSchema(db, sql, initialize = false) {
8
+ const ownsTransaction = !db.isTransaction;
9
+ db.exec(ownsTransaction ? "BEGIN IMMEDIATE" : "SAVEPOINT dd_flow_schema_change");
10
+ try {
11
+ const protectedStore = readStoreWriterContract(db) !== null;
12
+ if (initialize && !protectedStore && db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get()) {
13
+ throw new AppError("store_initialization_conflict", "Another writer initialized this store; explicit migration is required", 1);
14
+ }
15
+ const before = db.prepare("PRAGMA schema_version").get();
16
+ if (typeof sql === "string")
17
+ db.exec(sql);
18
+ else
19
+ sql();
20
+ const after = db.prepare("PRAGMA schema_version").get();
21
+ if (initialize || (protectedStore && before.schema_version !== after.schema_version))
22
+ installStoreWriterContract(db);
23
+ db.exec(ownsTransaction ? "COMMIT" : "RELEASE dd_flow_schema_change");
24
+ }
25
+ catch (error) {
26
+ db.exec(ownsTransaction ? "ROLLBACK" : "ROLLBACK TO dd_flow_schema_change; RELEASE dd_flow_schema_change");
27
+ throw error;
28
+ }
29
+ }
30
+ /** Registration is connection-local and never changes historical input bytes. */
31
+ export function registerStoreWriter(db) {
32
+ const current = readStoreWriterContract(db);
33
+ if (current !== null && current !== version)
34
+ throw new AppError("store_writer_incompatible", "This CLI cannot write the store's required contract", 1, { required: current, supported: version });
35
+ db.function("dd_flow_writer_version", () => version);
36
+ }
37
+ export function readStoreWriterContract(db) {
38
+ if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table))
39
+ return null;
40
+ const rows = db.prepare(`SELECT id, version FROM ${table}`).all();
41
+ if (rows.length !== 1 || rows[0]?.id !== 1 || !Number.isSafeInteger(rows[0].version) || rows[0].version < 1)
42
+ throw new AppError("store_writer_contract_invalid", "Store writer contract is malformed", 1);
43
+ return rows[0].version;
44
+ }
45
+ /** Install only at an explicit migration boundary; ordinary reads never call it. */
46
+ export function installStoreWriterContract(db) {
47
+ registerStoreWriter(db);
48
+ const ownsTransaction = !db.isTransaction;
49
+ if (ownsTransaction)
50
+ db.exec("BEGIN IMMEDIATE");
51
+ try {
52
+ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL);
53
+ INSERT OR IGNORE INTO ${table} (id, version) VALUES (1, ${version});`);
54
+ if (readStoreWriterContract(db) !== version)
55
+ throw new AppError("store_writer_incompatible", "Store writer contract changed before migration", 1);
56
+ const tables = db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
57
+ for (const target of tables) {
58
+ const key = crypto.createHash("sha256").update(target.name).digest("hex").slice(0, 24);
59
+ for (const action of ["INSERT", "UPDATE", "DELETE"]) {
60
+ const trigger = quote(`dd_flow_writer_${key}_${action.toLowerCase()}`);
61
+ db.exec(`DROP TRIGGER IF EXISTS ${trigger};
62
+ CREATE TRIGGER ${trigger} BEFORE ${action} ON ${quote(target.name)} BEGIN
63
+ SELECT CASE WHEN dd_flow_writer_version() <> (SELECT version FROM ${table} WHERE id = 1)
64
+ OR (SELECT COUNT(*) FROM ${table}) <> 1 THEN RAISE(ABORT, 'store_writer_incompatible') END;
65
+ END;`);
66
+ }
67
+ }
68
+ if (ownsTransaction)
69
+ db.exec("COMMIT");
70
+ }
71
+ catch (error) {
72
+ if (ownsTransaction)
73
+ db.exec("ROLLBACK");
74
+ throw error;
75
+ }
76
+ }
@@ -0,0 +1,102 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { installStoreWriterContract, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
7
+ /** Explicit one-store cutover. The caller must separately drain external owners. */
8
+ export function migrateStoreWriter(file, backup) {
9
+ if (!path.isAbsolute(file) || !path.isAbsolute(backup) || file === backup || !fs.existsSync(file) || !fs.statSync(file).isFile())
10
+ throw new AppError("store_migration_path_invalid", "Migration requires an existing database and a new absolute backup path", 1);
11
+ const source = fs.realpathSync(file);
12
+ const destination = path.join(fs.realpathSync(path.dirname(backup)), path.basename(backup));
13
+ if (fs.lstatSync(file).isSymbolicLink() || [source, `${source}-wal`, `${source}-shm`, `${source}-journal`].includes(destination))
14
+ throw new AppError("store_migration_path_invalid", "Backup cannot alias the source database or its SQLite sidecars", 1);
15
+ try {
16
+ const existing = fs.lstatSync(backup);
17
+ if (!existing.isFile() || existing.isSymbolicLink())
18
+ throw new AppError("store_migration_path_invalid", "Backup path already exists", 1);
19
+ const reader = new DatabaseSync(file, { readOnly: true });
20
+ try {
21
+ const recorded = reader.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'dd_flow_writer_migrations'").get()
22
+ ? reader.prepare("SELECT receipt_json FROM dd_flow_writer_migrations WHERE backup_path = ?").get(destination) : null;
23
+ const receipt = recorded ? JSON.parse(String(recorded.receipt_json)) : null;
24
+ if (!receipt || receipt.database !== file || receipt.backup !== backup || receipt.writer_version !== readStoreWriterContract(reader) || receipt.backup_sha256 !== crypto.createHash("sha256").update(fs.readFileSync(backup)).digest("hex")) {
25
+ throw new AppError("store_migration_path_invalid", "Existing backup has no matching committed migration receipt", 1);
26
+ }
27
+ return receipt;
28
+ }
29
+ finally {
30
+ reader.close();
31
+ }
32
+ }
33
+ catch (error) {
34
+ if (error.code !== "ENOENT")
35
+ throw error;
36
+ }
37
+ const db = new DatabaseSync(file);
38
+ try {
39
+ db.exec("PRAGMA busy_timeout = 1000");
40
+ registerStoreWriter(db);
41
+ db.exec("BEGIN IMMEDIATE");
42
+ try {
43
+ const previous = readStoreWriterContract(db);
44
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'").all().map(row => row.name));
45
+ const gates = [
46
+ ["managed_processes", "state IS NULL OR state NOT IN ('stopped','failed')"],
47
+ ["run_controllers", "status IS NULL OR status NOT IN ('completed','stop_target_reached','cancelled','superseded')"],
48
+ ["run_control_workers", "status IS NULL OR status NOT IN ('completed','failed','superseded')"],
49
+ ["work_sessions", "status IS NULL OR status NOT IN ('completed','failed','cancelled')"]
50
+ ];
51
+ for (const [table, predicate] of gates) {
52
+ if (tables.has(table) && db.prepare(`SELECT 1 FROM ${table} WHERE ${predicate} LIMIT 1`).get())
53
+ throw new AppError("store_migration_not_drained", "Store still contains unresolved writer ownership", 1, { table });
54
+ }
55
+ // A separate reader sees committed WAL while this connection prevents new writes.
56
+ const reader = new DatabaseSync(file, { readOnly: true });
57
+ try {
58
+ reader.exec(`VACUUM INTO '${backup.replaceAll("'", "''")}'`);
59
+ }
60
+ finally {
61
+ reader.close();
62
+ }
63
+ const descriptor = fs.openSync(backup, "r");
64
+ try {
65
+ fs.fsyncSync(descriptor);
66
+ }
67
+ finally {
68
+ fs.closeSync(descriptor);
69
+ }
70
+ const directory = fs.openSync(path.dirname(backup), "r");
71
+ try {
72
+ fs.fsyncSync(directory);
73
+ }
74
+ finally {
75
+ fs.closeSync(directory);
76
+ }
77
+ const verification = new DatabaseSync(backup, { readOnly: true });
78
+ try {
79
+ const check = verification.prepare("PRAGMA integrity_check").get();
80
+ if (Object.values(check)[0] !== "ok" || readStoreWriterContract(verification) !== previous)
81
+ throw new AppError("store_migration_backup_invalid", "Backup failed integrity or contract verification", 1);
82
+ }
83
+ finally {
84
+ verification.close();
85
+ }
86
+ const receipt = { database: file, backup, backup_sha256: crypto.createHash("sha256").update(fs.readFileSync(backup)).digest("hex"), previous_version: previous, writer_version: 1 };
87
+ db.exec("CREATE TABLE IF NOT EXISTS dd_flow_writer_migrations (backup_path TEXT PRIMARY KEY, receipt_json TEXT NOT NULL)");
88
+ db.prepare("INSERT INTO dd_flow_writer_migrations VALUES (?, ?)").run(destination, JSON.stringify(receipt));
89
+ installStoreWriterContract(db);
90
+ db.exec("COMMIT");
91
+ return receipt;
92
+ }
93
+ catch (error) {
94
+ if (db.isTransaction)
95
+ db.exec("ROLLBACK");
96
+ throw error;
97
+ }
98
+ }
99
+ finally {
100
+ db.close();
101
+ }
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.37",
3
+ "version": "0.9.0-beta.39",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {