@deksden-com/dd-flow-cli 0.9.0-beta.55 → 0.9.0-beta.56

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.56
4
+
5
+ ### Patch Changes
6
+
7
+ - Keep RUN database and runtime status aligned with pause, stop, controller failures and trusted recovery. Preserve real Stage pauses and terminal results, reject late revival under a recovery fence, roll back status changes after persistence failures, and provide an explicit backed-up legacy status repair tool.
8
+
3
9
  ## 0.9.0-beta.55
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -452,3 +452,25 @@ pnpm build
452
452
  ```
453
453
 
454
454
  Manual smoke checks should use a temporary `DD_FLOW_HOME` when they do not intentionally inspect the operator's real local state.
455
+
456
+ ### RUN status during recovery
457
+
458
+ A controller failure pauses the logical RUN without rewriting its Stage/Work
459
+ outcomes. Accepted pause/stop or recovery intent immediately persists `paused` in the RUN row,
460
+ `index_json`, and `run.json`. A trusted recovery acknowledgement restores the
461
+ operational status; an existing Stage/Work pause remains paused. Terminal RUNs
462
+ are never reopened. Physical tree settlement remains a separate control receipt.
463
+
464
+ Legacy stores with a fenced recovery may still contain `running`. After building,
465
+ inspect one exact RUN with:
466
+
467
+ ```sh
468
+ node tools/repair-paused-run-status.mjs --home /path/to/dd-flow-home --project PRJ-001-project --run RUN-001-eval-subject
469
+ ```
470
+
471
+ Add `--apply` to repair it. This requires the latest recovery to be draining, sealed or resuming, saves the original row and runtime JSON
472
+ beside `run.json`, and changes no schema, engine binding or recovery capture.
473
+ It does not claim physical settlement or alter its receipts. `--cancel-abandoned`
474
+ explicitly cancels an abandoned fixture only when the project directory is absent
475
+ and it has no Work, Session, controller or recovery history. Historical experiment
476
+ engine snapshots remain pinned; rebuild/release the engine for future executions.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.55",
4
- "cli_commit": "84e76cfa798732b3121534551515b02f4a238425",
5
- "built_at": "2026-09-13T19:08:39.320Z",
3
+ "cli_version": "0.9.0-beta.56",
4
+ "cli_commit": "ae4bbaa5b2299aeedf3ee90bccc8ed3db19a6191",
5
+ "built_at": "2026-09-13T21:08:32.108Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
@@ -1,3 +1,4 @@
1
+ import { setFlowRunRecoveryPaused } from "./runs.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import { ensureRunControlStorage } from "../storage/database.js";
@@ -746,6 +747,7 @@ export function recordRunControlIntent(context, input) {
746
747
  FROM runs r, json_each(r.index_json, '$.stage_runs') stage
747
748
  WHERE r.project_id = ? AND r.id = ? AND json_extract(stage.value, '$.status') = 'running'
748
749
  AND EXISTS (SELECT 1 FROM run_control_admissions a JOIN sessions s ON s.session_id = a.session_id AND s.project_id = r.project_id WHERE a.control_id = ? AND s.current_stage = json_extract(stage.value, '$.stage'))`, [controlId, controlId, project.id, input.runId, controlId]);
750
+ setFlowRunRecoveryPaused(context, project.id, input.runId, true);
749
751
  context.db.exec("COMMIT");
750
752
  return { ok: true, control_id: controlId, recovery_id: recoveryId, generation, action: input.action, status: "requested", settled: false };
751
753
  }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { AppError } from "../shared/errors.js";
7
7
  import { getResourceDatabase } from "../storage/database.js";
