@deksden-com/dd-flow-cli 0.9.0-beta.19 → 0.9.0-beta.21

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.
@@ -13,9 +13,18 @@ import { appendAudit } from "./audit.js";
13
13
  import { flowCommand } from "./stage-pause.js";
14
14
  import { assertPortableArtifactRef } from "./portable-refs.js";
15
15
  import { publicSessionIdentity } from "./session-identity.js";
16
- import { activateRecoveryWork, assertRunMutationAllowed, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
16
+ import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
17
17
  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
18
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
19
+ function ownsPhysicalWorkSession(context, projectId, owner, identity) {
20
+ if (identity.sessionId !== owner.session_id)
21
+ return false;
22
+ const binding = owner.hook_event_id ? context.db.get("SELECT daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, owner.hook_event_id]) : undefined;
23
+ // Legacy non-daemon sessions remain usable. Once a physical daemon was
24
+ // captured, another process cannot inherit authority merely by resuming
25
+ // the same provider Session ID.
26
+ return !binding?.daemon_id || binding.daemon_id === identity.daemonId;
27
+ }
19
28
  /**
20
29
  * A Stage terminal command is allowed only from the Session that owns its
21
30
  * running coordinator Work. A hook receipt proves the caller; a WorkSession
@@ -28,7 +37,7 @@ export function assertStageLifecycleOwner(context, input) {
28
37
  if (!project)
29
38
  throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
30
39
  requireRun(context, project.id, input.runId);
31
- const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id
40
+ const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
32
41
  FROM work_sessions ws
33
42
  JOIN works w ON w.work_id = ws.work_id
34
43
  JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
@@ -42,7 +51,7 @@ export function assertStageLifecycleOwner(context, input) {
42
51
  projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
43
52
  runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
44
53
  });
45
- if (identity.sessionId === owner.session_id)
54
+ if (ownsPhysicalWorkSession(context, project.id, owner, identity))
46
55
  return identity;
47
56
  context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
48
57
  const payload = { run_id: input.runId, stage: input.stage, work_id: owner.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
@@ -63,7 +72,7 @@ export function assertWorkLifecycleOwner(context, input) {
63
72
  if (!owner)
64
73
  throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
65
74
  const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
66
- if (identity.sessionId === owner.session_id)
75
+ if (ownsPhysicalWorkSession(context, work.project_id, owner, identity))
67
76
  return identity;
68
77
  context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
69
78
  const payload = { run_id: work.run_id, work_id: work.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
@@ -208,7 +217,7 @@ export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.rep
208
217
  * PATH is not inherited; using the shared lifecycle command keeps them on the
209
218
  * same captured runtime as their parent stage.
210
219
  */
211
- export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
220
+ 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`; }
212
221
  export function startWork(context, id, input) {
213
222
  ensureWorkRegistry(context);
214
223
  const work = requireWork(context, id);
@@ -223,16 +232,19 @@ export function startWork(context, id, input) {
223
232
  const run = requireRun(context, work.project_id, work.run_id);
224
233
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
225
234
  throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
235
+ const recoveryId = recoveryGuard(context, work.project_id, work.run_id)?.recovery_id;
236
+ if (input.recoveryId !== recoveryId)
237
+ throw new AppError("run_recovery_generation_stale", "Work launch must use the exact current recovery packet", 1, { work_id: id, recovery_id: recoveryId ?? null });
226
238
  let hookEventId = input.hookEventId;
227
239
  if (!hookEventId) {
228
240
  try {
229
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
241
+ hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
230
242
  }
231
243
  catch {
232
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
244
+ hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
233
245
  }
234
246
  }
235
- const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root });
247
+ const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) });
236
248
  const started = startBoundWork(context, work, run, identity, hookEventId);
237
249
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
238
250
  return started;
@@ -245,8 +257,11 @@ export function bindRunningWorkSession(context, input) {
245
257
  throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
246
258
  const run = requireRun(context, work.project_id, work.run_id);
247
259
  const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
260
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
248
261
  const active = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
249
262
  if (active) {
263
+ if (active.session_id === identity.sessionId && !ownsPhysicalWorkSession(context, work.project_id, active, identity))
264
+ throw new AppError("handoff_session_mismatch", "The active Work belongs to another physical daemon", 1, { work_id: work.work_id });
250
265
  if (active.session_id !== identity.sessionId) {
251
266
  // A frozen `new_session` policy may transfer the same coordinator Work
252
267
  // only to its next stage. Repeating the same stage from another Session
@@ -320,7 +335,8 @@ export function startStageCoordinatorWork(context, input) {
320
335
  }
321
336
  /** Stage entry points call this only after their own trusted hook claim. */
322
337
  export function startBoundWork(context, work, run, identity, hookEventId) {
323
- assertRunMutationAllowed(context, work.project_id, work.run_id);
338
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
339
+ const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
324
340
  if (work.status !== "created")
325
341
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
326
342
  const now = context.now();
@@ -345,15 +361,18 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
345
361
  fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
346
362
  context.db.exec("BEGIN IMMEDIATE");
347
363
  try {
364
+ assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
348
365
  bindSession(context, work, run, identity, now);
349
366
  if (work.parent_work_id && work.launch_policy === "reuse_allowed")
350
367
  context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, work.work_id]);
351
- const claimed = context.db.run("UPDATE works SET status = 'running', started_at = ?, updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, work.work_id]);
368
+ const claimed = context.db.run("UPDATE works SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, work.work_id]);
352
369
  if (claimed.changes !== 1)
353
370
  throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
354
371
  context.db.run(`INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)`, [linkId, work.work_id, identity.sessionId, hookEventId, promptPath, resultPath, now, now]);
355
372
  fs.renameSync(promptCandidate, promptPath);
356
373
  fs.renameSync(contextCandidate, contextPath);
374
+ if (recovery)
375
+ activateRecoveryWork(context, work.work_id);
357
376
  context.db.exec("COMMIT");
358
377
  }
359
378
  catch (error) {
@@ -362,8 +381,6 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
362
381
  fs.rmSync(contextCandidate, { force: true });
363
382
  throw error;
364
383
  }
365
- if (recovery)
366
- activateRecoveryWork(context, work.work_id);
367
384
  refreshRunWorkProjection(context, work.project_id, work.run_id);
368
385
  return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
369
386
  }
@@ -423,6 +440,9 @@ async function settle(context, id, status, result, progress) {
423
440
  const now = context.now();
424
441
  context.db.exec("BEGIN IMMEDIATE");
425
442
  try {
443
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
444
+ if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
445
+ throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
426
446
  context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
427
447
  if (link) {
428
448
  if (link.result_path && work.parent_work_id !== null)
@@ -19,7 +19,16 @@ export function ensureRecoveryStorage(db) {
19
19
  recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
20
20
  );
21
21
  CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
22
+ CREATE TABLE IF NOT EXISTS run_recovery_bindings (
23
+ recovery_id TEXT PRIMARY KEY REFERENCES run_recovery_guards(recovery_id),
24
+ work_id TEXT NOT NULL REFERENCES works(work_id), prior_work_session_id TEXT NOT NULL,
25
+ session_id TEXT NOT NULL, harness TEXT NOT NULL, provider_session_id TEXT NOT NULL,
26
+ daemon_id TEXT NOT NULL, status TEXT NOT NULL, hook_event_key TEXT,
27
+ created_at TEXT NOT NULL, accepted_at TEXT
28
+ );
22
29
  `);
