@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.
@@ -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 cancellationAcknowledged = action !== "stop";
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
- cancellationAcknowledged = true;
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
- 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 = ?", [String(error), context.now(), operationId]);
269
- throw error;
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
- return { session_id: id, settled: receipt.settled === true && cancellationAcknowledged, receipt, ...(cancellation ? { cancellation } : {}) };
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 launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest) {
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
- worker = { scope_id: scopeId, generation: control.generation, request_id: requestId, mode, token: crypto.randomUUID(), status: "starting", launcher: birth() };
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.snapshot = await prepareRuntimeScopeResume(context, scopeId, generation, resume?.generation === generation ? resume.request_id : undefined) ?? await controlRuntimeScopeRuns(context, scopeId, worker.request_id, worker.mode);
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
- await delay(1_000);
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
@@ -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.62",
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(); }