8
8
  import { finishManagedProcess, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
9
+ import { setFlowRunRecoveryPaused } from "./runs.js";
9
10
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
10
11
  import { requireRun, requireController, controllerReceipt, appendEvent, digest, errorRecord } from "./run-controller-state.js";
11
12
  const launchId = (row) => `${row.controller_id}:owner-launch:${digest(row.owner_token)}`;
@@ -172,7 +173,17 @@ export async function spawnController(context, row) {
172
173
  return controllerReceipt(requireController(context, controllerId));
173
174
  }
174
175
  catch (error) {
175
- context.db.run("UPDATE run_controllers SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(errorRecord(error)), context.now(), controllerId, token]);
176
+ context.db.exec("SAVEPOINT controller_spawn_status");
177
+ try {
178
+ const changed = context.db.run("UPDATE run_controllers SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(errorRecord(error)), context.now(), controllerId, token]);
179
+ if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation)
180
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
181
+ context.db.exec("RELEASE controller_spawn_status");
182
+ }
183
+ catch (failure) {
184
+ context.db.exec("ROLLBACK TO controller_spawn_status; RELEASE controller_spawn_status");
185
+ throw failure;
186
+ }
176
187
  throw error;
177
188
  }
178
189
  }
@@ -10,7 +10,7 @@ import { AppError } from "../shared/errors.js";
10
10
  import { parseRuntimeBudget } from "./runtime-budget.js";
11
11
  import { requireProjectByRoot } from "./projects.js";
12
12
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
13
- import { getFlowRunStatus } from "./runs.js";
13
+ import { getFlowRunStatus, setFlowRunRecoveryPaused } from "./runs.js";
14
14
  import { loadExternalStageContext } from "./stage-context.js";
15
15
  import { flowCommand } from "./stage-pause.js";
16
16
  import { resolveStageTransition, sameSessionProfile, runtimeHarness } from "./execution-policy.js";
@@ -202,7 +202,17 @@ export async function serveRunController(context, input) {
202
202
  };
203
203
  const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
204
204
  try {
205
- markControllerDispatchReady(context, row);
205
+ context.db.exec("SAVEPOINT controller_ready_status");
206
+ try {
207
+ markControllerDispatchReady(context, row);
208
+ if (!state.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
209
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
210
+ context.db.exec("RELEASE controller_ready_status");
211
+ }
212
+ catch (error) {
213
+ context.db.exec("ROLLBACK TO controller_ready_status; RELEASE controller_ready_status");
214
+ throw error;
215
+ }
206
216
  appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
207
217
  if (state.recovery)
208
218
  await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
@@ -221,10 +231,21 @@ export async function serveRunController(context, input) {
221
231
  return controllerReceipt(requireController(context, row.controller_id));
222
232
  }
223
233
  }
224
- // A failed capture must not reopen admission when this process exits.
225
- context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
226
- context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
227
- appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
234
+ context.db.exec("SAVEPOINT controller_failure_status");
235
+ try {
236
+ // A failed capture must not reopen admission when this process exits.
237
+ const changed = context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status NOT IN ('completed','cancelled','superseded')", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
238
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
239
+ if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
240
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
241
+ }
242
+ appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
243
+ context.db.exec("RELEASE controller_failure_status");
244
+ }
245
+ catch (failure) {
246
+ context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
247
+ throw failure;
248
+ }
228
249
  throw error;
229
250
  }
230
251
  finally {
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { requireProjectByRoot } from "./projects.js";
8
- import { appendFlowRunTimelineEvent } from "./runs.js";
8
+ import { appendFlowRunTimelineEvent, setFlowRunRecoveryPaused } from "./runs.js";
9
9
  import { verifyRecoverySnapshot } from "./eval-snapshots.js";
10
10
  import { findRecentMatchingHookEvent, hookSessionIdentity, recoveryAcceptMatchKey } from "./hooks.js";
11
11
  import { flowCommand } from "./stage-pause.js";
@@ -123,6 +123,7 @@ export function beginRunRecovery(context, input) {
123
123
  context.db.run("UPDATE run_recovery_bindings SET status = 'abandoned' WHERE recovery_id = ? AND status = 'prepared'", [prior.recovery_id]);
124
124
  }
125
125
  context.db.run("INSERT INTO run_recovery_guards (recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at) VALUES (?, ?, ?, ?, 'draining', ?, NULL, NULL, ?, ?, NULL, NULL)", [recoveryId, project.id, run.id, generation, JSON.stringify(interruption), now, now]);
126
+ setFlowRunRecoveryPaused(context, project.id, run.id, true);
126
127
  context.db.exec("COMMIT");
127
128
  }
128
129
  catch (error) {
@@ -147,9 +148,18 @@ export function sealRunRecovery(context, input) {
147
148
  throw new AppError("recovery_invalid_state", "Recovery is not draining", 1, { recovery_id: guard.recovery_id, status: guard.status });
148
149
  materializePendingWorkResults(context, project.id, run.id);
149
150
  const now = context.now();
150
- const updated = context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining' AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [JSON.stringify(settlement), now, now, guard.recovery_id, project.id, run.id]);
151
- if (updated.changes !== 1)
152
- throw new AppError("recovery_state_changed", "Recovery state changed before it could be sealed", 1, { recovery_id: guard.recovery_id });
151
+ context.db.exec("SAVEPOINT seal_run_status");
152
+ try {
153
+ const updated = context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining' AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [JSON.stringify(settlement), now, now, guard.recovery_id, project.id, run.id]);
154
+ if (updated.changes !== 1)
155
+ throw new AppError("recovery_state_changed", "Recovery state changed before it could be sealed", 1, { recovery_id: guard.recovery_id });
156
+ setFlowRunRecoveryPaused(context, project.id, run.id, true);
157
+ context.db.exec("RELEASE seal_run_status");
158
+ }
159
+ catch (error) {
160
+ context.db.exec("ROLLBACK TO seal_run_status; RELEASE seal_run_status");
161
+ throw error;
162
+ }
153
163
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
154
164
  return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
155
165
  }
@@ -286,6 +296,7 @@ export function acceptRunRecovery(context, input) {
286
296
  }
287
297
  context.db.run("UPDATE run_recovery_bindings SET status = 'accepted', hook_event_key = ?, accepted_at = ? WHERE recovery_id = ?", [eventKey, now, binding.recovery_id]);
288
298
  context.db.run("UPDATE run_recovery_guards SET status = 'recovered', resumed_at = ?, updated_at = ? WHERE recovery_id = ?", [now, now, guard.recovery_id]);
299
+ setFlowRunRecoveryPaused(context, project.id, run.id, false);
289
300
  context.db.exec("COMMIT");
290
301
  }
