@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 +6 -0
- package/README.md +22 -0
- package/dist/build-info.json +3 -3
- package/dist/services/run-control.js +2 -0
- package/dist/services/run-controller-process.js +12 -1
- package/dist/services/run-controller.js +27 -6
- package/dist/services/run-recovery.js +15 -4
- package/dist/services/runs.js +46 -18
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +59 -0
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.
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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) {
|
package/dist/services/runs.js
CHANGED
|
@@ -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:
|
|
860
|
-
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status:
|
|
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
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
@@ -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(); }
|