@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.
- package/CHANGELOG.md +20 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/run-cli.js +8 -4
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +4 -6
- package/dist/services/eval-snapshots.js +218 -12
- package/dist/services/hooks.js +67 -28
- package/dist/services/lifecycle-command.js +3 -1
- package/dist/services/merge-server.js +27 -7
- package/dist/services/run-recovery.js +170 -20
- package/dist/services/runs.js +15 -0
- package/dist/services/vnext-merge.js +34 -9
- package/dist/services/work-registry.js +40 -13
- package/dist/storage/database.js +9 -0
- package/package.json +2 -2
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
package/dist/storage/database.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
7
|
+
"dd-flow": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|