@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79
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/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +2 -2
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +84 -41
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +195 -92
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
|
|
8
8
|
import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { AppError } from "../shared/errors.js";
|
|
10
|
+
import { prepareFileBytes, prepareJsonText } from "../cli/input-preparation.js";
|
|
10
11
|
import { parseRuntimeBudget } from "./runtime-budget.js";
|
|
11
12
|
import { requireProjectByRoot } from "./projects.js";
|
|
12
13
|
import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
@@ -23,15 +24,14 @@ import { launchExternalWork } from "./external-work-launch.js";
|
|
|
23
24
|
import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFanout } from "./controller-fanout.js";
|
|
24
25
|
import { claimQueuedMergeRequest } from "./merge-server.js";
|
|
25
26
|
import { prepareVnextCodeWorkspace } from "./vnext-code.js";
|
|
26
|
-
import { requireRun, requireController, controllerReceipt, appendEvent, saveState, digest, digestBytes, errorRecord } from "./run-controller-state.js";
|
|
27
|
+
import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, digest, digestBytes, errorRecord } from "./run-controller-state.js";
|
|
27
28
|
import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady } from "./run-controller-process.js";
|
|
28
29
|
import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
|
|
29
30
|
import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
|
|
30
31
|
import { captureControllerBoundary } from "./run-controller-capture.js";
|
|
31
32
|
import { requestRunControl } from "./run-control-worker.js";
|
|
32
33
|
export { resumeRunController } from "./run-controller-recovery.js";
|
|
33
|
-
|
|
34
|
-
export async function launchRunController(context, input) {
|
|
34
|
+
export function prepareRunControllerLaunch(context, input) {
|
|
35
35
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
|
|
36
36
|
throw new AppError("usage", "Managed launch needs a request id and context SHA-256", 2);
|
|
37
37
|
if (input.stopAfter !== undefined && !/^[a-z][a-z-]*$/.test(input.stopAfter))
|
|
@@ -46,12 +46,12 @@ export async function launchRunController(context, input) {
|
|
|
46
46
|
const budget = parseRuntimeBudget(context.env.DD_FLOW_RUNTIME_BUDGET);
|
|
47
47
|
const request = { project_root: project.root, run_id: run.id, context_sha256: input.contextSha256, stop_after: input.stopAfter ?? null, ...(captureRoot ? { capture_root: captureRoot } : {}), ...(budget ? { budget_sha256: digest(budget) } : {}) };
|
|
48
48
|
const requestHash = digest(request);
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const existing = context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")
|
|
50
|
+
? context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]) : undefined;
|
|
51
51
|
if (existing) {
|
|
52
52
|
if (existing.request_hash !== requestHash)
|
|
53
53
|
throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
|
|
54
|
-
return {
|
|
54
|
+
return { kind: "replay", existing };
|
|
55
55
|
}
|
|
56
56
|
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
57
57
|
if (run.status !== "running")
|
|
@@ -71,13 +71,21 @@ export async function launchRunController(context, input) {
|
|
|
71
71
|
const harness = runtimeHarness(profile);
|
|
72
72
|
adapters[harness] = { executable: resolveHarnessCommand(context.ddFlowHome, harness, "adapter"), runtime_args: harnessRuntimeArguments(context.ddFlowHome, harness) };
|
|
73
73
|
}
|
|
74
|
+
const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
75
|
+
if (!fs.existsSync(entry))
|
|
76
|
+
throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
|
|
77
|
+
return { kind: "create", project, run, request, requestHash, generation, budget, execution, contexts, adapters };
|
|
78
|
+
}
|
|
79
|
+
export async function launchRunController(context, input, prepared = prepareRunControllerLaunch(context, input)) {
|
|
80
|
+
if (prepared.kind === "replay")
|
|
81
|
+
return { ...await spawnController(context, prepared.existing), reused: true };
|
|
82
|
+
const { project, run, request, requestHash, generation, budget, execution, contexts, adapters } = prepared;
|
|
83
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
84
|
+
ensureRunControllerStorage(context.db);
|
|
74
85
|
const controllerId = `DRV-${crypto.randomUUID()}`;
|
|
75
86
|
const root = path.join(run.run_root, "controllers", controllerId);
|
|
76
87
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
77
88
|
const flowExecutable = prepareHarnessFlowExecutable(context, root);
|
|
78
|
-
const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
79
|
-
if (!fs.existsSync(entry))
|
|
80
|
-
throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
|
|
81
89
|
const frozenContexts = {};
|
|
82
90
|
for (const [stage, value] of Object.entries(contexts)) {
|
|
83
91
|
const file = path.join(root, `${stage}.context.json`);
|
|
@@ -98,6 +106,8 @@ export async function launchRunController(context, input) {
|
|
|
98
106
|
return { ...controllerReceipt(duplicate), reused: true };
|
|
99
107
|
}
|
|
100
108
|
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
109
|
+
if (requireRun(context, project.id, run.id).status !== "running")
|
|
110
|
+
throw new AppError("invalid_run_state", "Managed launch requires a running logical RUN", 1);
|
|
101
111
|
const owner = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, run.id]);
|
|
102
112
|
if (owner)
|
|
103
113
|
throw new AppError("controller_already_owned", "RUN already has an unresolved managed owner; observe or reconcile it", 1, { controller_id: owner.controller_id });
|
|
@@ -287,42 +297,66 @@ export async function serveRunController(context, input) {
|
|
|
287
297
|
}
|
|
288
298
|
}
|
|
289
299
|
}
|
|
290
|
-
|
|
291
|
-
export function answerRunController(context, input) {
|
|
300
|
+
export function prepareRunControllerAnswer(context, input) {
|
|
292
301
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !input.pauseId.trim())
|
|
293
302
|
throw new AppError("usage", "Answer requires request-id and pause-id", 2);
|
|
294
303
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
304
|
+
const prior = context.db.get(`SELECT o.operation_id, o.status, o.input_json
|
|
305
|
+
FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id
|
|
306
|
+
WHERE c.project_id = ? AND c.run_id = ? AND o.operation_id = c.controller_id || ':answer:' || ?`, [project.id, input.runId, input.requestId]);
|
|
307
|
+
if (prior) {
|
|
308
|
+
const retained = JSON.parse(prior.input_json);
|
|
309
|
+
if (retained.pause_id !== input.pauseId)
|
|
310
|
+
throw new AppError("runtime_request_conflict", "HITL answer request id belongs to another pause", 1, { operation_id: prior.operation_id });
|
|
311
|
+
return { kind: "replay", operationId: prior.operation_id, status: prior.status };
|
|
312
|
+
}
|
|
313
|
+
const { bytes } = prepareFileBytes(input.answerFile, "HITL answer");
|
|
314
|
+
if (!bytes.toString("utf8").trim())
|
|
315
|
+
throw new AppError("validation", "HITL answer must not be empty", 2);
|
|
295
316
|
const row = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, input.runId]);
|
|
296
317
|
if (!row)
|
|
297
318
|
throw new AppError("not_found", "RUN has no managed controller for this answer", 1);
|
|
298
|
-
const bytes = fs.readFileSync(path.resolve(input.answerFile));
|
|
299
|
-
if (!bytes.toString("utf8").trim())
|
|
300
|
-
throw new AppError("validation", "HITL answer must not be empty", 2);
|
|
301
319
|
const operationId = `${row.controller_id}:answer:${input.requestId}`;
|
|
302
320
|
const answerHash = digestBytes(bytes);
|
|
303
321
|
const hash = digest({ pause_id: input.pauseId, answer_sha256: answerHash });
|
|
322
|
+
prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
|
|
323
|
+
return { kind: "create", project, row, bytes, operationId, answerHash, hash };
|
|
324
|
+
}
|
|
325
|
+
function prepareControllerAnswerAcceptance(context, input, projectId, row, operationId, hash) {
|
|
326
|
+
const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
|
|
327
|
+
if (prior) {
|
|
328
|
+
if (prior.input_hash !== hash)
|
|
329
|
+
throw new AppError("runtime_request_conflict", "HITL answer request id has different raw input", 1);
|
|
330
|
+
return { prior };
|
|
331
|
+
}
|
|
332
|
+
const currentOwner = requireController(context, row.controller_id);
|
|
333
|
+
if (["completed", "cancelled", "superseded"].includes(currentOwner.status))
|
|
334
|
+
throw new AppError("invalid_run_state", "The answer's controller is no longer current", 1);
|
|
335
|
+
const competing = context.db.get("SELECT operation_id FROM run_controller_operations WHERE controller_id = ? AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? LIMIT 1", [row.controller_id, input.pauseId]);
|
|
336
|
+
if (competing)
|
|
337
|
+
throw new AppError("runtime_request_conflict", "This HITL pause already has an accepted answer", 1, { operation_id: competing.operation_id });
|
|
338
|
+
const run = requireRun(context, projectId, input.runId);
|
|
339
|
+
if (["done", "failed", "cancelled"].includes(run.status))
|
|
340
|
+
throw new AppError("invalid_run_state", "A terminal RUN cannot accept a new answer", 1);
|
|
341
|
+
const index = JSON.parse(run.index_json);
|
|
342
|
+
const stage = index.stage_runs.find(stage => stage.pause?.id === input.pauseId && ["paused", "waiting_for_user"].includes(stage.status));
|
|
343
|
+
if (!stage?.pause?.work_id)
|
|
344
|
+
throw new AppError("hitl_pause_mismatch", "Answer does not match the RUN's current HITL pause", 1);
|
|
345
|
+
return { run, stage, workId: stage.pause.work_id };
|
|
346
|
+
}
|
|
347
|
+
export function answerRunController(context, input, prepared = prepareRunControllerAnswer(context, input)) {
|
|
348
|
+
if (prepared.kind === "replay")
|
|
349
|
+
return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
|
|
350
|
+
const { project, row, bytes, operationId, answerHash, hash } = prepared;
|
|
304
351
|
context.db.beginWriteTransaction();
|
|
305
352
|
try {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
throw new AppError("runtime_request_conflict", "HITL answer request id has different raw input", 1);
|
|
353
|
+
// Recheck only mutable acceptance state under the write transaction; bytes are already prepared.
|
|
354
|
+
const acceptance = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
|
|
355
|
+
if (acceptance.prior) {
|
|
310
356
|
context.db.exec("COMMIT");
|
|
311
|
-
return { ok: true, operation_id: operationId, status: prior.status, reused: true };
|
|
357
|
+
return { ok: true, operation_id: operationId, status: acceptance.prior.status, reused: true };
|
|
312
358
|
}
|
|
313
|
-
const
|
|
314
|
-
if (["completed", "cancelled", "superseded"].includes(currentOwner.status))
|
|
315
|
-
throw new AppError("invalid_run_state", "The answer's controller is no longer current", 1);
|
|
316
|
-
const competing = context.db.get("SELECT operation_id FROM run_controller_operations WHERE controller_id = ? AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? LIMIT 1", [row.controller_id, input.pauseId]);
|
|
317
|
-
if (competing)
|
|
318
|
-
throw new AppError("runtime_request_conflict", "This HITL pause already has an accepted answer", 1, { operation_id: competing.operation_id });
|
|
319
|
-
const run = requireRun(context, project.id, input.runId);
|
|
320
|
-
if (["done", "failed", "cancelled"].includes(run.status))
|
|
321
|
-
throw new AppError("invalid_run_state", "A terminal RUN cannot accept a new answer", 1);
|
|
322
|
-
const index = JSON.parse(run.index_json);
|
|
323
|
-
const stage = index.stage_runs.find(stage => stage.pause?.id === input.pauseId && ["paused", "waiting_for_user"].includes(stage.status));
|
|
324
|
-
if (!stage?.pause?.work_id)
|
|
325
|
-
throw new AppError("hitl_pause_mismatch", "Answer does not match the RUN's current HITL pause", 1);
|
|
359
|
+
const { run, stage, workId } = acceptance;
|
|
326
360
|
const file = path.join(row.state_dir, `answer-${digest(operationId)}.md`);
|
|
327
361
|
try {
|
|
328
362
|
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
@@ -336,7 +370,7 @@ export function answerRunController(context, input) {
|
|
|
336
370
|
}
|
|
337
371
|
const now = context.now();
|
|
338
372
|
const generation = recoveryGuard(context, project.id, run.id)?.generation ?? row.generation;
|
|
339
|
-
context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, generation, hash, JSON.stringify({ action: "user_answer", pause_id: input.pauseId, stage: stage.stage, work_id:
|
|
373
|
+
context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, generation, hash, JSON.stringify({ action: "user_answer", pause_id: input.pauseId, stage: stage.stage, work_id: workId, file, sha256: answerHash }), now, now]);
|
|
340
374
|
appendEvent(context, row.controller_id, "answer_accepted", { operation_id: operationId, pause_id: input.pauseId, answer_sha256: answerHash });
|
|
341
375
|
context.db.exec("COMMIT");
|
|
342
376
|
return { ok: true, operation_id: operationId, status: "accepted" };
|
|
@@ -346,13 +380,29 @@ export function answerRunController(context, input) {
|
|
|
346
380
|
throw error;
|
|
347
381
|
}
|
|
348
382
|
}
|
|
349
|
-
|
|
350
|
-
export function supplyRunControllerContext(context, input) {
|
|
383
|
+
export function prepareRunControllerContext(context, input) {
|
|
351
384
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-z][a-z-]*$/.test(input.stage) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
|
|
352
385
|
throw new AppError("usage", "Context submission requires request id, stage and SHA-256", 2);
|
|
353
386
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
354
387
|
const operationId = `${input.controllerId}:context:${input.requestId}`;
|
|
355
388
|
const hash = digest({ stage: input.stage, sha256: input.contextSha256 });
|
|
389
|
+
const row = requireController(context, input.controllerId);
|
|
390
|
+
if (row.project_id !== project.id || row.run_id !== input.runId)
|
|
391
|
+
throw new AppError("controller_owner_mismatch", "Context submission belongs to another RUN", 1);
|
|
392
|
+
const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
|
|
393
|
+
if (prior) {
|
|
394
|
+
if (prior.input_hash !== hash)
|
|
395
|
+
throw new AppError("runtime_request_conflict", "Context request id has different inputs", 1);
|
|
396
|
+
return { kind: "replay", operationId, status: prior.status };
|
|
397
|
+
}
|
|
398
|
+
availableControllerContext(context, input, row);
|
|
399
|
+
const { bytes } = loadExternalStageContext({ file: input.contextFile, expectedSha256: input.contextSha256, stage: input.stage });
|
|
400
|
+
return { kind: "create", project, operationId, hash, bytes };
|
|
401
|
+
}
|
|
402
|
+
export function supplyRunControllerContext(context, input, prepared = prepareRunControllerContext(context, input)) {
|
|
403
|
+
if (prepared.kind === "replay")
|
|
404
|
+
return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
|
|
405
|
+
const { project, operationId, hash, bytes } = prepared;
|
|
356
406
|
context.db.beginWriteTransaction();
|
|
357
407
|
try {
|
|
358
408
|
const row = requireController(context, input.controllerId);
|
|
@@ -365,16 +415,7 @@ export function supplyRunControllerContext(context, input) {
|
|
|
365
415
|
context.db.exec("COMMIT");
|
|
366
416
|
return { ok: true, operation_id: operationId, status: prior.status, reused: true };
|
|
367
417
|
}
|
|
368
|
-
|
|
369
|
-
if (!["starting", "running", "waiting_for_context", "waiting_for_user", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(row.status))
|
|
370
|
-
throw new AppError("invalid_run_state", "Controller cannot accept a new context in its current state", 1);
|
|
371
|
-
const manifest = JSON.parse(row.manifest_json);
|
|
372
|
-
if (manifest.contexts[input.stage])
|
|
373
|
-
throw new AppError("stage_context_replacement", "Controller already has an immutable context for this stage", 1);
|
|
374
|
-
loadExternalStageContext({ file: input.contextFile, expectedSha256: input.contextSha256, stage: input.stage });
|
|
375
|
-
const bytes = fs.readFileSync(input.contextFile);
|
|
376
|
-
if (digestBytes(bytes) !== input.contextSha256)
|
|
377
|
-
throw new AppError("context_checksum_mismatch", "Context changed before publication", 2);
|
|
418
|
+
const manifest = availableControllerContext(context, input, row);
|
|
378
419
|
const file = path.join(row.state_dir, `${input.stage}.context.json`);
|
|
379
420
|
try {
|
|
380
421
|
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
@@ -398,6 +439,15 @@ export function supplyRunControllerContext(context, input) {
|
|
|
398
439
|
throw error;
|
|
399
440
|
}
|
|
400
441
|
}
|
|
442
|
+
function availableControllerContext(context, input, row) {
|
|
443
|
+
assertRunMutationAllowed(context, row.project_id, input.runId, row.generation);
|
|
444
|
+
if (!["starting", "running", "waiting_for_context", "waiting_for_user", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(row.status))
|
|
445
|
+
throw new AppError("invalid_run_state", "Controller cannot accept a new context in its current state", 1);
|
|
446
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
447
|
+
if (manifest.contexts[input.stage])
|
|
448
|
+
throw new AppError("stage_context_replacement", "Controller already has an immutable context for this stage", 1);
|
|
449
|
+
return manifest;
|
|
450
|
+
}
|
|
401
451
|
async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner) {
|
|
402
452
|
assertOwnership();
|
|
403
453
|
const observer = { ...context, db: getDatabase(context.ddFlowHome, "read_existing") };
|
|
@@ -580,6 +630,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
580
630
|
continue;
|
|
581
631
|
}
|
|
582
632
|
loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
|
|
633
|
+
const continuation = JSON.stringify(["start_stage", next.stage, next.attempt ?? null]);
|
|
634
|
+
retainControllerContinuation(state, continuation, { code: "stage_entry_nonprogressing", message: "The coordinator returned without starting the assigned Stage", details: { stage: next.stage, attempt: next.attempt ?? null, last_receipt: state.last_receipt ?? null } });
|
|
583
635
|
const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
|
|
584
636
|
if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
|
|
585
637
|
const request = context.db.get("SELECT merge_request_id, status FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC, merge_request_id DESC LIMIT 1", [row.project_id, row.run_id]);
|
|
@@ -629,7 +681,6 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
629
681
|
}
|
|
630
682
|
state.native_baseline = controllerNativeChildren(state.last_receipt ?? {}, session.id).map(child => child.session_id);
|
|
631
683
|
state.stage = next.stage;
|
|
632
|
-
state.last_continuation = null;
|
|
633
684
|
saveState(context, row, state);
|
|
634
685
|
setStatus("running");
|
|
635
686
|
if (next.stage === "code" || next.stage === "code-review") {
|
|
@@ -692,9 +743,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
692
743
|
saveState(context, row, state);
|
|
693
744
|
continue;
|
|
694
745
|
}
|
|
695
|
-
|
|
696
|
-
throw new AppError("fanout_stage_nonprogressing", "The coordinator repeated an unchanged Work-graph continuation", 1, { stage: next.stage });
|
|
697
|
-
state.last_continuation = fanout.fingerprint;
|
|
746
|
+
retainControllerContinuation(state, fanout.fingerprint, { code: "fanout_stage_nonprogressing", message: "The coordinator repeated an unchanged Work-graph continuation", details: { stage: next.stage } });
|
|
698
747
|
saveState(context, row, state);
|
|
699
748
|
await prompt(session, fanout.prompt);
|
|
700
749
|
}
|
|
@@ -713,20 +762,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
713
762
|
}
|
|
714
763
|
}
|
|
715
764
|
function readContexts(file, expected) {
|
|
716
|
-
const bytes =
|
|
765
|
+
const { bytes } = prepareFileBytes(file, "Managed context package");
|
|
717
766
|
if (digestBytes(bytes) !== expected)
|
|
718
767
|
throw new AppError("context_checksum_mismatch", "Managed context package SHA-256 differs", 2);
|
|
719
|
-
const value =
|
|
720
|
-
if (value.schema_id !== "dd-flow/managed-context@1" || !value.stages || typeof value.stages !== "object" || Array.isArray(value.stages) || Object.keys(value).some(key => !["schema_id", "stages"].includes(key)))
|
|
768
|
+
const value = prepareJsonText(bytes.toString("utf8"), { file, label: "Managed context package" }).value;
|
|
769
|
+
if (!value || value.schema_id !== "dd-flow/managed-context@1" || !value.stages || typeof value.stages !== "object" || Array.isArray(value.stages) || Object.keys(value).some(key => !["schema_id", "stages"].includes(key)))
|
|
721
770
|
throw new AppError("validation", "Expected dd-flow/managed-context@1 with stage file/hash references", 2);
|
|
722
771
|
const result = {};
|
|
723
772
|
for (const [stage, source] of Object.entries(value.stages)) {
|
|
724
773
|
if (!/^[a-z][a-z-]*$/.test(stage) || !source || typeof source !== "object" || Object.keys(source).some(key => !["file", "sha256"].includes(key)) || typeof source.file !== "string" || !path.isAbsolute(source.file) || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256))
|
|
725
774
|
throw new AppError("validation", "Invalid managed stage context reference", 2);
|
|
726
|
-
loadExternalStageContext({ file: source.file, expectedSha256: source.sha256, stage });
|
|
727
|
-
const content = fs.readFileSync(source.file);
|
|
728
|
-
if (digestBytes(content) !== source.sha256)
|
|
729
|
-
throw new AppError("context_checksum_mismatch", "Stage context changed while freezing the launch", 2);
|
|
775
|
+
const { bytes: content } = loadExternalStageContext({ file: source.file, expectedSha256: source.sha256, stage });
|
|
730
776
|
result[stage] = { bytes: content, sha256: source.sha256 };
|
|
731
777
|
}
|
|
732
778
|
return result;
|
|
@@ -4,19 +4,28 @@ import path from "node:path";
|
|
|
4
4
|
import { createContext } from "../runtime/context.js";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { canonicalPath } from "../storage/paths.js";
|
|
7
|
-
import { installCurrentEngine, upgradeForkedRunEngine } from "./engines.js";
|
|
8
|
-
import { restoreEvalRunSnapshot } from "./eval-snapshots.js";
|
|
9
|
-
|
|
10
|
-
* copied engine binding. The original snapshot and RUN are read-only. */
|
|
11
|
-
export function forkRunFromSnapshot(context, input) {
|
|
7
|
+
import { installCurrentEngine, upgradeForkedRunEngine, prepareForkEngine } from "./engines.js";
|
|
8
|
+
import { restoreEvalRunSnapshot, prepareEvalRunSnapshot, prepareEvalRunRestore } from "./eval-snapshots.js";
|
|
9
|
+
export function prepareRunFork(input) {
|
|
12
10
|
const requestId = requireId(input.requestId, "request-id");
|
|
13
|
-
const snapshot = canonicalPath(input.snapshot);
|
|
14
|
-
const output =
|
|
11
|
+
const snapshot = canonicalPath(input.snapshot, false);
|
|
12
|
+
const output = canonicalPath(input.output, false);
|
|
15
13
|
if (snapshot === output || snapshot.startsWith(`${output}${path.sep}`) || output.startsWith(`${snapshot}${path.sep}`)) {
|
|
16
14
|
throw new AppError("fork_path_conflict", "Fork output must not contain or be contained by its source snapshot", 2, { snapshot, output });
|
|
17
15
|
}
|
|
18
16
|
const manifestFile = path.join(snapshot, "snapshot.json");
|
|
19
|
-
const
|
|
17
|
+
const intentFile = path.join(output, ".fork-intent.json");
|
|
18
|
+
const receiptFile = path.join(output, "fork.json");
|
|
19
|
+
const engineVersion = requireText(input.engineVersion, "engine-version");
|
|
20
|
+
if (fs.existsSync(receiptFile)) {
|
|
21
|
+
const receipt = readJson(receiptFile);
|
|
22
|
+
const prior = receipt?.intent;
|
|
23
|
+
if (!prior || prior.request_id !== requestId || prior.snapshot !== snapshot || prior.engine_version !== engineVersion || prior.integrity_checksum !== (input.integrityChecksum ?? null))
|
|
24
|
+
throw new AppError("fork_request_conflict", "Fork output already belongs to another request", 1, { output });
|
|
25
|
+
return { receipt, output };
|
|
26
|
+
}
|
|
27
|
+
prepareForkEngine(engineVersion, input.integrityChecksum);
|
|
28
|
+
const manifest = prepareEvalRunSnapshot(snapshot);
|
|
20
29
|
if (manifest.schema_id !== "dd-flow/eval-run-snapshot@5" || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
|
|
21
30
|
throw new AppError("fork_checkpoint_invalid", "Fork requires a sealed stage-entry snapshot", 2, { snapshot });
|
|
22
31
|
}
|
|
@@ -25,34 +34,39 @@ export function forkRunFromSnapshot(context, input) {
|
|
|
25
34
|
request_id: requestId,
|
|
26
35
|
snapshot,
|
|
27
36
|
snapshot_sha256: fileHash(manifestFile),
|
|
28
|
-
engine_version:
|
|
37
|
+
engine_version: engineVersion,
|
|
29
38
|
integrity_checksum: input.integrityChecksum ?? null,
|
|
30
39
|
target_stage: manifest.stage_entry
|
|
31
40
|
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return receipt;
|
|
39
|
-
}
|
|
41
|
+
assertForkOutput(output, intentFile, intent);
|
|
42
|
+
return { manifest, snapshot, output, intent, intentFile, receiptFile, requestId };
|
|
43
|
+
}
|
|
44
|
+
function assertForkOutput(output, intentFile, intent) {
|
|
45
|
+
if (fs.existsSync(output) && !fs.statSync(output).isDirectory())
|
|
46
|
+
throw new AppError("fork_output_not_empty", "Fork output must be a directory", 2, { output });
|
|
40
47
|
if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
|
|
41
48
|
if (!fs.existsSync(intentFile) || hash(readJson(intentFile).intent) !== hash(intent))
|
|
42
49
|
throw new AppError("fork_output_not_empty", "Fork output must be empty", 2, { output });
|
|
43
50
|
const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home"].includes(name));
|
|
44
51
|
if (unknown.length)
|
|
45
52
|
throw new AppError("fork_retry_inspect_required", "Fork output contains data not owned by the interrupted fork", 1, { output, unknown });
|
|
46
|
-
for (const name of ["project", "dd-flow-home"])
|
|
47
|
-
fs.rmSync(path.join(output, name), { recursive: true, force: true });
|
|
48
53
|
}
|
|
54
|
+
}
|
|
55
|
+
export function forkRunFromSnapshot(context, input, prepared = prepareRunFork(input)) {
|
|
56
|
+
if ("receipt" in prepared)
|
|
57
|
+
return prepared.receipt;
|
|
58
|
+
const { manifest, snapshot, output, intent, intentFile, receiptFile, requestId } = prepared;
|
|
59
|
+
assertForkOutput(output, intentFile, intent);
|
|
60
|
+
for (const name of ["project", "dd-flow-home"])
|
|
61
|
+
fs.rmSync(path.join(output, name), { recursive: true, force: true });
|
|
49
62
|
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
50
63
|
writeJsonAtomic(intentFile, { schema_id: "dd-flow/run-fork-intent@1", intent });
|
|
51
64
|
const projectRoot = path.join(output, "project");
|
|
52
65
|
const home = path.join(output, "dd-flow-home");
|
|
53
66
|
fs.mkdirSync(projectRoot, { recursive: true, mode: 0o700 });
|
|
54
67
|
const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
55
|
-
const
|
|
68
|
+
const restoreInput = { snapshot, projectRoot };
|
|
69
|
+
const restored = restoreEvalRunSnapshot(targetContext, restoreInput, prepareEvalRunRestore(targetContext, restoreInput, manifest));
|
|
56
70
|
const restoredContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
57
71
|
try {
|
|
58
72
|
const runHome = String(restored.run_home);
|
|
@@ -69,8 +83,8 @@ export function forkRunFromSnapshot(context, input) {
|
|
|
69
83
|
};
|
|
70
84
|
writeJsonAtomic(path.join(runHome, "fork-lineage.json"), lineage);
|
|
71
85
|
// Install the immutable artifact belonging to the currently executing
|
|
72
|
-
// CLI into the new home.
|
|
73
|
-
//
|
|
86
|
+
// CLI into the new home. Its exact version/checksum were checked before
|
|
87
|
+
// any interrupted-copy cleanup; snapshots exclude installed engines.
|
|
74
88
|
installCurrentEngine(restoredContext, { forRouting: true });
|
|
75
89
|
const binding = upgradeForkedRunEngine(restoredContext, {
|
|
76
90
|
projectRoot,
|
|
@@ -51,12 +51,21 @@ export function requireRecoveryRun(context, projectId, runId) {
|
|
|
51
51
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId });
|
|
52
52
|
return run;
|
|
53
53
|
}
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
export function prepareRecoveryPayload(value, label) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(value);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new AppError("invalid_json", `${label} must be valid JSON`, 2);
|
|
61
|
+
}
|
|
62
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
63
|
+
throw new AppError("validation", `${label} must be a JSON object`, 2);
|
|
64
|
+
const payload = parsed;
|
|
65
|
+
if (label === "settlement" && payload.settled !== true)
|
|
66
|
+
throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1);
|
|
67
|
+
return payload;
|
|
58
68
|
}
|
|
59
|
-
catch { /* validated below */ } throw new AppError("validation", `${label} must be a JSON object`, 2); }
|
|
60
69
|
export function recoveryGuard(context, projectId, runId) { return context.db.get("SELECT recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at FROM run_recovery_guards WHERE project_id = ? AND run_id = ? ORDER BY generation DESC LIMIT 1", [projectId, runId]) ?? null; }
|
|
61
70
|
export function assertRunMutationAllowed(context, projectId, runId, expectedGeneration) {
|
|
62
71
|
const guard = recoveryGuard(context, projectId, runId);
|
|
@@ -103,10 +112,9 @@ export function assertRecoveryHookCurrent(context, projectId, runId, identity) {
|
|
|
103
112
|
if (!identity.daemonId || retired)
|
|
104
113
|
throw new AppError("recovery_daemon_stale", "Lifecycle receipt belongs to an unidentified or retired recovery daemon", 1, { run_id: runId, generation, daemon_id: identity.daemonId });
|
|
105
114
|
}
|
|
106
|
-
export function beginRunRecovery(context, input) {
|
|
115
|
+
export function beginRunRecovery(context, input, interruption = prepareRecoveryPayload(input.interruption, "interruption")) {
|
|
107
116
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
108
117
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
109
|
-
const interruption = parseObject(input.interruption, "interruption");
|
|
110
118
|
const recoveryId = `RCV-${crypto.randomUUID()}`, now = context.now();
|
|
111
119
|
let generation;
|
|
112
120
|
context.db.beginWriteTransaction();
|
|
@@ -133,15 +141,12 @@ export function beginRunRecovery(context, input) {
|
|
|
133
141
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_draining", recovery_id: recoveryId, generation, interruption });
|
|
134
142
|
return { ok: true, recovery: { recovery_id: recoveryId, run_id: run.id, generation, status: "draining", interruption } };
|
|
135
143
|
}
|
|
136
|
-
export function sealRunRecovery(context, input) {
|
|
144
|
+
export function sealRunRecovery(context, input, settlement = prepareRecoveryPayload(input.settlement, "settlement")) {
|
|
137
145
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
138
146
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
139
147
|
const guard = recoveryGuard(context, project.id, run.id);
|
|
140
148
|
if (!guard || guard.recovery_id !== input.recoveryId)
|
|
141
149
|
throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
142
|
-
const settlement = parseObject(input.settlement, "settlement");
|
|
143
|
-
if (settlement.settled !== true)
|
|
144
|
-
throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1, { recovery_id: guard.recovery_id });
|
|
145
150
|
if (guard.status === "sealed")
|
|
146
151
|
return { ok: true, recovery: { ...guard, settlement: JSON.parse(guard.settlement_json ?? "{}") }, reused: true };
|
|
147
152
|
if (guard.status !== "draining")
|
|
@@ -163,7 +168,7 @@ export function sealRunRecovery(context, input) {
|
|
|
163
168
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
|
|
164
169
|
return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
|
|
165
170
|
}
|
|
166
|
-
export function
|
|
171
|
+
export function prepareRecoveryCapture(context, input) {
|
|
167
172
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
168
173
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
169
174
|
const guard = recoveryGuard(context, project.id, run.id);
|
|
@@ -175,28 +180,33 @@ export function recordRecoveryCapture(context, input) {
|
|
|
175
180
|
if (!fs.existsSync(capture))
|
|
176
181
|
throw new AppError("recovery_capture_missing", "Recovery capture path is unavailable", 1, { capture });
|
|
177
182
|
verifyRecoverySnapshot(capture, { runId: run.id, recoveryId: guard.recovery_id, projectRoot: run.project_root, workspaceRoot: run.workspace_root });
|
|
183
|
+
return { project, run, guard, capture };
|
|
184
|
+
}
|
|
185
|
+
export function recordRecoveryCapture(context, input, prepared = prepareRecoveryCapture(context, input)) {
|
|
186
|
+
const { project, run, guard, capture } = prepared;
|
|
178
187
|
const updated = context.db.run("UPDATE run_recovery_guards SET capture_path = ?, updated_at = ? WHERE recovery_id = ? AND status = 'sealed' AND (capture_path IS NULL OR capture_path = ?) AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [capture, context.now(), guard.recovery_id, capture, project.id, run.id]);
|
|
179
188
|
if (updated.changes !== 1)
|
|
180
189
|
throw new AppError("recovery_state_changed", "Recovery state changed before capture could be bound", 1, { recovery_id: guard.recovery_id });
|
|
181
190
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_capture_ready", recovery_id: guard.recovery_id, capture_path: capture });
|
|
182
191
|
return { ok: true, recovery_id: guard.recovery_id, capture_path: capture };
|
|
183
192
|
}
|
|
184
|
-
export function
|
|
193
|
+
export function prepareRunRecoveryResume(context, input) {
|
|
185
194
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
186
195
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
187
196
|
const guard = recoveryGuard(context, project.id, run.id);
|
|
188
197
|
if (!guard || guard.recovery_id !== input.recoveryId)
|
|
189
198
|
throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
190
|
-
const acceptCommand = acceptanceCommand(context, run.id, guard.recovery_id, run.project_root);
|
|
191
199
|
if (["resuming", "recovered"].includes(guard.status)) {
|
|
192
200
|
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [guard.recovery_id]);
|
|
193
201
|
if (!binding || binding.daemon_id !== input.daemonId || binding.harness !== input.harness || binding.provider_session_id !== input.nativeSessionId)
|
|
194
202
|
throw new AppError("recovery_binding_mismatch", "Recovery is already pinned to another physical Session", 1);
|
|
195
203
|
recoveryPacketReceipt(binding);
|
|
196
|
-
return {
|
|
204
|
+
return { project, run, guard, binding, reused: true };
|
|
197
205
|
}
|
|
198
206
|
if (guard.status !== "sealed" || !guard.capture_path)
|
|
199
207
|
throw new AppError("recovery_capture_required", "Recovery must be sealed and captured before it can resume", 1, { recovery_id: guard.recovery_id, status: guard.status });
|
|
208
|
+
if (!input.daemonId?.trim() || !input.harness?.trim() || !input.nativeSessionId?.trim())
|
|
209
|
+
throw new AppError("recovery_identity_required", "Recovery requires the replacement daemon and retained native Session identity", 1);
|
|
200
210
|
// The coordinator keeps its same native Session and receives a recovery
|
|
201
211
|
// prompt from the eval runner. At a stage boundary its last WorkSession is
|
|
202
212
|
// completed; that retained identity still authorizes acknowledgement, but
|
|
@@ -204,6 +214,13 @@ export function resumeRunRecovery(context, input) {
|
|
|
204
214
|
// Only delegated Work is returned to the ready graph and therefore gets a
|
|
205
215
|
// fresh, addressable recovery packet.
|
|
206
216
|
verifyRecoverySnapshot(guard.capture_path, { runId: run.id, recoveryId: guard.recovery_id, projectRoot: run.project_root, workspaceRoot: run.workspace_root });
|
|
217
|
+
return { project, run, guard, reused: false };
|
|
218
|
+
}
|
|
219
|
+
export function resumeRunRecovery(context, input, prepared = prepareRunRecoveryResume(context, input)) {
|
|
220
|
+
const { project, run, guard } = prepared;
|
|
221
|
+
const acceptCommand = acceptanceCommand(context, run.id, guard.recovery_id, run.project_root);
|
|
222
|
+
if (prepared.reused)
|
|
223
|
+
return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: guard.status === "recovered", awaiting_acceptance: guard.status === "resuming", accept_command: acceptCommand, reused: true };
|
|
207
224
|
const now = context.now();
|
|
208
225
|
let running = [];
|
|
209
226
|
context.db.beginWriteTransaction();
|
|
@@ -211,8 +228,6 @@ export function resumeRunRecovery(context, input) {
|
|
|
211
228
|
const current = recoveryGuard(context, project.id, run.id);
|
|
212
229
|
if (!current || current.recovery_id !== guard.recovery_id || current.generation !== guard.generation || current.status !== "sealed" || current.capture_path !== guard.capture_path)
|
|
213
230
|
throw new AppError("recovery_state_changed", "Recovery state changed before its resume transaction", 1, { recovery_id: guard.recovery_id });
|
|
214
|
-
if (!input.daemonId?.trim() || !input.harness?.trim() || !input.nativeSessionId?.trim())
|
|
215
|
-
throw new AppError("recovery_identity_required", "Recovery requires the replacement daemon and retained native Session identity", 1);
|
|
216
231
|
const owner = context.db.get(`
|
|
217
232
|
SELECT w.work_id, ws.id, ws.session_id, s.harness, s.provider_session_id,
|
|
218
233
|
w.status AS work_status, ws.status AS session_status
|