@deksden-com/dd-flow-cli 0.9.0-beta.55 → 0.9.0-beta.62
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 +43 -0
- package/README.md +22 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/help.js +2 -0
- package/dist/cli/run-cli.js +13 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +17 -3
- package/dist/harness-runtime/lib/dd-zcode.mjs +57 -18
- package/dist/services/harness-adapter.js +2 -1
- package/dist/services/hooks.js +15 -7
- package/dist/services/lifecycle-invocations.js +84 -14
- package/dist/services/native-session-control.js +6 -4
- package/dist/services/recovery-observation-budget.js +53 -0
- package/dist/services/run-control-worker.js +55 -8
- package/dist/services/run-control.js +33 -9
- package/dist/services/run-controller-capture.js +20 -3
- package/dist/services/run-controller-process.js +12 -1
- package/dist/services/run-controller-state.js +1 -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/dist/services/runtime-scope-control.js +20 -8
- package/dist/services/runtime-scope-worker.js +50 -5
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -235,7 +235,7 @@ export async function controlRuntimeScopeNativeOwners(context, scopeId, generati
|
|
|
235
235
|
const sessions = await Promise.all(ids.map(async (id) => {
|
|
236
236
|
try {
|
|
237
237
|
let cancellation;
|
|
238
|
-
let
|
|
238
|
+
let cancellationError;
|
|
239
239
|
if (action === "stop") {
|
|
240
240
|
const operationId = `scope-cancel:${crypto.createHash("sha256").update(JSON.stringify([scopeId, generation, record.id, state.daemon_id, id])).digest("hex")}`;
|
|
241
241
|
const now = context.now();
|
|
@@ -255,22 +255,34 @@ export async function controlRuntimeScopeNativeOwners(context, scopeId, generati
|
|
|
255
255
|
if (saved.status === "completed") {
|
|
256
256
|
assertNativeCancellationReceipt(saved.receipt, id);
|
|
257
257
|
cancellation = saved.receipt;
|
|
258
|
-
|
|
258
|
+
}
|
|
259
|
+
else if (saved.status === "failed") {
|
|
260
|
+
cancellation = saved.receipt;
|
|
261
|
+
try {
|
|
262
|
+
cancellationError = JSON.parse(saved.error ?? "");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
cancellationError = { code: "cancellation_outcome_unknown", message: saved.error ?? "Retained cancellation has no conclusive outcome" };
|
|
266
|
+
}
|
|
259
267
|
}
|
|
260
268
|
else {
|
|
261
269
|
const result = await cancelControlledNativeSession({ executable: metadata.adapter_executable, sessionId: id, stateDir: metadata.state_dir, env, operationId, observe: !inserted, assertCurrent: assertOwner });
|
|
262
270
|
registry.run("UPDATE managed_resources SET metadata_json = json_set(metadata_json, '$.status', ?, '$.receipt', json(?)), updated_at = ? WHERE resource_kind = 'scope-control-operation' AND resource_key = ?", [result.status, JSON.stringify(result.receipt), context.now(), operationId]);
|
|
263
271
|
cancellation = result.receipt;
|
|
264
|
-
cancellationAcknowledged = result.status === "completed";
|
|
265
272
|
}
|
|
266
273
|
}
|
|
267
274
|
catch (error) {
|
|
268
|
-
|
|
269
|
-
|
|
275
|
+
cancellationError = { code: error instanceof AppError ? error.code : "cancellation_outcome_unknown", message: String(error) };
|
|
276
|
+
registry.run("UPDATE managed_resources SET metadata_json = json_set(metadata_json, '$.status', 'outcome_unknown', '$.error', ?), updated_at = ? WHERE resource_kind = 'scope-control-operation' AND resource_key = ?", [JSON.stringify(cancellationError), context.now(), operationId]);
|
|
270
277
|
}
|
|
278
|
+
if (cancellationError?.code === "controller_operation_mismatch")
|
|
279
|
+
throw new AppError("controller_operation_mismatch", "Cancellation evidence belongs to another operation or Session", 1);
|
|
271
280
|
}
|
|
272
281
|
const receipt = await inspectControlledNativeSession({ executable: metadata.adapter_executable, sessionId: id, stateDir: metadata.state_dir, env, operationId: `scope:${scopeId}:${generation}:inspect:${crypto.randomUUID()}`, assertCurrent: assertOwner });
|
|
273
|
-
|
|
282
|
+
// Physical settlement is determined by this fresh inspection. A past
|
|
283
|
+
// cancel ACK (or an inconclusive cancel outcome) is retained as
|
|
284
|
+
// evidence, never substituted for the state of the native tree.
|
|
285
|
+
return { session_id: id, settled: receipt.settled === true, receipt, ...(cancellation ? { cancellation } : {}), ...(cancellationError ? { cancellation_error: cancellationError } : {}) };
|
|
274
286
|
}
|
|
275
287
|
catch (error) {
|
|
276
288
|
return { session_id: id, settled: false, reason: error instanceof AppError ? error.code : "runtime_scope_session_control_failed", message: String(error) };
|
|
@@ -294,7 +306,7 @@ export async function controlRuntimeScopeNativeOwners(context, scopeId, generati
|
|
|
294
306
|
}));
|
|
295
307
|
}
|
|
296
308
|
/** Scope routing only: existing RUN workers own physical drain and capture. */
|
|
297
|
-
export async function controlRuntimeScopeRuns(context, scopeId, requestId, mode) {
|
|
309
|
+
export async function controlRuntimeScopeRuns(context, scopeId, requestId, mode, reconcileId) {
|
|
298
310
|
const control = requestRuntimeScopeControl(context, scopeId, requestId, mode);
|
|
299
311
|
const registry = getResourceDatabase(resourceHome(context));
|
|
300
312
|
const inventory = runtimeBudgetStatus(context, scopeId).processes;
|
|
@@ -341,7 +353,7 @@ export async function controlRuntimeScopeRuns(context, scopeId, requestId, mode)
|
|
|
341
353
|
throw error;
|
|
342
354
|
}
|
|
343
355
|
seen.add(key);
|
|
344
|
-
const receipt = await requestRunControl(target, { ...input, waitMs: 0 });
|
|
356
|
+
const receipt = await requestRunControl(target, { ...input, waitMs: 0, ...(reconcileId ? { reconcileId: crypto.createHash("sha256").update(JSON.stringify([scopeId, control.generation, reconcileId])).digest("hex") } : {}) });
|
|
345
357
|
runs.push({ runtime_home: metadata.dd_flow_home, project_id: record.project_id, run_id: record.run_id, request_id: input.requestId, receipt });
|
|
346
358
|
}
|
|
347
359
|
catch (error) {
|
|
@@ -10,6 +10,7 @@ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, p
|
|
|
10
10
|
import { publicRuntimeScopeWorker, requestRuntimeScopeControl, runtimeBudgetStatus } from "./runtime-budget.js";
|
|
11
11
|
import { controlRuntimeScopeRuns } from "./runtime-scope-control.js";
|
|
12
12
|
import { prepareRuntimeScopeResume, recordRuntimeScopeResume } from "./runtime-scope-resume.js";
|
|
13
|
+
import { recoveryObservationBudget, renewRecoveryObservation, withRecoveryObservation } from "./recovery-observation-budget.js";
|
|
13
14
|
export async function launchRuntimeScopeResume(context, input) {
|
|
14
15
|
const resume = recordRuntimeScopeResume(context, input);
|
|
15
16
|
if (!resume.current)
|
|
@@ -19,7 +20,7 @@ export async function launchRuntimeScopeResume(context, input) {
|
|
|
19
20
|
const control = runtimeBudgetStatus(context, input.scopeId).control;
|
|
20
21
|
if (control?.generation !== input.generation)
|
|
21
22
|
throw new AppError("runtime_scope_control_superseded", "Scope resume source changed before owner launch", 1);
|
|
22
|
-
const owner = await launchRuntimeScopeControl(context, input.scopeId, control.request_id, control.requested_mode);
|
|
23
|
+
const owner = await launchRuntimeScopeControl(context, input.scopeId, control.request_id, control.requested_mode, undefined, undefined, input.requestId);
|
|
23
24
|
return { ...owner, resume, dispatch_blocked: true, settled: false };
|
|
24
25
|
}
|
|
25
26
|
const keyFor = (scope, generation) => crypto.createHash("sha256").update(JSON.stringify([scope, generation])).digest("hex");
|
|
@@ -29,7 +30,14 @@ function birth() {
|
|
|
29
30
|
throw new AppError("runtime_scope_owner_unknown", "Scope owner birth identity is unavailable", 1);
|
|
30
31
|
return { pid: process.pid, pid_started_at: started };
|
|
31
32
|
}
|
|
32
|
-
export async function
|
|
33
|
+
export async function reconcileRuntimeScopeControl(context, scopeId, generation, requestId) {
|
|
34
|
+
const control = runtimeBudgetStatus(context, scopeId).control;
|
|
35
|
+
if (!control || control.generation !== generation)
|
|
36
|
+
throw new AppError("runtime_scope_control_superseded", "Reconciliation requires the current controlled scope generation", 1);
|
|
37
|
+
renewRecoveryObservation({}, requestId);
|
|
38
|
+
return launchRuntimeScopeControl(context, scopeId, control.request_id, control.requested_mode, undefined, requestId);
|
|
39
|
+
}
|
|
40
|
+
export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest, reconcileId, resumeId) {
|
|
33
41
|
const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
|
|
34
42
|
let worker;
|
|
35
43
|
db.exec("BEGIN IMMEDIATE");
|
|
@@ -50,7 +58,18 @@ export async function launchRuntimeScopeControl(context, scopeId, requestId, mod
|
|
|
50
58
|
db.exec("COMMIT");
|
|
51
59
|
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
52
60
|
}
|
|
53
|
-
|
|
61
|
+
if (existing?.status === "completed" && !reconcileId && !resumeId) {
|
|
62
|
+
db.exec("COMMIT");
|
|
63
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
64
|
+
}
|
|
65
|
+
const renewal = resumeId ? `resume:${resumeId}` : reconcileId;
|
|
66
|
+
const snapshot = renewal ? renewRecoveryObservation(existing?.snapshot ?? {}, renewal) : existing?.snapshot;
|
|
67
|
+
if (existing && recoveryObservationBudget(snapshot).state().remaining_ms === 0) {
|
|
68
|
+
db.exec("COMMIT");
|
|
69
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
70
|
+
}
|
|
71
|
+
const cleanupRequest = resumeId ? undefined : reconcileId ?? existing?.reconcile_id;
|
|
72
|
+
worker = { scope_id: scopeId, generation: control.generation, request_id: requestId, mode, token: crypto.randomUUID(), status: "starting", launcher: birth(), ...(snapshot ? { snapshot } : {}), ...(cleanupRequest ? { reconcile_id: cleanupRequest } : {}) };
|
|
54
73
|
const now = context.now();
|
|
55
74
|
db.run("INSERT INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-worker',?,?,?,?,NULL,?,?,?) ON CONFLICT(resource_kind,resource_key) DO UPDATE SET lease_token = excluded.lease_token, metadata_json = excluded.metadata_json, updated_at = excluded.updated_at", [key, scopeId, worker.token, now, JSON.stringify(worker), now, now]);
|
|
56
75
|
db.exec("COMMIT");
|
|
@@ -107,6 +126,10 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
|
|
|
107
126
|
throw error;
|
|
108
127
|
}
|
|
109
128
|
let heartbeat;
|
|
129
|
+
// A fence must never be held indefinitely by an observer that cannot obtain
|
|
130
|
+
// fresh provider evidence. A later operator request continues the retained
|
|
131
|
+
// budget; this owner records why it stopped without claiming settlement.
|
|
132
|
+
const observation = recoveryObservationBudget(worker.snapshot);
|
|
110
133
|
try {
|
|
111
134
|
const record = registerManagedProcess(context, { kind: "scope-control", ownerId: key, operationId: `scope-control:${key}:${token}`, ownerPid: process.pid, metadata: { role: "control", scope_id: scopeId, generation } });
|
|
112
135
|
worker.process_id = record.id;
|
|
@@ -125,6 +148,19 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
|
|
|
125
148
|
for (;;) {
|
|
126
149
|
if (leaseLost)
|
|
127
150
|
throw new AppError("runtime_scope_owner_changed", "Scope worker lost its managed lease", 1);
|
|
151
|
+
if (observation.exhausted()) {
|
|
152
|
+
worker.status = "failed";
|
|
153
|
+
worker.error = "recovery_observation_budget_exhausted";
|
|
154
|
+
worker.snapshot = {
|
|
155
|
+
...(worker.snapshot ?? {}),
|
|
156
|
+
status: "recovery_blocked",
|
|
157
|
+
settled: false,
|
|
158
|
+
pending_reasons: ["recovery_observation_budget_exhausted"],
|
|
159
|
+
error: { code: "recovery_observation_budget_exhausted", message: "Scope recovery observation exceeded its 120 second budget" },
|
|
160
|
+
recovery_observation: observation.state(),
|
|
161
|
+
};
|
|
162
|
+
return { scope_id: scopeId, generation, status: "recovery_blocked", settled: false, error: worker.error };
|
|
163
|
+
}
|
|
128
164
|
const status = runtimeBudgetStatus(context, scopeId);
|
|
129
165
|
const release = status.release;
|
|
130
166
|
if (release?.current === true && release.generation === generation) {
|
|
@@ -137,9 +173,16 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
|
|
|
137
173
|
break;
|
|
138
174
|
}
|
|
139
175
|
const resume = status.resume;
|
|
140
|
-
worker.
|
|
176
|
+
const outcome = await withRecoveryObservation(observation, async () => (worker.reconcile_id ? null : await prepareRuntimeScopeResume(context, scopeId, generation, resume?.generation === generation ? resume.request_id : undefined))
|
|
177
|
+
?? await controlRuntimeScopeRuns(context, scopeId, worker.request_id, worker.mode, worker.reconcile_id));
|
|
178
|
+
observation.exhausted();
|
|
179
|
+
worker.snapshot = { ...outcome, recovery_observation: observation.state() };
|
|
141
180
|
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
142
|
-
|
|
181
|
+
if (worker.snapshot.drain?.physical_settled === true) {
|
|
182
|
+
worker.status = "completed";
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
await delay(observation.nextDelay());
|
|
143
186
|
}
|
|
144
187
|
return { scope_id: scopeId, generation, status: worker.status };
|
|
145
188
|
}
|
|
@@ -158,6 +201,8 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
|
|
|
158
201
|
finally {
|
|
159
202
|
if (heartbeat)
|
|
160
203
|
clearInterval(heartbeat);
|
|
204
|
+
observation.exhausted();
|
|
205
|
+
worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
|
|
161
206
|
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
162
207
|
if (worker.process_id && worker.process_lease_token)
|
|
163
208
|
finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
|
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(); }
|