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

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,17 +232,27 @@ 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 });
236
- const started = startBoundWork(context, work, run, identity, hookEventId);
247
+ const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) });
248
+ let started;
249
+ try {
250
+ started = startBoundWork(context, work, run, identity, hookEventId);
251
+ }
252
+ catch (error) {
253
+ context.db.run("UPDATE hook_events SET status = 'observed', claimed_at = NULL WHERE project_id = ? AND event_key = ? AND status = 'claimed'", [work.project_id, hookEventId]);
254
+ throw error;
255
+ }
237
256
  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
257
  return started;
239
258
  }
@@ -245,8 +264,11 @@ export function bindRunningWorkSession(context, input) {
245
264
  throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
246
265
  const run = requireRun(context, work.project_id, work.run_id);
247
266
  const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
267
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
248
268
  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
269
  if (active) {
270
+ if (active.session_id === identity.sessionId && !ownsPhysicalWorkSession(context, work.project_id, active, identity))
271
+ throw new AppError("handoff_session_mismatch", "The active Work belongs to another physical daemon", 1, { work_id: work.work_id });
250
272
  if (active.session_id !== identity.sessionId) {
251
273
  // A frozen `new_session` policy may transfer the same coordinator Work
252
274
  // only to its next stage. Repeating the same stage from another Session
@@ -320,7 +342,8 @@ export function startStageCoordinatorWork(context, input) {
320
342
  }
321
343
  /** Stage entry points call this only after their own trusted hook claim. */
322
344
  export function startBoundWork(context, work, run, identity, hookEventId) {
323
- assertRunMutationAllowed(context, work.project_id, work.run_id);
345
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
346
+ const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
324
347
  if (work.status !== "created")
325
348
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
326
349
  const now = context.now();
@@ -345,15 +368,18 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
345
368
  fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
346
369
  context.db.exec("BEGIN IMMEDIATE");
347
370
  try {
371
+ assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
348
372
  bindSession(context, work, run, identity, now);
349
373
  if (work.parent_work_id && work.launch_policy === "reuse_allowed")
350
374
  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]);
375
+ 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
376
  if (claimed.changes !== 1)
353
377
  throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
354
378
  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
379
  fs.renameSync(promptCandidate, promptPath);
356
380
  fs.renameSync(contextCandidate, contextPath);
381
+ if (recovery)
382
+ activateRecoveryWork(context, work.work_id);
357
383
  context.db.exec("COMMIT");
358
384
  }
359
385
  catch (error) {
@@ -362,8 +388,6 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
362
388
  fs.rmSync(contextCandidate, { force: true });
363
389
  throw error;
364
390
  }
365
- if (recovery)
366
- activateRecoveryWork(context, work.work_id);
367
391
  refreshRunWorkProjection(context, work.project_id, work.run_id);
368
392
  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
393
  }
@@ -423,6 +447,9 @@ async function settle(context, id, status, result, progress) {
423
447
  const now = context.now();
424
448
  context.db.exec("BEGIN IMMEDIATE");
425
449
  try {
450
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
451
+ if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
452
+ throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
426
453
  context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
427
454
  if (link) {
428
455
  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,10 +1,10 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.19",
3
+ "version": "0.9.0-beta.22",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dd-flow": "./dist/cli.js"
7
+ "dd-flow": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",