30
+ ensureColumn(db, "run_recovery_bindings", "packet_json", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_json TEXT");
31
+ ensureColumn(db, "run_recovery_bindings", "packet_sha256", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_sha256 TEXT");
23
32
  }
24
33
  /** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
25
34
  export function ensureVnextWorkStorage(db) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.19",
3
+ "version": "0.9.0-beta.21",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,16 @@
16
16
  "node": ">=26.0.0",
17
17
  "pnpm": ">=10.0.0"
18
18
  },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "eslint . --max-warnings=0",
23
+ "test": "vitest run",
24
+ "changeset": "changeset",
25
+ "version-packages": "changeset version",
26
+ "release": "pnpm run build && changeset publish",
27
+ "prepublishOnly": "node scripts/verify-release-build.mjs"
28
+ },
19
29
  "devDependencies": {
20
30
  "@changesets/cli": "^2.31.0",
21
31
  "@eslint/js": "^9.39.1",
@@ -34,14 +44,5 @@
34
44
  "publishConfig": {
35
45
  "access": "public"
36
46
  },
37
- "license": "MIT",
38
- "scripts": {
39
- "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
40
- "typecheck": "tsc --noEmit",
41
- "lint": "eslint . --max-warnings=0",
42
- "test": "vitest run",
43
- "changeset": "changeset",
44
- "version-packages": "changeset version",
45
- "release": "pnpm run build && changeset publish"
46
- }
47
- }
47
+ "license": "MIT"
48
+ }