@deksden-com/dd-flow-cli 0.9.0-beta.17 → 0.9.0-beta.19
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/README.md +21 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +16 -2
- package/dist/cli/run-cli.js +77 -14
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/services/cli-operation-classifier.js +2 -0
- package/dist/services/eval-snapshots.js +14 -6
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +121 -30
- package/dist/services/lifecycle-command.js +8 -0
- package/dist/services/run-recovery.js +104 -0
- package/dist/services/stage-lifecycle.js +3 -0
- package/dist/services/usage.js +13 -3
- package/dist/services/vnext-fanout.js +40 -0
- package/dist/services/work-registry.js +80 -7
- package/dist/storage/database.js +27 -1
- package/package.json +1 -1
|
@@ -2,20 +2,78 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
5
|
+
import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
6
6
|
import { validateSchema } from "./schema-validation.js";
|
|
7
7
|
import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
|
|
8
8
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
9
9
|
import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
|
|
10
10
|
import { nextWorkId, nextWorkIds } from "./ids.js";
|
|
11
11
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
|
+
import { appendAudit } from "./audit.js";
|
|
12
13
|
import { flowCommand } from "./stage-pause.js";
|
|
13
14
|
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
14
15
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
16
|
+
import { activateRecoveryWork, assertRunMutationAllowed, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
|
|
15
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";
|
|
16
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
|
+
/**
|
|
20
|
+
* A Stage terminal command is allowed only from the Session that owns its
|
|
21
|
+
* running coordinator Work. A hook receipt proves the caller; a WorkSession
|
|
22
|
+
* proves the owner. Either fact alone is insufficient.
|
|
23
|
+
*/
|
|
24
|
+
export function assertStageLifecycleOwner(context, input) {
|
|
25
|
+
ensureWorkRegistry(context);
|
|
26
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
27
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
|
|
28
|
+
if (!project)
|
|
29
|
+
throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
|
|
30
|
+
requireRun(context, project.id, input.runId);
|
|
31
|
+
const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id
|
|
32
|
+
FROM work_sessions ws
|
|
33
|
+
JOIN works w ON w.work_id = ws.work_id
|
|
34
|
+
JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
|
|
35
|
+
WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
|
|
36
|
+
AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
|
|
37
|
+
${input.workId ? "AND w.work_id = ?" : ""}
|
|
38
|
+
ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
|
|
39
|
+
if (!owner)
|
|
40
|
+
throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
|
|
41
|
+
const identity = claimStageLifecycleHookEvent(context, {
|
|
42
|
+
projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
|
|
43
|
+
runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
|
|
44
|
+
});
|
|
45
|
+
if (identity.sessionId === owner.session_id)
|
|
46
|
+
return identity;
|
|
47
|
+
context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
|
|
48
|
+
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 };
|
|
49
|
+
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
|
|
50
|
+
appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
|
|
51
|
+
throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
|
|
52
|
+
}
|
|
53
|
+
/** A Work terminal command belongs to the Session that started that Work.
|
|
54
|
+
* Controllers can still use their explicit non-agent operations; they cannot
|
|
55
|
+
* borrow a native worker's authority by naming its Work id. */
|
|
56
|
+
export function assertWorkLifecycleOwner(context, input) {
|
|
57
|
+
ensureWorkRegistry(context);
|
|
58
|
+
const work = requireWork(context, input.workId);
|
|
59
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
60
|
+
if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
61
|
+
throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
|
|
62
|
+
const owner = 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]);
|
|
63
|
+
if (!owner)
|
|
64
|
+
throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
|
|
65
|
+
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)
|
|
67
|
+
return identity;
|
|
68
|
+
context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
|
|
69
|
+
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 };
|
|
70
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
|
|
71
|
+
appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
|
|
72
|
+
throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
|
|
73
|
+
}
|
|
17
74
|
export function createChildWork(context, input) {
|
|
18
75
|
const parent = requireWork(context, input.parentWorkId);
|
|
76
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
19
77
|
if (parent.status !== "running")
|
|
20
78
|
throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
|
|
21
79
|
const id = nextWorkId(context, parent.project_id, input.slug);
|
|
@@ -46,6 +104,7 @@ export function validateWorkBatchFile(file) {
|
|
|
46
104
|
export function addWorkBatch(context, input) {
|
|
47
105
|
ensureWorkRegistry(context);
|
|
48
106
|
const parent = requireWork(context, input.parentWorkId);
|
|
107
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
49
108
|
if (parent.status !== "running")
|
|
50
109
|
throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
|
|
51
110
|
const parsed = readJson(input.file);
|
|
@@ -118,6 +177,7 @@ export function mutateWorkDeps(context, input) {
|
|
|
118
177
|
const work = requireWork(context, input.workId);
|
|
119
178
|
if (input.action === "list")
|
|
120
179
|
return { work_id: work.work_id, depends_on: parseDependencies(work) };
|
|
180
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
121
181
|
if (work.status !== "created")
|
|
122
182
|
throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
|
|
123
183
|
const dependencies = new Set(parseDependencies(work));
|
|
@@ -139,7 +199,7 @@ export function mutateWorkDeps(context, input) {
|
|
|
139
199
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
140
200
|
return { work_id: work.work_id, depends_on: [...dependencies] };
|
|
141
201
|
}
|
|
142
|
-
export function deleteWork(context, id) { const work = requireWork(context, id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
|
|
202
|
+
export function deleteWork(context, id) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
|
|
143
203
|
throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
|
|
144
204
|
export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
145
205
|
/**
|
|
@@ -152,6 +212,7 @@ export function workStartCommand(context, work) { const run = requireRun(context
|
|
|
152
212
|
export function startWork(context, id, input) {
|
|
153
213
|
ensureWorkRegistry(context);
|
|
154
214
|
const work = requireWork(context, id);
|
|
215
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
155
216
|
if (work.status !== "created")
|
|
156
217
|
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
|
|
157
218
|
if (!isReady(context, work)) {
|
|
@@ -179,6 +240,7 @@ export function startWork(context, id, input) {
|
|
|
179
240
|
/** Binds the already-running coordinator of a stage to its trusted hook Session. */
|
|
180
241
|
export function bindRunningWorkSession(context, input) {
|
|
181
242
|
const work = requireWork(context, input.workId);
|
|
243
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
182
244
|
if (work.status !== "running")
|
|
183
245
|
throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
|
|
184
246
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
@@ -231,6 +293,7 @@ export function bindRunningWorkSession(context, input) {
|
|
|
231
293
|
/** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
|
|
232
294
|
export function bindStageCoordinatorWork(context, input) {
|
|
233
295
|
const work = requireWork(context, input.workId);
|
|
296
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
234
297
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
235
298
|
claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
236
299
|
const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
|
|
@@ -242,6 +305,7 @@ export function bindStageCoordinatorWork(context, input) {
|
|
|
242
305
|
/** Starts a created coordinator Work from its trusted `stage start` event. */
|
|
243
306
|
export function startStageCoordinatorWork(context, input) {
|
|
244
307
|
const work = requireWork(context, input.workId);
|
|
308
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
245
309
|
if (work.status !== "created")
|
|
246
310
|
throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
247
311
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
@@ -256,6 +320,7 @@ export function startStageCoordinatorWork(context, input) {
|
|
|
256
320
|
}
|
|
257
321
|
/** Stage entry points call this only after their own trusted hook claim. */
|
|
258
322
|
export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
323
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
259
324
|
if (work.status !== "created")
|
|
260
325
|
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
261
326
|
const now = context.now();
|
|
@@ -266,10 +331,13 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
266
331
|
const resultPath = path.join(directory, "result.json");
|
|
267
332
|
const contextPath = path.join(directory, "context.json");
|
|
268
333
|
const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
269
|
-
const
|
|
334
|
+
const recovery = recoveryPromptForWork(context, work.work_id);
|
|
335
|
+
let prompt = renderWorkerPrompt(context, work, run, dependencyResults);
|
|
336
|
+
if (recovery)
|
|
337
|
+
prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
|
|
270
338
|
const payload = parsePayload(work);
|
|
271
339
|
const readOnly = payload?.read_only === true;
|
|
272
|
-
const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
|
|
340
|
+
const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
|
|
273
341
|
const token = crypto.randomUUID();
|
|
274
342
|
const promptCandidate = `${promptPath}.${token}.tmp`;
|
|
275
343
|
const contextCandidate = `${contextPath}.${token}.tmp`;
|
|
@@ -294,17 +362,19 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
294
362
|
fs.rmSync(contextCandidate, { force: true });
|
|
295
363
|
throw error;
|
|
296
364
|
}
|
|
365
|
+
if (recovery)
|
|
366
|
+
activateRecoveryWork(context, work.work_id);
|
|
297
367
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
298
|
-
return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
|
|
368
|
+
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 } } };
|
|
299
369
|
}
|
|
300
370
|
export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
|
|
301
371
|
/** Structured fan-in closes a parent after its Session was handed to a child. */
|
|
302
|
-
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
|
|
372
|
+
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
|
|
303
373
|
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
|
|
304
374
|
throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
|
|
305
375
|
export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
|
|
306
376
|
export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
|
|
307
|
-
export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
|
|
377
|
+
export function retryWork(context, id, reason) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); id = work.work_id; if (work.status !== "failed")
|
|
308
378
|
throw new AppError("invalid_work_state", "Only failed Work may be retried", 2); const run = requireRun(context, work.project_id, work.run_id); const directory = path.join(requireRunHome(run), "works", id); const attempts = path.join(directory, "attempts"); const number = fs.existsSync(attempts) ? fs.readdirSync(attempts).filter((entry) => /^ATT-\d{3}$/.test(entry)).length + 1 : 1; const archive = path.join(attempts, `ATT-${String(number).padStart(3, "0")}`); fs.mkdirSync(archive, { recursive: true }); for (const file of ["prompt.md", "result.json", "context.json"]) {
|
|
309
379
|
const source = path.join(directory, file);
|
|
310
380
|
if (fs.existsSync(source))
|
|
@@ -312,6 +382,7 @@ export function retryWork(context, id, reason) { const work = requireWork(contex
|
|
|
312
382
|
} context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
|
|
313
383
|
async function settle(context, id, status, result, progress) {
|
|
314
384
|
const work = requireWork(context, id);
|
|
385
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
315
386
|
id = work.work_id;
|
|
316
387
|
if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
|
|
317
388
|
throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
|
|
@@ -367,6 +438,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
367
438
|
context.db.exec("ROLLBACK");
|
|
368
439
|
throw error;
|
|
369
440
|
}
|
|
441
|
+
settleRecoveryWork(context, work.work_id, status);
|
|
370
442
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
371
443
|
const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
|
|
372
444
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
|
|
@@ -374,6 +446,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
374
446
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
|
|
375
447
|
return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
|
|
376
448
|
}
|
|
449
|
+
function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
|
|
377
450
|
function plannedAreaDrift(packet, result) {
|
|
378
451
|
const changed = JSON.parse(result).changed_paths;
|
|
379
452
|
if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
|
package/dist/storage/database.js
CHANGED
|
@@ -7,6 +7,20 @@ import { AppError } from "../shared/errors.js";
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const { DatabaseSync } = require("node:sqlite");
|
|
9
9
|
const resourceDatabases = new Map();
|
|
10
|
+
/** Recovery guards apply to every RUN kind, including custom eval fixtures. */
|
|
11
|
+
export function ensureRecoveryStorage(db) {
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS run_recovery_guards (
|
|
14
|
+
recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
|
|
17
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
|
|
18
|
+
CREATE TABLE IF NOT EXISTS work_recovery_segments (
|
|
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
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
10
24
|
/** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
|
|
11
25
|
export function ensureVnextWorkStorage(db) {
|
|
12
26
|
const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
|
|
@@ -53,6 +67,16 @@ export function ensureVnextWorkStorage(db) {
|
|
|
53
67
|
);
|
|
54
68
|
CREATE INDEX IF NOT EXISTS idx_work_sessions_work ON work_sessions(work_id, created_at DESC);
|
|
55
69
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open ON work_sessions(work_id) WHERE status = 'running';
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS run_recovery_guards (
|
|
72
|
+
recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
|
|
75
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
|
|
76
|
+
CREATE TABLE IF NOT EXISTS work_recovery_segments (
|
|
77
|
+
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)
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
|
|
56
80
|
CREATE TABLE IF NOT EXISTS check_receipts (
|
|
57
81
|
id TEXT PRIMARY KEY,
|
|
58
82
|
project_id TEXT NOT NULL,
|
|
@@ -95,8 +119,10 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
95
119
|
configureDatabase(db, mode);
|
|
96
120
|
// Internal additive schema changes must be available to every write command.
|
|
97
121
|
// Higher-level Memory Bank migrations remain explicit in services/migrations.
|
|
98
|
-
if (mode !== "read_existing")
|
|
122
|
+
if (mode !== "read_existing") {
|
|
99
123
|
migrate(db, dbPath);
|
|
124
|
+
ensureRecoveryStorage(db);
|
|
125
|
+
}
|
|
100
126
|
if (mode === "read_existing")
|
|
101
127
|
db.exec("PRAGMA query_only = ON");
|
|
102
128
|
db.exec("PRAGMA foreign_keys = ON");
|