291
302
  catch (error) {
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
6
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
- import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
8
+ import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
11
  import { buildRunFlowGuidance } from "./flow-guidance.js";
@@ -788,8 +788,24 @@ function operationalRunStatus(context, projectId, runId) {
788
788
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
789
789
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
790
790
  }
791
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
792
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
793
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
794
+ const run = resolveRun(context, projectId, runId);
795
+ if (!["running", "paused"].includes(run.status))
796
+ return;
797
+ const index = authoritativeIndex(run);
798
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
799
+ ? "paused" : operationalRunStatus(context, projectId, runId);
800
+ if (run.status === status && index.status === status)
801
+ return;
802
+ index.status = status;
803
+ index.updated_at = context.now();
804
+ const project = requireProjectByRoot(context, run.project_root);
805
+ persistRunState(context, project, run, index);
806
+ }
791
807
  export function completeFlowRun(context, input) {
792
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
808
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
793
809
  const run = resolveRun(context, project.id, input.runId);
794
810
  assertRunMutationAllowed(context, project.id, run.id);
795
811
  const index = authoritativeIndex(run);
@@ -856,8 +872,8 @@ export function advanceFlowRun(context, input) {
856
872
  index.next_action = control ? null : input.nextAction ?? null;
857
873
  index.updated_at = now;
858
874
  persistRunState(context, project, run, index);
859
- appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: input.status, verdict: input.verdict, next_action: index.next_action } });
860
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
875
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
876
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
861
877
  const updatedRun = requireRunById(context, project.id, run.id);
862
878
  return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
863
879
  }
@@ -1189,6 +1205,10 @@ function vnextSpecifyGuidance(run, index) {
1189
1205
  };
1190
1206
  }
