@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90
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 +460 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +304 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1409 -382
- package/dist/cli.js +6 -2
- 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.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -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/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -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.d.mts +1 -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/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/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/canon.js +11 -8
- package/dist/services/cleanup.js +57 -13
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +543 -96
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +132 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +750 -73
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +587 -299
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +118 -7
- package/dist/services/lifecycle-invocations.js +1055 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +283 -27
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +73 -28
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -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 +222 -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 +36 -0
- package/dist/services/run-controller.js +789 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +368 -74
- package/dist/services/runtime-budget.js +434 -0
- package/dist/services/runtime-command.js +43 -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 +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +99 -102
- package/dist/services/stage-pause.js +84 -55
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +195 -88
- package/dist/services/vnext-code.js +285 -252
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +160 -17
- package/dist/services/vnext-merge.js +291 -104
- package/dist/services/vnext-plan-review.js +139 -81
- package/dist/services/vnext-plan.js +138 -69
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +88 -65
- package/dist/services/work-registry.js +716 -168
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +459 -32
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -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,243 @@
|
|
|
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, prepareRuntimeScopeFence, parseRuntimeBudget } 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 function prepareRuntimeScopeReconciliation(context, scopeId, generation, requestId) {
|
|
34
|
+
prepareRuntimeScopeFence(scopeId, requestId);
|
|
35
|
+
if (!Number.isSafeInteger(generation) || generation < 1)
|
|
36
|
+
throw new AppError("validation", "Scope reconciliation requires a positive integer generation", 2);
|
|
37
|
+
renewRecoveryObservation({}, requestId);
|
|
38
|
+
const db = getResourceDatabase(resourceHome(context), "read_existing");
|
|
39
|
+
try {
|
|
40
|
+
const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
|
|
41
|
+
const control = row ? JSON.parse(row.metadata_json) : null;
|
|
42
|
+
if (!control || control.generation !== generation)
|
|
43
|
+
throw new AppError("runtime_scope_control_superseded", "Reconciliation requires the current controlled scope generation", 1);
|
|
44
|
+
return control;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
db.close?.();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function reconcileRuntimeScopeControl(context, scopeId, generation, requestId) {
|
|
51
|
+
const control = prepareRuntimeScopeReconciliation(context, scopeId, generation, requestId);
|
|
52
|
+
return launchRuntimeScopeControl(context, scopeId, control.request_id, control.requested_mode, undefined, requestId);
|
|
53
|
+
}
|
|
54
|
+
export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest, reconcileId, resumeId, prepared) {
|
|
55
|
+
const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest, prepared), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
|
|
56
|
+
let worker;
|
|
57
|
+
db.beginWriteTransaction();
|
|
58
|
+
try {
|
|
59
|
+
const current = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
|
|
60
|
+
const prior = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
|
|
61
|
+
const existing = prior ? JSON.parse(prior.metadata_json) : null;
|
|
62
|
+
if (existing) {
|
|
63
|
+
const identity = existing.owner ?? existing.launcher;
|
|
64
|
+
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))
|
|
65
|
+
throw new AppError("runtime_scope_owner_unknown", "Retained scope owner has no verifiable birth identity", 1);
|
|
66
|
+
}
|
|
67
|
+
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])) {
|
|
68
|
+
db.exec("COMMIT");
|
|
69
|
+
return { scope_id: scopeId, control, current: false, settled: false, worker: existing ? publicRuntimeScopeWorker(existing) : null };
|
|
70
|
+
}
|
|
71
|
+
if (existing && (existing.owner ? processIsAlive(existing.owner) : existing.status !== "failed" && processIsAlive(existing.launcher))) {
|
|
72
|
+
db.exec("COMMIT");
|
|
73
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
74
|
+
}
|
|
75
|
+
if (existing?.status === "completed" && !reconcileId && !resumeId) {
|
|
76
|
+
db.exec("COMMIT");
|
|
77
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
78
|
+
}
|
|
79
|
+
const renewal = resumeId ? `resume:${resumeId}` : reconcileId;
|
|
80
|
+
const snapshot = renewal ? renewRecoveryObservation(existing?.snapshot ?? {}, renewal) : existing?.snapshot;
|
|
81
|
+
if (existing && recoveryObservationBudget(snapshot).state().remaining_ms === 0) {
|
|
82
|
+
db.exec("COMMIT");
|
|
83
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
|
|
84
|
+
}
|
|
85
|
+
const cleanupRequest = resumeId ? undefined : reconcileId ?? existing?.reconcile_id;
|
|
86
|
+
worker = { scope_id: scopeId, generation: control.generation, request_id: requestId, mode, token: crypto.randomUUID(), status: "starting", launcher: birth(), ...(snapshot ? { snapshot } : {}), ...(cleanupRequest ? { reconcile_id: cleanupRequest } : {}) };
|
|
87
|
+
const now = context.now();
|
|
88
|
+
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]);
|
|
89
|
+
db.exec("COMMIT");
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
db.exec("ROLLBACK");
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
97
|
+
if (!fs.existsSync(entry))
|
|
98
|
+
throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching scope control", 1);
|
|
99
|
+
const directory = path.join(resourceHome(context), "scope-controls", key);
|
|
100
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
101
|
+
const log = fs.openSync(path.join(directory, "control.log"), "a", 0o600);
|
|
102
|
+
try {
|
|
103
|
+
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 };
|
|
104
|
+
delete env.DD_FLOW_CONTROL_TOKEN;
|
|
105
|
+
delete env.DD_FLOW_CONTROLLER_TOKEN;
|
|
106
|
+
const child = spawn(process.execPath, [entry, "runtime", "scope", "serve", "--scope-id", scopeId, "--generation", String(control.generation), "--json"], { env, detached: true, stdio: ["ignore", log, log] });
|
|
107
|
+
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
|
108
|
+
child.unref();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
fs.closeSync(log);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
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]);
|
|
116
|
+
}
|
|
117
|
+
const retained = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
|
|
118
|
+
return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(JSON.parse(retained.metadata_json)) };
|
|
119
|
+
}
|
|
120
|
+
export function prepareRuntimeScopeServing(context, scopeId, generation) {
|
|
121
|
+
parseRuntimeBudget(JSON.stringify({ schema_id: "dd-flow/runtime-budget@1", scope_id: scopeId, per_harness: {} }));
|
|
122
|
+
if (!Number.isSafeInteger(generation) || generation < 1)
|
|
123
|
+
throw new AppError("validation", "Scope worker requires a positive integer generation", 2);
|
|
124
|
+
const token = context.env.DD_FLOW_SCOPE_CONTROL_TOKEN;
|
|
125
|
+
if (!token)
|
|
126
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope worker requires its launch token", 1);
|
|
127
|
+
const key = keyFor(scopeId, generation), db = getResourceDatabase(resourceHome(context), "read_existing");
|
|
128
|
+
try {
|
|
129
|
+
requireStartingScopeWorker(db, key, token);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
db.close?.();
|
|
133
|
+
}
|
|
134
|
+
return { key, token };
|
|
135
|
+
}
|
|
136
|
+
function requireStartingScopeWorker(db, key, token) {
|
|
137
|
+
const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
|
|
138
|
+
if (!row)
|
|
139
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope worker claim changed", 1);
|
|
140
|
+
const worker = JSON.parse(row.metadata_json);
|
|
141
|
+
if (worker.owner || worker.status !== "starting")
|
|
142
|
+
throw new AppError("runtime_scope_already_owned", "Scope control already has a physical owner", 1);
|
|
143
|
+
return worker;
|
|
144
|
+
}
|
|
145
|
+
export async function serveRuntimeScopeControl(context, scopeId, generation) {
|
|
146
|
+
const { key, token } = prepareRuntimeScopeServing(context, scopeId, generation);
|
|
147
|
+
const db = getResourceDatabase(resourceHome(context));
|
|
148
|
+
let worker;
|
|
149
|
+
db.beginWriteTransaction();
|
|
150
|
+
try {
|
|
151
|
+
worker = requireStartingScopeWorker(db, key, token);
|
|
152
|
+
worker.owner = birth();
|
|
153
|
+
worker.status = "running";
|
|
154
|
+
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]);
|
|
155
|
+
db.exec("COMMIT");
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
db.exec("ROLLBACK");
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
let heartbeat;
|
|
162
|
+
// A fence must never be held indefinitely by an observer that cannot obtain
|
|
163
|
+
// fresh provider evidence. A later operator request continues the retained
|
|
164
|
+
// budget; this owner records why it stopped without claiming settlement.
|
|
165
|
+
const observation = recoveryObservationBudget(worker.snapshot);
|
|
166
|
+
try {
|
|
167
|
+
const record = registerManagedProcess(context, { kind: "scope-control", ownerId: key, operationId: `scope-control:${key}:${token}`, ownerPid: process.pid, metadata: { role: "control", scope_id: scopeId, generation } });
|
|
168
|
+
worker.process_id = record.id;
|
|
169
|
+
worker.process_lease_token = record.lease_token;
|
|
170
|
+
confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
|
|
171
|
+
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]);
|
|
172
|
+
let leaseLost = false;
|
|
173
|
+
heartbeat = setInterval(() => { try {
|
|
174
|
+
if (!heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }))
|
|
175
|
+
leaseLost = true;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
leaseLost = true;
|
|
179
|
+
} }, 10_000);
|
|
180
|
+
heartbeat.unref();
|
|
181
|
+
for (;;) {
|
|
182
|
+
if (leaseLost)
|
|
183
|
+
throw new AppError("runtime_scope_owner_changed", "Scope worker lost its managed lease", 1);
|
|
184
|
+
if (observation.exhausted()) {
|
|
185
|
+
worker.status = "failed";
|
|
186
|
+
worker.error = "recovery_observation_budget_exhausted";
|
|
187
|
+
worker.snapshot = {
|
|
188
|
+
...(worker.snapshot ?? {}),
|
|
189
|
+
status: "recovery_blocked",
|
|
190
|
+
settled: false,
|
|
191
|
+
pending_reasons: ["recovery_observation_budget_exhausted"],
|
|
192
|
+
error: { code: "recovery_observation_budget_exhausted", message: "Scope recovery observation exceeded its 120 second budget" },
|
|
193
|
+
recovery_observation: observation.state(),
|
|
194
|
+
};
|
|
195
|
+
return { scope_id: scopeId, generation, status: "recovery_blocked", settled: false, error: worker.error };
|
|
196
|
+
}
|
|
197
|
+
const status = runtimeBudgetStatus(context, scopeId);
|
|
198
|
+
const release = status.release;
|
|
199
|
+
if (release?.current === true && release.generation === generation) {
|
|
200
|
+
worker.status = "completed";
|
|
201
|
+
worker.snapshot = release;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
if (status.control?.generation !== generation || status.fence) {
|
|
205
|
+
worker.status = "superseded";
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
const resume = status.resume;
|
|
209
|
+
const outcome = await withRecoveryObservation(observation, async () => (worker.reconcile_id ? null : await prepareRuntimeScopeResume(context, scopeId, generation, resume?.generation === generation ? resume.request_id : undefined))
|
|
210
|
+
?? await controlRuntimeScopeRuns(context, scopeId, worker.request_id, worker.mode, worker.reconcile_id));
|
|
211
|
+
observation.exhausted();
|
|
212
|
+
worker.snapshot = { ...outcome, recovery_observation: observation.state() };
|
|
213
|
+
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]);
|
|
214
|
+
if (worker.snapshot.drain?.physical_settled === true) {
|
|
215
|
+
worker.status = "completed";
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
await delay(observation.nextDelay());
|
|
219
|
+
}
|
|
220
|
+
return { scope_id: scopeId, generation, status: worker.status };
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (error instanceof AppError && error.code === "runtime_scope_control_superseded") {
|
|
224
|
+
const release = runtimeBudgetStatus(context, scopeId).release;
|
|
225
|
+
worker.status = release?.current === true && release.generation === generation ? "completed" : "superseded";
|
|
226
|
+
if (worker.status === "completed")
|
|
227
|
+
worker.snapshot = release;
|
|
228
|
+
return { scope_id: scopeId, generation, status: worker.status };
|
|
229
|
+
}
|
|
230
|
+
worker.status = "failed";
|
|
231
|
+
worker.error = String(error);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
if (heartbeat)
|
|
236
|
+
clearInterval(heartbeat);
|
|
237
|
+
observation.exhausted();
|
|
238
|
+
worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
|
|
239
|
+
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]);
|
|
240
|
+
if (worker.process_id && worker.process_lease_token)
|
|
241
|
+
finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
export function prepareRuntimeService(context, input) {
|
|
11
|
+
if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0)
|
|
12
|
+
throw new AppError("validation", "Readiness timeout must be positive and finite", 2);
|
|
13
|
+
if (typeof input.command !== "string" || !input.command.trim() || !Array.isArray(input.ports) || !input.ports.length || input.ports.some(name => typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || typeof input.readyPath !== "string" || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
|
|
14
|
+
throw new AppError("validation", "Declare unique lowercase port names, a ready-port from that list and a local /ready-path", 2);
|
|
15
|
+
const project = requireProjectByRoot(context, path.resolve(input.projectRoot));
|
|
16
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
17
|
+
if (!run?.run_root || !run.workspace_root)
|
|
18
|
+
throw new AppError("not_found", "Registered RUN with a materialized workspace is required", 2);
|
|
19
|
+
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
20
|
+
return { project, run, runHome: run.run_root, generation };
|
|
21
|
+
}
|
|
22
|
+
export async function startRuntimeService(context, input) {
|
|
23
|
+
const { project, run, runHome, generation } = prepareRuntimeService(context, input);
|
|
24
|
+
input = { ...input, runId: run.id };
|
|
25
|
+
const id = `PROC-${crypto.randomUUID()}`;
|
|
26
|
+
const directory = path.join(runHome, "runtime", id);
|
|
27
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
28
|
+
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 } });
|
|
29
|
+
const receiptPath = path.join(directory, "service.json");
|
|
30
|
+
let stdout, stderr;
|
|
31
|
+
let settled = false, ready = false;
|
|
32
|
+
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") };
|
|
33
|
+
const save = () => { const temporary = `${receiptPath}.${process.pid}.tmp`; fs.writeFileSync(temporary, JSON.stringify(receipt, null, 2)); fs.renameSync(temporary, receiptPath); };
|
|
34
|
+
try {
|
|
35
|
+
const allocation = await reservePorts(context, { ownerId: record.owner_id, processId: record.id, names: input.ports });
|
|
36
|
+
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") };
|
|
37
|
+
const quoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
38
|
+
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` });
|
|
39
|
+
save();
|
|
40
|
+
input.progress?.(`service ${record.id} preparing; receipt: ${receiptPath}`);
|
|
41
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
42
|
+
stdout = fs.openSync(String(receipt.stdout_path), "a");
|
|
43
|
+
stderr = fs.openSync(String(receipt.stderr_path), "a");
|
|
44
|
+
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 }));
|
|
45
|
+
void running.then(() => { settled = true; });
|
|
46
|
+
const deadline = performance.now() + input.timeoutMs;
|
|
47
|
+
while (!settled && performance.now() < deadline) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`http://127.0.0.1:${allocation.ports[input.readyPort]}${input.readyPath}`, { signal: AbortSignal.timeout(1_000), redirect: "error" });
|
|
50
|
+
await response.body?.cancel();
|
|
51
|
+
ready = response.ok;
|
|
52
|
+
}
|
|
53
|
+
catch { /* Startup is observed until the declared deadline. */ }
|
|
54
|
+
if (ready)
|
|
55
|
+
break;
|
|
56
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
57
|
+
}
|
|
58
|
+
if (!ready) {
|
|
59
|
+
await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token });
|
|
60
|
+
await running;
|
|
61
|
+
throw new AppError("service_not_ready", "Service did not pass its declared HTTP readiness check; inspect retained logs", 2, { receipt_path: receiptPath });
|
|
62
|
+
}
|
|
63
|
+
Object.assign(receipt, { status: "ready", ready_at: context.now() });
|
|
64
|
+
save();
|
|
65
|
+
input.progress?.(`service ready: ${JSON.stringify(receipt)}; keep this supervisor running, use its exact stop command when finished`);
|
|
66
|
+
const outcome = await running;
|
|
67
|
+
const current = managedProcessStatus(context).find(item => item.id === record.id);
|
|
68
|
+
if (current.state === "stopping") {
|
|
69
|
+
const settleBy = performance.now() + 5_000;
|
|
70
|
+
while (processTreeIsAlive(current) && performance.now() < settleBy)
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
72
|
+
}
|
|
73
|
+
if (processTreeIsAlive(current))
|
|
74
|
+
throw new AppError("process_tree_not_settled", "Service still owns live children; retain resources", 2, { process_id: record.id });
|
|
75
|
+
if (!["stopped", "failed"].includes(current.state))
|
|
76
|
+
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 });
|
|
77
|
+
Object.assign(receipt, { status: managedProcessStatus(context).find(item => item.id === record.id).state, finished_at: context.now(), exit_code: outcome.exitCode });
|
|
78
|
+
save();
|
|
79
|
+
if (receipt.status === "failed")
|
|
80
|
+
throw new AppError("service_failed", "Service exited unsuccessfully; inspect retained logs", 2, receipt);
|
|
81
|
+
return receipt;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const current = managedProcessStatus(context).find(item => item.id === record.id);
|
|
85
|
+
if (!processTreeIsAlive(current))
|
|
86
|
+
finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "failed", reason: error instanceof Error ? error.message : String(error) });
|
|
87
|
+
Object.assign(receipt, { status: "failed", error: error instanceof Error ? error.message : String(error), cleanup_pending: processTreeIsAlive(current) });
|
|
88
|
+
save();
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
if (stdout !== undefined)
|
|
93
|
+
fs.closeSync(stdout);
|
|
94
|
+
if (stderr !== undefined)
|
|
95
|
+
fs.closeSync(stderr);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -94,8 +94,11 @@ 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");
|
|
98
|
-
|
|
97
|
+
const data = Object.hasOwn(options, "data") ? options.data : readJson(filePath, "input file");
|
|
98
|
+
// `data` may have come from stdin or from the prepare phase. Do not reopen
|
|
99
|
+
// `options.file` merely to infer the RUN-bound schema: apart from being a
|
|
100
|
+
// second read of caller input, `<stdin>` is not a filesystem path at all.
|
|
101
|
+
const schemaResolution = resolveSchema(options, data);
|
|
99
102
|
const schema = readJson(schemaResolution.path, "schema");
|
|
100
103
|
// Ajv's default export only carries the draft-07 meta-schema. New stage
|
|
101
104
|
// contracts may deliberately use draft 2020-12, so choose the validator
|
|
@@ -133,8 +136,8 @@ export function validateSchema(options) {
|
|
|
133
136
|
errors: []
|
|
134
137
|
};
|
|
135
138
|
}
|
|
136
|
-
function resolveSchema(options) {
|
|
137
|
-
const bound = resolveRunBoundSchema(options);
|
|
139
|
+
function resolveSchema(options, data) {
|
|
140
|
+
const bound = resolveRunBoundSchema(options, data);
|
|
138
141
|
if (bound)
|
|
139
142
|
return bound;
|
|
140
143
|
const fileNames = [`${options.schemaName}.schema.json`];
|
|
@@ -180,10 +183,9 @@ const historicalSchemaRegistry = {
|
|
|
180
183
|
checksum: "d9889af7f2c0afb47e2abab330aea3e1750879d0738d87d38d2d20e3fe0403f3"
|
|
181
184
|
}]
|
|
182
185
|
};
|
|
183
|
-
function resolveRunBoundSchema(options) {
|
|
186
|
+
function resolveRunBoundSchema(options, data) {
|
|
184
187
|
if (!options.runRoot || options.schemaDir)
|
|
185
188
|
return null;
|
|
186
|
-
const data = readJson(path.resolve(options.file), "input file");
|
|
187
189
|
const root = asRecord(data);
|
|
188
190
|
const run = root ? asRecord(objectValue(root, "run")) : undefined;
|
|
189
191
|
const inferredRunId = run ? objectValue(run, "run_id") : objectValue(root ?? {}, "run_id");
|
|
@@ -268,13 +270,13 @@ function readJson(file, label) {
|
|
|
268
270
|
text = fs.readFileSync(file, "utf8");
|
|
269
271
|
}
|
|
270
272
|
catch (error) {
|
|
271
|
-
throw new AppError("validation", `Cannot read ${label}: ${file}`, 2, { cause: String(error) });
|
|
273
|
+
throw new AppError(label === "input file" ? "validation" : "schema_registry_invalid", `Cannot read ${label}: ${file}`, label === "input file" ? 2 : 1, { cause: String(error) });
|
|
272
274
|
}
|
|
273
275
|
try {
|
|
274
276
|
return JSON.parse(text);
|
|
275
277
|
}
|
|
276
278
|
catch (error) {
|
|
277
|
-
throw new AppError("invalid_json", `Invalid JSON in ${label}: ${file}`, 2, { cause: String(error) });
|
|
279
|
+
throw new AppError(label === "input file" ? "invalid_json" : "schema_registry_invalid", `Invalid JSON in ${label}: ${file}`, label === "input file" ? 2 : 1, { cause: String(error) });
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
282
|
function formatAjvError(error) {
|