@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.74
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 +396 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +470 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +120 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +211 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +223 -215
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +592 -154
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { runHarnessAdapter } from "./harness-adapter.js";
|
|
6
|
+
import { managedLeaseReleased, managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
7
|
+
import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
|
|
8
|
+
import { retainedDaemonOperations } from "./run-control.js";
|
|
9
|
+
import { historicalDaemonSettlement } from "./native-daemon-history.js";
|
|
10
|
+
import { managedProviderBelongsToDaemon } from "./managed-daemon-binding.js";
|
|
11
|
+
/** Native daemons own cancellation after the observing CLI disconnects. */
|
|
12
|
+
export async function stopRuntimeScope(context, scopeId, requestId) {
|
|
13
|
+
const fence = fenceRuntimeBudget(context, scopeId, requestId);
|
|
14
|
+
const inventory = runtimeBudgetStatus(context, scopeId).processes;
|
|
15
|
+
const nodes = await Promise.all(inventory.filter(record => record.kind.endsWith("-daemon")).map(async (record) => {
|
|
16
|
+
try {
|
|
17
|
+
const historical = historicalDaemonSettlement(context, scopeId, record);
|
|
18
|
+
if (historical)
|
|
19
|
+
return historical;
|
|
20
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
21
|
+
if (!metadata.state_dir || !path.isAbsolute(metadata.state_dir) || !metadata.adapter_executable || !path.isAbsolute(metadata.adapter_executable))
|
|
22
|
+
throw new AppError("runtime_scope_owner_unknown", "Native stop requires its retained directory and executable", 1);
|
|
23
|
+
const file = path.join(metadata.state_dir, "daemon.json");
|
|
24
|
+
const read = () => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
25
|
+
const state = read();
|
|
26
|
+
const owner = state.config?.runtime_owner;
|
|
27
|
+
if (!state.daemon_id || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.resource_home !== resourceHome(context) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.id !== record.id || state.resource_process.lease_token !== record.lease_token || state.pid !== record.pid)
|
|
28
|
+
throw new AppError("runtime_scope_owner_unknown", "Native owner does not match its registration", 1);
|
|
29
|
+
const closed = state.shutdown_state === "clean" && state.active_tree === false && !processTreeIsAlive(record)
|
|
30
|
+
? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
|
|
31
|
+
const operationId = closed ? String(closed.operation_id) : `scope-stop:${crypto.createHash("sha256").update(JSON.stringify([scopeId, record.id, state.daemon_id])).digest("hex")}`;
|
|
32
|
+
const requested = path.join(metadata.state_dir, "operations", crypto.createHash("sha256").update(operationId).digest("hex"), "requested.json");
|
|
33
|
+
const prior = fs.existsSync(requested);
|
|
34
|
+
const current = managedProcessStatus(context).find(item => item.id === record.id);
|
|
35
|
+
if (!current || current.lease_token !== record.lease_token || current.pid !== record.pid || current.pid_started_at !== record.pid_started_at)
|
|
36
|
+
throw new AppError("runtime_scope_owner_changed", "Native registration changed before control", 1);
|
|
37
|
+
if (!prior && !processIsAlive(current))
|
|
38
|
+
throw new AppError("runtime_scope_outcome_unknown", "Dead native owner requires retained shutdown evidence", 1);
|
|
39
|
+
const env = { ...context.env, DD_FLOW_HOME: owner.dd_flow_home, DD_FLOW_BIN: owner.dd_flow_bin, DD_FLOW_RESOURCE_HOME: owner.resource_home };
|
|
40
|
+
delete env.DD_FLOW_CONTROL_TOKEN;
|
|
41
|
+
delete env.DD_FLOW_CONTROLLER_TOKEN;
|
|
42
|
+
const receipt = await runHarnessAdapter({ executable: metadata.adapter_executable,
|
|
43
|
+
args: prior ? ["daemon", "operation", "--operation-id", operationId, "--state-dir", metadata.state_dir, "--json"] : ["daemon", "stop", "--cancel-tree", "--state-dir", metadata.state_dir, "--json"],
|
|
44
|
+
env, operationId: prior ? `${operationId}:observe` : operationId, timeoutMs: 30_000 });
|
|
45
|
+
if (prior && (receipt.operation_id !== operationId || receipt.daemon_id !== state.daemon_id || receipt.operation !== "daemon.stop" || receipt.state !== "completed"))
|
|
46
|
+
throw new AppError("runtime_scope_outcome_unknown", "Native close operation has not completed", 1);
|
|
47
|
+
const result = (prior ? receipt.result : receipt);
|
|
48
|
+
const after = read();
|
|
49
|
+
const finalOwner = managedProcessStatus(context).find(item => item.id === record.id);
|
|
50
|
+
if (!finalOwner || finalOwner.lease_token !== record.lease_token || finalOwner.pid !== record.pid || finalOwner.pid_started_at !== record.pid_started_at || after.resource_process?.id !== record.id || after.resource_process.lease_token !== record.lease_token || JSON.stringify(after.config?.runtime_owner) !== JSON.stringify(owner))
|
|
51
|
+
throw new AppError("runtime_scope_owner_changed", "Native ownership changed during control", 1);
|
|
52
|
+
if (result?.stopped !== true || result.clean !== true || after.daemon_id !== state.daemon_id || after.shutdown_state !== "clean" || after.active_tree !== false || processTreeIsAlive(finalOwner))
|
|
53
|
+
throw new AppError("runtime_scope_not_settled", "Native close has not proved whole-tree settlement", 1);
|
|
54
|
+
const processes = managedProcessStatus(context).filter(item => managedProviderBelongsToDaemon(item, finalOwner, after) && item.operation_id?.startsWith(`${owner.operation_id}:`) && !processTreeIsAlive(item)).map(item => ({ process_id: item.id, native_settled: true, binding: managedProcessBinding(item, resourceHome(context)) }));
|
|
55
|
+
return { process_id: record.id, state_dir: metadata.state_dir, settled: true, operation_id: operationId, owner_operation_id: owner.operation_id, processes };
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return { process_id: record.id, settled: false, reason: error instanceof AppError ? error.code : "runtime_scope_control_failed", message: String(error) };
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
const covered = new Set(nodes.map(node => node.process_id));
|
|
62
|
+
for (const record of runtimeBudgetStatus(context, scopeId).processes) {
|
|
63
|
+
if (covered.has(record.id))
|
|
64
|
+
continue;
|
|
65
|
+
if (managedLeaseReleased(record)) {
|
|
66
|
+
nodes.push({ process_id: record.id, settled: true });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
70
|
+
// RUN control owns active controllers/checks. Adopt only its already-finished
|
|
71
|
+
// physical registrations here; death alone is not a completed RUN operation.
|
|
72
|
+
if ((record.kind === "run-controller" && metadata.role === "controller" && record.operation_id
|
|
73
|
+
|| record.kind === "check" && metadata.role === "check" && record.check_id)
|
|
74
|
+
&& record.project_id && record.run_id && ["stopped", "failed"].includes(record.state)
|
|
75
|
+
&& record.pid && record.pid_started_at && !processTreeIsAlive(record)) {
|
|
76
|
+
const operationId = record.operation_id ?? record.check_id;
|
|
77
|
+
nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (scopeLocalProcessRole(record) && record.operation_id && record.pid && record.pid_started_at) {
|
|
81
|
+
try {
|
|
82
|
+
await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token, force: false, graceMs: 500, reason: `scope-stop:${scopeId}` });
|
|
83
|
+
nodes.push({ process_id: record.id, state_dir: "", settled: true, operation_id: record.operation_id, owner_operation_id: record.operation_id });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: String(error) });
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const binding = JSON.stringify(managedProcessBinding(record, resourceHome(context)));
|
|
91
|
+
const parent = nodes.find(node => node.settled && node.state_dir === metadata.state_dir && "processes" in node && node.processes?.some(item => item.process_id === record.id && item.native_settled && JSON.stringify(item.binding) === binding));
|
|
92
|
+
if (parent && !processTreeIsAlive(record)) {
|
|
93
|
+
nodes.push({ process_id: record.id, state_dir: metadata.state_dir, settled: true, operation_id: parent.operation_id, owner_operation_id: parent.owner_operation_id });
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: "Resource needs its own settlement proof" });
|
|
97
|
+
}
|
|
98
|
+
return { scope_id: scopeId, fence, settled: nodes.every(node => node.settled), nodes };
|
|
99
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { AppError } from "../shared/errors.js";
|
|
8
|
+
import { getResourceDatabase } from "../storage/database.js";
|
|
9
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, processIsAlive, processStartedAt, registerManagedProcess, resourceHome } from "./managed-processes.js";
|
|
10
|
+
import { publicRuntimeScopeWorker, requestRuntimeScopeControl, runtimeBudgetStatus } from "./runtime-budget.js";
|
|
11
|
+
import { controlRuntimeScopeRuns } from "./runtime-scope-control.js";
|
|
12
|
+
import { prepareRuntimeScopeResume, recordRuntimeScopeResume } from "./runtime-scope-resume.js";
|
|
13
|
+
import { recoveryObservationBudget, renewRecoveryObservation, withRecoveryObservation } from "./recovery-observation-budget.js";
|
|
14
|
+
export async function launchRuntimeScopeResume(context, input) {
|
|
15
|
+
const resume = recordRuntimeScopeResume(context, input);
|
|
16
|
+
if (!resume.current)
|
|
17
|
+
return { scope_id: input.scopeId, resume, current: false, settled: false };
|
|
18
|
+
if (resume.status === "released")
|
|
19
|
+
return { scope_id: input.scopeId, resume, release: resume.release, current: true, dispatch_blocked: resume.dispatch_blocked, settled: false };
|
|
20
|
+
const control = runtimeBudgetStatus(context, input.scopeId).control;
|
|
21
|
+
if (control?.generation !== input.generation)
|
|
22
|
+
throw new AppError("runtime_scope_control_superseded", "Scope resume source changed before owner launch", 1);
|
|
23
|
+
const owner = await launchRuntimeScopeControl(context, input.scopeId, control.request_id, control.requested_mode, undefined, undefined, input.requestId);
|
|
24
|
+
return { ...owner, resume, dispatch_blocked: true, settled: false };
|
|
25
|
+
}
|
|
26
|
+
const keyFor = (scope, generation) => crypto.createHash("sha256").update(JSON.stringify([scope, generation])).digest("hex");
|
|
27
|
+
function birth() {
|
|
28
|
+
const started = processStartedAt(process.pid);
|
|
29
|
+
if (!started)
|
|
30
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope owner birth identity is unavailable", 1);
|
|
31
|
+
return { pid: process.pid, pid_started_at: started };
|
|
32
|
+
}
|
|
33
|
+
export async function reconcileRuntimeScopeControl(context, scopeId, generation, requestId) {
|
|
34
|
+
const control = runtimeBudgetStatus(context, scopeId).control;
|
|
35
|
+
if (!control || control.generation !== generation)
|
|
36
|
+
throw new AppError("runtime_scope_control_superseded", "Reconciliation requires the current controlled scope generation", 1);
|
|
37
|
+
renewRecoveryObservation({}, requestId);
|
|
38
|
+
return launchRuntimeScopeControl(context, scopeId, control.request_id, control.requested_mode, undefined, requestId);
|
|
39
|
+
}
|
|
40
|
+
export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest, reconcileId, resumeId) {
|
|
41
|
+
const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
|
|
42
|
+
let worker;
|
|
43
|
+
db.beginWriteTransaction();
|
|
44
|
+
try {
|
|
45
|
+
const current = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
|
|
46
|
+
const prior = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
|
|
47
|
+
const existing = prior ? JSON.parse(prior.metadata_json) : null;
|
|
48
|
+
if (existing) {
|
|
49
|
+
const identity = existing.owner ?? existing.launcher;
|
|
50
|
+
if (!identity || !Number.isSafeInteger(identity.pid) || identity.pid < 1 || typeof identity.pid_started_at !== "string" || !identity.pid_started_at || !["starting", "running", "completed", "failed", "superseded"].includes(existing.status))
|
|
51
|
+
throw new AppError("runtime_scope_owner_unknown", "Retained scope owner has no verifiable birth identity", 1);
|
|
52
|
+
}
|
|
53
|
+
if (!current || JSON.parse(current.metadata_json).generation !== control.generation || db.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId])) {
|
|
54
|
+
db.exec("COMMIT");
|
|
55
|
+
return { scope_id: scopeId, control, current: false, settled: false, worker: existing ? publicRuntimeScopeWorker(existing) : null };
|
|
56
|
+
}
|
|
57
|
+
if (existing && (existing.owner ? processIsAlive(existing.owner) : existing.status !== "failed" && processIsAlive(existing.launcher))) {
|
|
58
|
+
db.exec("COMMIT");
|
|
59
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
60
|
+
}
|
|
61
|
+
if (existing?.status === "completed" && !reconcileId && !resumeId) {
|
|
62
|
+
db.exec("COMMIT");
|
|
63
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
64
|
+
}
|
|
65
|
+
const renewal = resumeId ? `resume:${resumeId}` : reconcileId;
|
|
66
|
+
const snapshot = renewal ? renewRecoveryObservation(existing?.snapshot ?? {}, renewal) : existing?.snapshot;
|
|
67
|
+
if (existing && recoveryObservationBudget(snapshot).state().remaining_ms === 0) {
|
|
68
|
+
db.exec("COMMIT");
|
|
69
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
70
|
+
}
|
|
71
|
+
const cleanupRequest = resumeId ? undefined : reconcileId ?? existing?.reconcile_id;
|
|
72
|
+
worker = { scope_id: scopeId, generation: control.generation, request_id: requestId, mode, token: crypto.randomUUID(), status: "starting", launcher: birth(), ...(snapshot ? { snapshot } : {}), ...(cleanupRequest ? { reconcile_id: cleanupRequest } : {}) };
|
|
73
|
+
const now = context.now();
|
|
74
|
+
db.run("INSERT INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-worker',?,?,?,?,NULL,?,?,?) ON CONFLICT(resource_kind,resource_key) DO UPDATE SET lease_token = excluded.lease_token, metadata_json = excluded.metadata_json, updated_at = excluded.updated_at", [key, scopeId, worker.token, now, JSON.stringify(worker), now, now]);
|
|
75
|
+
db.exec("COMMIT");
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
db.exec("ROLLBACK");
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
83
|
+
if (!fs.existsSync(entry))
|
|
84
|
+
throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching scope control", 1);
|
|
85
|
+
const directory = path.join(resourceHome(context), "scope-controls", key);
|
|
86
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
87
|
+
const log = fs.openSync(path.join(directory, "control.log"), "a", 0o600);
|
|
88
|
+
try {
|
|
89
|
+
const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome(context), DD_FLOW_ENGINE_MODE: "1", DD_FLOW_SCOPE_CONTROL_TOKEN: worker.token };
|
|
90
|
+
delete env.DD_FLOW_CONTROL_TOKEN;
|
|
91
|
+
delete env.DD_FLOW_CONTROLLER_TOKEN;
|
|
92
|
+
const child = spawn(process.execPath, [entry, "runtime", "scope", "serve", "--scope-id", scopeId, "--generation", String(control.generation), "--json"], { env, detached: true, stdio: ["ignore", log, log] });
|
|
93
|
+
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
|
94
|
+
child.unref();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
fs.closeSync(log);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
db.run("UPDATE managed_resources SET metadata_json = json_set(metadata_json, '$.status', 'failed', '$.error', ?), updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ? AND json_extract(metadata_json, '$.owner') IS NULL", [String(error), context.now(), key, worker.token]);
|
|
102
|
+
}
|
|
103
|
+
const retained = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
|
|
104
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(JSON.parse(retained.metadata_json)) };
|
|
105
|
+
}
|
|
106
|
+
export async function serveRuntimeScopeControl(context, scopeId, generation) {
|
|
107
|
+
if (!Number.isSafeInteger(generation) || generation < 1 || !context.env.DD_FLOW_SCOPE_CONTROL_TOKEN)
|
|
108
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope worker requires its launch token and generation", 1);
|
|
109
|
+
const db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, generation), token = context.env.DD_FLOW_SCOPE_CONTROL_TOKEN;
|
|
110
|
+
let worker;
|
|
111
|
+
db.beginWriteTransaction();
|
|
112
|
+
try {
|
|
113
|
+
const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
|
|
114
|
+
if (!row)
|
|
115
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope worker claim changed", 1);
|
|
116
|
+
worker = JSON.parse(row.metadata_json);
|
|
117
|
+
if (worker.owner || worker.status !== "starting")
|
|
118
|
+
throw new AppError("runtime_scope_already_owned", "Scope control already has a physical owner", 1);
|
|
119
|
+
worker.owner = birth();
|
|
120
|
+
worker.status = "running";
|
|
121
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
122
|
+
db.exec("COMMIT");
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
db.exec("ROLLBACK");
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
let heartbeat;
|
|
129
|
+
// A fence must never be held indefinitely by an observer that cannot obtain
|
|
130
|
+
// fresh provider evidence. A later operator request continues the retained
|
|
131
|
+
// budget; this owner records why it stopped without claiming settlement.
|
|
132
|
+
const observation = recoveryObservationBudget(worker.snapshot);
|
|
133
|
+
try {
|
|
134
|
+
const record = registerManagedProcess(context, { kind: "scope-control", ownerId: key, operationId: `scope-control:${key}:${token}`, ownerPid: process.pid, metadata: { role: "control", scope_id: scopeId, generation } });
|
|
135
|
+
worker.process_id = record.id;
|
|
136
|
+
worker.process_lease_token = record.lease_token;
|
|
137
|
+
confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
|
|
138
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
139
|
+
let leaseLost = false;
|
|
140
|
+
heartbeat = setInterval(() => { try {
|
|
141
|
+
if (!heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }))
|
|
142
|
+
leaseLost = true;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
leaseLost = true;
|
|
146
|
+
} }, 10_000);
|
|
147
|
+
heartbeat.unref();
|
|
148
|
+
for (;;) {
|
|
149
|
+
if (leaseLost)
|
|
150
|
+
throw new AppError("runtime_scope_owner_changed", "Scope worker lost its managed lease", 1);
|
|
151
|
+
if (observation.exhausted()) {
|
|
152
|
+
worker.status = "failed";
|
|
153
|
+
worker.error = "recovery_observation_budget_exhausted";
|
|
154
|
+
worker.snapshot = {
|
|
155
|
+
...(worker.snapshot ?? {}),
|
|
156
|
+
status: "recovery_blocked",
|
|
157
|
+
settled: false,
|
|
158
|
+
pending_reasons: ["recovery_observation_budget_exhausted"],
|
|
159
|
+
error: { code: "recovery_observation_budget_exhausted", message: "Scope recovery observation exceeded its 120 second budget" },
|
|
160
|
+
recovery_observation: observation.state(),
|
|
161
|
+
};
|
|
162
|
+
return { scope_id: scopeId, generation, status: "recovery_blocked", settled: false, error: worker.error };
|
|
163
|
+
}
|
|
164
|
+
const status = runtimeBudgetStatus(context, scopeId);
|
|
165
|
+
const release = status.release;
|
|
166
|
+
if (release?.current === true && release.generation === generation) {
|
|
167
|
+
worker.status = "completed";
|
|
168
|
+
worker.snapshot = release;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (status.control?.generation !== generation || status.fence) {
|
|
172
|
+
worker.status = "superseded";
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
const resume = status.resume;
|
|
176
|
+
const outcome = await withRecoveryObservation(observation, async () => (worker.reconcile_id ? null : await prepareRuntimeScopeResume(context, scopeId, generation, resume?.generation === generation ? resume.request_id : undefined))
|
|
177
|
+
?? await controlRuntimeScopeRuns(context, scopeId, worker.request_id, worker.mode, worker.reconcile_id));
|
|
178
|
+
observation.exhausted();
|
|
179
|
+
worker.snapshot = { ...outcome, recovery_observation: observation.state() };
|
|
180
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
181
|
+
if (worker.snapshot.drain?.physical_settled === true) {
|
|
182
|
+
worker.status = "completed";
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
await delay(observation.nextDelay());
|
|
186
|
+
}
|
|
187
|
+
return { scope_id: scopeId, generation, status: worker.status };
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error instanceof AppError && error.code === "runtime_scope_control_superseded") {
|
|
191
|
+
const release = runtimeBudgetStatus(context, scopeId).release;
|
|
192
|
+
worker.status = release?.current === true && release.generation === generation ? "completed" : "superseded";
|
|
193
|
+
if (worker.status === "completed")
|
|
194
|
+
worker.snapshot = release;
|
|
195
|
+
return { scope_id: scopeId, generation, status: worker.status };
|
|
196
|
+
}
|
|
197
|
+
worker.status = "failed";
|
|
198
|
+
worker.error = String(error);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
if (heartbeat)
|
|
203
|
+
clearInterval(heartbeat);
|
|
204
|
+
observation.exhausted();
|
|
205
|
+
worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
|
|
206
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
207
|
+
if (worker.process_id && worker.process_lease_token)
|
|
208
|
+
finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
6
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { resolveRun } from "./runs.js";
|
|
8
|
+
import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
|
|
9
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processTreeIsAlive, registerManagedProcess, reservePorts, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
10
|
+
/** Foreground supervisor: the tool may yield while this command owns the service.
|
|
11
|
+
* Its ready receipt is emitted immediately; a separate stop ends this command.
|
|
12
|
+
* Readiness is infrastructure evidence, never a successful scenario verdict. */
|
|
13
|
+
export async function startRuntimeService(context, input) {
|
|
14
|
+
if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0)
|
|
15
|
+
throw new AppError("validation", "Readiness timeout must be positive and finite", 2);
|
|
16
|
+
if (!input.command.trim() || !input.ports.length || input.ports.some(name => !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
|
|
17
|
+
throw new AppError("validation", "Declare unique lowercase port names, a ready-port from that list and a local /ready-path", 2);
|
|
18
|
+
const project = requireProjectByRoot(context, path.resolve(input.projectRoot));
|
|
19
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
20
|
+
if (!run?.run_root || !run.workspace_root)
|
|
21
|
+
throw new AppError("not_found", "Registered RUN with a materialized workspace is required", 2);
|
|
22
|
+
input = { ...input, runId: run.id };
|
|
23
|
+
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
24
|
+
const id = `PROC-${crypto.randomUUID()}`;
|
|
25
|
+
const directory = path.join(run.run_root, "runtime", id);
|
|
26
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
27
|
+
const record = registerManagedProcess(context, { id, kind: "runtime-service", ownerId: `run:${input.runId}`, ownerPid: process.pid, runId: input.runId, projectId: project.id, stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), metadata: { command: input.command } });
|
|
28
|
+
const receiptPath = path.join(directory, "service.json");
|
|
29
|
+
let stdout, stderr;
|
|
30
|
+
let settled = false, ready = false;
|
|
31
|
+
const receipt = { schema_id: "dd-flow/runtime-service@1", process_id: record.id, run_id: input.runId, status: "starting", started_at: context.now(), receipt_path: receiptPath, stdout_path: path.join(directory, "stdout.log"), stderr_path: path.join(directory, "stderr.log") };
|
|
32
|
+
const save = () => { const temporary = `${receiptPath}.${process.pid}.tmp`; fs.writeFileSync(temporary, JSON.stringify(receipt, null, 2)); fs.renameSync(temporary, receiptPath); };
|
|
33
|
+
try {
|
|
34
|
+
const allocation = await reservePorts(context, { ownerId: record.owner_id, processId: record.id, names: input.ports });
|
|
35
|
+
const environment = { ...codeExecutionEnvironment(run.workspace_root), ...Object.fromEntries(Object.entries(allocation.ports).map(([name, port]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(port)])), DD_FLOW_EVIDENCE_DIR: directory, DD_FLOW_CHECK_COMPLETION_FILE: path.join(directory, "completion.json") };
|
|
36
|
+
const quoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
37
|
+
Object.assign(receipt, { ports: allocation.ports, environment: Object.fromEntries(Object.entries(environment).filter(([key]) => key.startsWith("DD_FLOW_PORT_"))), stop_command: `DD_FLOW_HOME=${quoted(context.ddFlowHome)} DD_FLOW_RESOURCE_HOME=${quoted(resourceHome(context))} dd-flow runtime process stop --id ${record.id} --lease-token ${record.lease_token} --json` });
|
|
38
|
+
save();
|
|
39
|
+
input.progress?.(`service ${record.id} preparing; receipt: ${receiptPath}`);
|
|
40
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
41
|
+
stdout = fs.openSync(String(receipt.stdout_path), "a");
|
|
42
|
+
stderr = fs.openSync(String(receipt.stderr_path), "a");
|
|
43
|
+
const running = runCheck(input.command, run.workspace_root, environment, stdout, stderr, elapsed => input.progress?.(`service ${record.id} alive (${elapsed}s); receipt: ${receiptPath}`), pid => confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }));
|
|
44
|
+
void running.then(() => { settled = true; });
|
|
45
|
+
const deadline = performance.now() + input.timeoutMs;
|
|
46
|
+
while (!settled && performance.now() < deadline) {
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch(`http://127.0.0.1:${allocation.ports[input.readyPort]}${input.readyPath}`, { signal: AbortSignal.timeout(1_000), redirect: "error" });
|
|
49
|
+
await response.body?.cancel();
|
|
50
|
+
ready = response.ok;
|
|
51
|
+
}
|
|
52
|
+
catch { /* Startup is observed until the declared deadline. */ }
|
|
53
|
+
if (ready)
|
|
54
|
+
break;
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
56
|
+
}
|
|
57
|
+
if (!ready) {
|
|
58
|
+
await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token });
|
|
59
|
+
await running;
|
|
60
|
+
throw new AppError("service_not_ready", "Service did not pass its declared HTTP readiness check; inspect retained logs", 2, { receipt_path: receiptPath });
|
|
61
|
+
}
|
|
62
|
+
Object.assign(receipt, { status: "ready", ready_at: context.now() });
|
|
63
|
+
save();
|
|
64
|
+
input.progress?.(`service ready: ${JSON.stringify(receipt)}; keep this supervisor running, use its exact stop command when finished`);
|
|
65
|
+
const outcome = await running;
|
|
66
|
+
const current = managedProcessStatus(context).find(item => item.id === record.id);
|
|
67
|
+
if (current.state === "stopping") {
|
|
68
|
+
const settleBy = performance.now() + 5_000;
|
|
69
|
+
while (processTreeIsAlive(current) && performance.now() < settleBy)
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
71
|
+
}
|
|
72
|
+
if (processTreeIsAlive(current))
|
|
73
|
+
throw new AppError("process_tree_not_settled", "Service still owns live children; retain resources", 2, { process_id: record.id });
|
|
74
|
+
if (!["stopped", "failed"].includes(current.state))
|
|
75
|
+
finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: current.state === "stopping" || outcome.exitCode === 0 ? "stopped" : "failed", reason: current.state === "stopping" ? "requested_stop" : outcome.error });
|
|
76
|
+
Object.assign(receipt, { status: managedProcessStatus(context).find(item => item.id === record.id).state, finished_at: context.now(), exit_code: outcome.exitCode });
|
|
77
|
+
save();
|
|
78
|
+
if (receipt.status === "failed")
|
|
79
|
+
throw new AppError("service_failed", "Service exited unsuccessfully; inspect retained logs", 2, receipt);
|
|
80
|
+
return receipt;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const current = managedProcessStatus(context).find(item => item.id === record.id);
|
|
84
|
+
if (!processTreeIsAlive(current))
|
|
85
|
+
finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "failed", reason: error instanceof Error ? error.message : String(error) });
|
|
86
|
+
Object.assign(receipt, { status: "failed", error: error instanceof Error ? error.message : String(error), cleanup_pending: processTreeIsAlive(current) });
|
|
87
|
+
save();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
if (stdout !== undefined)
|
|
92
|
+
fs.closeSync(stdout);
|
|
93
|
+
if (stderr !== undefined)
|
|
94
|
+
fs.closeSync(stderr);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -6,7 +6,7 @@ import { Ajv } from "ajv/dist/ajv.js";
|
|
|
6
6
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
7
7
|
import { normalizeFlowContract } from "../domain/flow-contract.js";
|
|
8
8
|
import { AppError } from "../shared/errors.js";
|
|
9
|
-
import {
|
|
9
|
+
import { readRunEngineBinding } from "./run-engine-bindings.js";
|
|
10
10
|
export function captureMemoryBankBaseline(input) {
|
|
11
11
|
const projectRoot = path.resolve(input.projectRoot);
|
|
12
12
|
const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
|
|
@@ -94,7 +94,7 @@ export function validateSchema(options) {
|
|
|
94
94
|
throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
|
|
95
95
|
}
|
|
96
96
|
const filePath = path.resolve(options.file);
|
|
97
|
-
const data = readJson(filePath, "input file");
|
|
97
|
+
const data = Object.hasOwn(options, "data") ? options.data : readJson(filePath, "input file");
|
|
98
98
|
const schemaResolution = resolveSchema(options);
|
|
99
99
|
const schema = readJson(schemaResolution.path, "schema");
|
|
100
100
|
// Ajv's default export only carries the draft-07 meta-schema. New stage
|
|
@@ -117,8 +117,8 @@ export function validateSchema(options) {
|
|
|
117
117
|
}
|
|
118
118
|
const valid = validate(data);
|
|
119
119
|
const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
|
|
120
|
-
const
|
|
121
|
-
const allErrors = [...errors, ...
|
|
120
|
+
const contractErrors = valid ? validateContractInvariants(options.schemaName, data) : [];
|
|
121
|
+
const allErrors = [...errors, ...contractErrors];
|
|
122
122
|
if (allErrors.length > 0) {
|
|
123
123
|
throw new AppError("schema_validation", `${path.basename(filePath)} does not match schema`, 2, {
|
|
124
124
|
schema: schemaResolution,
|
|
@@ -181,7 +181,7 @@ const historicalSchemaRegistry = {
|
|
|
181
181
|
}]
|
|
182
182
|
};
|
|
183
183
|
function resolveRunBoundSchema(options) {
|
|
184
|
-
if (!options.
|
|
184
|
+
if (!options.runRoot || options.schemaDir)
|
|
185
185
|
return null;
|
|
186
186
|
const data = readJson(path.resolve(options.file), "input file");
|
|
187
187
|
const root = asRecord(data);
|
|
@@ -190,13 +190,13 @@ function resolveRunBoundSchema(options) {
|
|
|
190
190
|
const runId = options.runId ?? inferredRunId;
|
|
191
191
|
if (typeof runId !== "string")
|
|
192
192
|
return null;
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
return null;
|
|
197
|
-
const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
|
|
193
|
+
const runRoot = path.resolve(options.runRoot);
|
|
194
|
+
const bindingPath = path.join(runRoot, "engine-binding.json");
|
|
195
|
+
const binding = readRunEngineBinding(bindingPath);
|
|
198
196
|
if (!binding)
|
|
199
|
-
|
|
197
|
+
throw new AppError("run_engine_binding_missing", "RUN engine binding is missing", 1, { path: bindingPath });
|
|
198
|
+
if (binding.run_id !== runId)
|
|
199
|
+
throw new AppError("run_engine_binding_invalid", "RUN engine binding belongs to another RUN", 1, { expected_run_id: runId, actual_run_id: binding.run_id, path: bindingPath });
|
|
200
200
|
const manifestPath = path.join(binding.engine.snapshot_root, "engine.json");
|
|
201
201
|
const manifest = readJson(manifestPath, "bound engine manifest");
|
|
202
202
|
const integrity = asRecord(objectValue(manifest, "integrity"));
|
|
@@ -284,7 +284,7 @@ function formatAjvError(error) {
|
|
|
284
284
|
keyword: error.keyword
|
|
285
285
|
};
|
|
286
286
|
}
|
|
287
|
-
function
|
|
287
|
+
function validateContractInvariants(schemaName, data) {
|
|
288
288
|
const root = asRecord(data);
|
|
289
289
|
if (!root) {
|
|
290
290
|
return [];
|
|
@@ -76,8 +76,8 @@ export function renderExternalStageContext(input) {
|
|
|
76
76
|
].join("\n");
|
|
77
77
|
}
|
|
78
78
|
function validateContext(value, expectedStage) {
|
|
79
|
-
if (value.schema_id !== "dd-eval/stage-context@1")
|
|
80
|
-
throw new AppError("validation", "Stage context must use dd-
|
|
79
|
+
if (value.schema_id !== "dd-flow/stage-context@1" && value.schema_id !== "dd-eval/stage-context@1")
|
|
80
|
+
throw new AppError("validation", "Stage context must use dd-flow/stage-context@1", 2);
|
|
81
81
|
if (value.stage !== expectedStage)
|
|
82
82
|
throw new AppError("validation", "Stage context does not match --stage", 2, { expected_stage: expectedStage, context_stage: value.stage });
|
|
83
83
|
if (!value.objective?.trim())
|
|
@@ -8,7 +8,7 @@ import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from
|
|
|
8
8
|
import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
9
9
|
import { validateSchema } from "./schema-validation.js";
|
|
10
10
|
import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
|
|
11
|
-
import { usageForRun } from "./usage.js";
|
|
11
|
+
import { recalculateRunUsage, usageForRun } from "./usage.js";
|
|
12
12
|
import { boundCanonicalPlan } from "./plan-runtime.js";
|
|
13
13
|
import { AppError } from "../shared/errors.js";
|
|
14
14
|
import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
@@ -18,6 +18,17 @@ import { registerProject } from "./projects.js";
|
|
|
18
18
|
import { registerProtocol } from "./protocols.js";
|
|
19
19
|
import { previewNextEntityId } from "./ids.js";
|
|
20
20
|
import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
|
|
21
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
22
|
+
function stageUsageProjection(context, view, stage, startedAt) {
|
|
23
|
+
if (!startedAt)
|
|
24
|
+
return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
|
|
25
|
+
const value = recalculateRunUsage(context, { projectId: view.run.project_id, runId: view.run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
|
|
26
|
+
const totals = value.totals, coverage = value.coverage;
|
|
27
|
+
const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
|
|
28
|
+
const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
|
|
29
|
+
const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
|
|
30
|
+
return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
|
|
31
|
+
}
|
|
21
32
|
const stagePromptSections = [
|
|
22
33
|
"stage_identity",
|
|
23
34
|
"authoritative_runtime_facts",
|
|
@@ -53,7 +64,9 @@ function bootstrapStageRun(context, projectRoot, input) {
|
|
|
53
64
|
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
|
|
54
65
|
throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
|
|
55
66
|
}
|
|
56
|
-
|
|
67
|
+
if (!started.run.run_root)
|
|
68
|
+
throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
|
|
69
|
+
const intakeDir = path.join(started.run.run_root, "intake");
|
|
57
70
|
fs.mkdirSync(intakeDir, { recursive: true });
|
|
58
71
|
fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
|
|
59
72
|
}
|
|
@@ -117,6 +130,7 @@ export function startStage(context, input) {
|
|
|
117
130
|
if (!runId)
|
|
118
131
|
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
119
132
|
const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
133
|
+
assertRunMutationAllowed(context, before.run.project_id, before.run.id);
|
|
120
134
|
const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
|
|
121
135
|
const attached = runView(attachFlowRunStage(context, {
|
|
122
136
|
projectRoot,
|
|
@@ -216,7 +230,7 @@ export function startStage(context, input) {
|
|
|
216
230
|
})),
|
|
217
231
|
worker_prompt_markdown: prompt
|
|
218
232
|
});
|
|
219
|
-
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, ddFlowHome: context.ddFlowHome });
|
|
233
|
+
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
220
234
|
atomicWrite(path.join(stageRoot, "stage-start.json"), {
|
|
221
235
|
schema_id: "dd-flow/stage-start@2",
|
|
222
236
|
run_id: attached.run.id,
|
|
@@ -261,6 +275,7 @@ export function finishStage(context, input) {
|
|
|
261
275
|
throw new AppError("usage", "stage finish requires a RUN id", 2);
|
|
262
276
|
const runId = input.runId;
|
|
263
277
|
const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
278
|
+
assertRunMutationAllowed(context, view.run.project_id, view.run.id);
|
|
264
279
|
const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
|
|
265
280
|
if (!existing) {
|
|
266
281
|
throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
|
|
@@ -273,7 +288,7 @@ export function finishStage(context, input) {
|
|
|
273
288
|
const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
|
|
274
289
|
const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage);
|
|
275
290
|
try {
|
|
276
|
-
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, ddFlowHome: context.ddFlowHome });
|
|
291
|
+
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
277
292
|
const semantic = readSemanticFile(semanticFile, runHome);
|
|
278
293
|
const status = stringValue(semantic.status) ?? "done";
|
|
279
294
|
if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
|
|
@@ -288,12 +303,13 @@ export function finishStage(context, input) {
|
|
|
288
303
|
const planFinish = input.stage === "plan"
|
|
289
304
|
? preparePlanFinish(context, projectRoot, view, stageRoot)
|
|
290
305
|
: undefined;
|
|
306
|
+
const stageUsage = stageUsageProjection(context, view, input.stage, existing.started_at);
|
|
291
307
|
const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
|
|
292
308
|
const dataPath = path.join(stageRoot, "stage-report.json");
|
|
293
309
|
const reportPath = path.join(stageRoot, "stage-report.md");
|
|
294
310
|
const htmlPath = path.join(stageRoot, "stage-report.html");
|
|
295
311
|
atomicWrite(dataPath, report);
|
|
296
|
-
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, ddFlowHome: context.ddFlowHome });
|
|
312
|
+
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
297
313
|
atomicWrite(reportPath, renderMarkdown(report));
|
|
298
314
|
atomicWrite(htmlPath, renderHtml(projectRoot, report));
|
|
299
315
|
if (view.run.subject.type === "protocol") {
|
|
@@ -308,7 +324,8 @@ export function finishStage(context, input) {
|
|
|
308
324
|
stageReport: htmlPath,
|
|
309
325
|
data: dataPath,
|
|
310
326
|
dataSchemaId: String(report.schema_id),
|
|
311
|
-
report: reportPath
|
|
327
|
+
report: reportPath,
|
|
328
|
+
usage: stageUsage
|
|
312
329
|
});
|
|
313
330
|
finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
|
|
314
331
|
appendAudit(context, {
|
|
@@ -410,7 +427,12 @@ function nextActionForProtocolStage(stage) {
|
|
|
410
427
|
return `continue_${stage}`;
|
|
411
428
|
}
|
|
412
429
|
function runHomePath(run) {
|
|
413
|
-
|
|
430
|
+
if (!run.run_root)
|
|
431
|
+
throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
|
|
432
|
+
return run.run_root;
|
|
433
|
+
}
|
|
434
|
+
function runStatePath(run) {
|
|
435
|
+
return path.join(runHomePath(run), "run.json");
|
|
414
436
|
}
|
|
415
437
|
export function defaultStageDir(stage, flowKind) {
|
|
416
438
|
const normalized = stage === "implementation" ? "code" : stage;
|
|
@@ -702,7 +724,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
702
724
|
if (stage === "code" || stage === "implementation") {
|
|
703
725
|
return {
|
|
704
726
|
schema_id: "dd-flow/code-stage-report@2",
|
|
705
|
-
run: { run_id: view.run.id, run_state: view.run
|
|
727
|
+
run: { run_id: view.run.id, run_state: runStatePath(view.run) },
|
|
706
728
|
stage: { name: stage, dir: path.basename(stageRoot), status },
|
|
707
729
|
project: { id: view.run.project_id, title: path.basename(view.run.project_root) },
|
|
708
730
|
subject: { id: view.run.subject.id, title: view.run.subject.id },
|
|
@@ -712,7 +734,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
712
734
|
summary: result,
|
|
713
735
|
next_action: stringValue(semantic.next_action) ?? "Proceed to readiness."
|
|
714
736
|
},
|
|
715
|
-
breadcrumbs: [{ label: "RUN", href: view.run
|
|
737
|
+
breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }],
|
|
716
738
|
implemented_goals: [{ title: `Stage ${stage}`, summary: result }],
|
|
717
739
|
acceptance_scenarios: acceptance.map((summary, index) => ({
|
|
718
740
|
id: `SCN-${stage}-${index + 1}`,
|
|
@@ -763,7 +785,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
763
785
|
memory_bank_scope: "changed_files_and_links_only",
|
|
764
786
|
status: "passed"
|
|
765
787
|
},
|
|
766
|
-
breadcrumbs: [{ label: "RUN", href: view.run
|
|
788
|
+
breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }]
|
|
767
789
|
};
|
|
768
790
|
}
|
|
769
791
|
function preparePlanFinish(context, projectRoot, view, stageRoot) {
|