1191
1207
  function persistRunState(context, project, run, index, options = {}) {
1208
+ // Fenced productive admission is a logical pause, even while physical work
1209
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1210
+ if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1211
+ index.status = "paused";
1192
1212
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1193
1213
  const previousRuntime = readRuntimeSnapshot(runtimePath);
1194
1214
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
@@ -1199,22 +1219,30 @@ function persistRunState(context, project, run, index, options = {}) {
1199
1219
  }
1200
1220
  const persistedIndex = persistedVnextIndex(index);
1201
1221
  ensureDir(path.dirname(runtimePath));
1202
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1203
- context.db.run(`UPDATE runs
1222
+ context.db.exec("SAVEPOINT persist_run_state");
1223
+ try {
1224
+ context.db.run(`UPDATE runs
1204
1225
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1205
1226
  updated_at = ?, completed_at = ?
1206
1227
  WHERE project_id = ? AND id = ?`, [
1207
- index.status,
1208
- index.verdict,
1209
- index.next_action,
1210
- JSON.stringify(persistedIndex),
1211
- runtimePath,
1212
- runtimePath,
1213
- index.updated_at,
1214
- index.completed_at ?? null,
1215
- project.id,
1216
- run.id
1217
- ]);
1228
+ index.status,
1229
+ index.verdict,
1230
+ index.next_action,
1231
+ JSON.stringify(persistedIndex),
1232
+ runtimePath,
1233
+ runtimePath,
1234
+ index.updated_at,
1235
+ index.completed_at ?? null,
1236
+ project.id,
1237
+ run.id
1238
+ ]);
1239
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1240
+ context.db.exec("RELEASE persist_run_state");
1241
+ }
1242
+ catch (error) {
1243
+ context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
1244
+ throw error;
1245
+ }
1218
1246
  }
1219
1247
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1220
1248
  function persistedVnextIndex(index) {
@@ -1576,5 +1604,5 @@ export function gitFacts(workspaceRoot) {
1576
1604
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1577
1605
  };
1578
1606
  }
1579
- import { assertRunMutationAllowed, assertStageSettlementAllowed } from "./run-recovery.js";
1607
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1580
1608
  import { runControlDirective } from "./run-control.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.55",
3
+ "version": "0.9.0-beta.56",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /* global process, console */
3
+ // Explicit repair of legacy fenced RUN projections; no migrations or process control.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { parseArgs } from 'node:util';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import { registerStoreWriter } from '../dist/storage/writer-contract.js';
10
+ import { setFlowRunRecoveryPaused, completeFlowRun } from '../dist/services/runs.js';
11
+
12
+ const { values } = parseArgs({ options: { home: { type: 'string' }, run: { type: 'string' }, project: { type: 'string' }, apply: { type: 'boolean', default: false }, 'cancel-abandoned': { type: 'boolean', default: false } } });
13
+ if (!values.home || !values.run || !values.project) throw new Error('Usage: --home <runtime> --project <project-id> --run <run-id> [--apply]');
14
+ const home = fs.realpathSync(values.home), file = path.join(home, 'db.sqlite');
15
+ const db = new DatabaseSync(file, { readOnly: !values.apply });
16
+ let runtimePath, original, backup, report, changed = false;
17
+ try {
18
+ db.exec('PRAGMA busy_timeout=4000');
19
+ if (values.apply) { registerStoreWriter(db); db.exec('BEGIN IMMEDIATE'); }
20
+ const run = db.prepare('SELECT * FROM runs WHERE project_id=? AND id=?').get(values.project, values.run);
21
+ if (!run) throw new Error('RUN not found');
22
+ const guard = db.prepare('SELECT * FROM run_recovery_guards WHERE project_id=? AND run_id=? ORDER BY generation DESC LIMIT 1').get(values.project, values.run);
23
+ const cancel = values['cancel-abandoned'];
24
+ if (cancel) {
25
+ if (fs.existsSync(run.project_root)) throw new Error('Abandoned-fixture cancellation requires an absent project directory');
26
+ for (const table of ['works', 'sessions', 'run_controllers']) {
27
+ if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(table)) continue;
28
+ if (db.prepare(`SELECT 1 FROM ${table} WHERE project_id=? AND run_id=? LIMIT 1`).get(values.project, values.run)) throw new Error('Abandoned-fixture cancellation requires no Work, Session or controller history');
29
+ }
30
+ if (guard) throw new Error('A recovery-owned RUN requires recovery reconciliation');
31
+ }
32
+ if (!cancel && !["draining", "sealed", "resuming"].includes(guard?.status)) throw new Error("Repair requires the current recovery to fence productive admission");
33
+ if (run.status !== 'running') {
34
+ report = { run_id: run.id, status: run.status, changed: false };
35
+ } else {
36
+ runtimePath = fs.realpathSync(run.runtime_path);
37
+ const relative = path.relative(home, runtimePath);
38
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Runtime file is outside the selected home');
39
+ original = fs.readFileSync(runtimePath);
40
+ if (JSON.parse(original).status !== 'running' || JSON.parse(run.index_json).status !== 'running') throw new Error('RUN projections disagree; manual review required');
41
+ if (values.apply) {
42
+ backup = `${runtimePath}.status-repair-${crypto.randomUUID()}.json`;
43
+ fs.writeFileSync(backup, JSON.stringify({ recovery_id: guard?.recovery_id ?? null, row: run, runtime: original.toString('utf8') }), { flag: 'wx', mode: 0o600 });
44
+ const context = { ddFlowHome: home, env: { ...process.env, DD_FLOW_HOME: home }, now: () => new Date().toISOString(), db: {
45
+ path: file, writable: true, get: (sql, params = []) => db.prepare(sql).get(...params), all: (sql, params = []) => db.prepare(sql).all(...params), run: (sql, params = []) => db.prepare(sql).run(...params), exec: sql => db.exec(sql)
46
+ } };
47
+ changed = true;
48
+ if (cancel) completeFlowRun(context, { projectRoot: run.project_root, runId: run.id, status: 'cancelled', manualOverrideReason: 'Operator requested cleanup of abandoned fixture: project removed; no Work, Session or controller history.' });
49
+ else setFlowRunRecoveryPaused(context, values.project, values.run, true);
50
+ }
51
+ report = { run_id: run.id, from: 'running', to: cancel ? 'cancelled' : 'paused', recovery_id: guard?.recovery_id ?? null, applied: values.apply, backup };
52
+ }
53
+ if (values.apply) db.exec('COMMIT');
54
+ console.log(JSON.stringify(report));
55
+ } catch (error) {
56
+ if (values.apply && db.isTransaction) db.exec('ROLLBACK');
57
+ if (changed && original) { const temporary = `${runtimePath}.repair-rollback`; fs.writeFileSync(temporary, original); fs.renameSync(temporary, runtimePath); }
58
+ throw error;
59
+ } finally { db.close(); }