@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.
- package/CHANGELOG.md +12 -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 +24 -8
- 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 +32 -12
- package/dist/storage/database.js +9 -0
- package/package.json +13 -12
|
@@ -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,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
|
-
|
|
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 =
|
|
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)
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"version": "0.9.0-beta.
|
|
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
|
-
|
|
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
|
+
}
|