@deksden-com/dd-flow-cli 0.9.0-beta.140 → 0.9.0-beta.141
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 +7 -0
- package/dist/build-info.json +3 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +7 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +13 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +62 -12
- package/dist/harness-runtime/lib/dd-opencode.mjs +12 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/managed-daemon.d.mts +3 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +36 -7
- package/dist/harness-runtime/lib/operation-errors.d.mts +2 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +11 -0
- package/dist/services/check-runner-evidence.js +102 -20
- package/dist/services/code-checks.js +27 -15
- package/dist/services/stage-lifecycle.js +57 -13
- package/dist/services/tooling.js +14 -12
- package/dist/services/verification-matrix.js +3 -2
- package/dist/services/vnext-plan.js +1 -2
- package/dist/services/vnext-protocolize.js +121 -10
- package/dist/services/worktrees.js +8 -5
- package/dist/shared/errors.js +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.141
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 49dbfa3: Retain precise check and startup failures, validate bound browser inactivity proof,
|
|
8
|
+
and observe test progress without losing diagnostics or rollback guarantees.
|
|
9
|
+
|
|
3
10
|
## 0.9.0-beta.140
|
|
4
11
|
|
|
5
12
|
- Compare observable birth after EPERM so a foreign reused PID is not mistaken for the retained owner; unknown identity remains conservative.
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-10-
|
|
3
|
+
"cli_version": "0.9.0-beta.141",
|
|
4
|
+
"cli_commit": "c370c6e9beaefba2216bbf5bbadac02e4827768f",
|
|
5
|
+
"built_at": "2026-10-17T22:37:48.634Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.2",
|
|
8
8
|
"commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
|
|
@@ -21,7 +21,7 @@ import path from "node:path";
|
|
|
21
21
|
import { observeModel } from "./model-observations.mjs";
|
|
22
22
|
import { AGY_VERSION_TIMEOUT_MS, AgyError, agyTerminalFailure, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
|
|
23
23
|
import { settledNativeTree } from "./native-children.mjs";
|
|
24
|
-
import { prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
24
|
+
import { retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
25
25
|
import { readNativeLines, streamNativeDiagnostics, nativeObservationError, admitNativeRequestBuffer } from "./native-streams.mjs";
|
|
26
26
|
|
|
27
27
|
const REQUEST_SCHEMA = "dd-agy/daemon-request@1", RESPONSE_SCHEMA = "dd-agy/daemon-response@1", STATE_SCHEMA = "dd-agy/daemon-state@1";
|
|
@@ -677,8 +677,12 @@ export async function serveDaemon(stateDir) {
|
|
|
677
677
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
|
|
678
678
|
try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
|
|
679
679
|
catch (error) {
|
|
680
|
-
await runtime.abortProvider("daemon_start_failed");
|
|
681
|
-
|
|
680
|
+
try { await runtime.abortProvider("daemon_start_failed"); }
|
|
681
|
+
catch (cleanup) { error.details = { ...error.details, cleanup_failed: errorRecord(cleanup) }; }
|
|
682
|
+
try { if (!error.details?.cleanup_failed) await runtime.finishResource("failed", "daemon_start_failed"); }
|
|
683
|
+
catch (cleanup) { error.details = { ...error.details, resource_cleanup_failed: errorRecord(cleanup) }; }
|
|
684
|
+
Object.assign(state, { shutdown_state: error.details?.cleanup_failed || error.details?.resource_cleanup_failed ? "cleanup_failed" : "failed", active_tree: Boolean(error.details?.cleanup_failed) });
|
|
685
|
+
await retainStartupFailure({ state, publish: snapshot => runtime.persist(snapshot), log: paths.log, error });
|
|
682
686
|
await new Promise(resolve => server.close(resolve));
|
|
683
687
|
throw error;
|
|
684
688
|
}
|
|
@@ -15,7 +15,7 @@ import { spawn } from "node:child_process";
|
|
|
15
15
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
16
16
|
import { errorRecord } from "./operation-errors.mjs";
|
|
17
17
|
import { CODEX_REQUEST_TIMEOUT_MS, LATE_RESPONSE_GRACE_MS, CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
|
|
18
|
-
import { assertDaemonOwnership, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
18
|
+
import { assertDaemonOwnership, assertStartupChild, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
19
19
|
|
|
20
20
|
export function codexBridgeEnv(config, daemonId) { return { ...config.env, ...managedFlowEnv(config, daemonId) }; }
|
|
21
21
|
|
|
@@ -94,14 +94,14 @@ export async function startDaemon(options) {
|
|
|
94
94
|
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
95
95
|
await removeSocket(paths.socket); const resourceProcess = await registerDaemonProcess(config, { kind: "codex-daemon", owner: `codex:${path.basename(paths.dir)}`, operation: `codex-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log }); const state = { schema_id: stateSchema, daemon_id: randomUUID(), pid: null, cwd: config.cwd, journal: config.journal, config, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", sessions: previous?.sessions ?? [], resource_process: resourceProcess }; await writeState(paths.state, state);
|
|
96
96
|
const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [options.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
97
|
-
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id); while (!startupClock.sample(await startupProgress())) { await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 5_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed", details: observationDetails(startupClock, "daemon-start", child.pid) }); }
|
|
97
|
+
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id); while (!startupClock.sample(await startupProgress())) { assertStartupChild(child, state, await readState(paths.state), paths.log); await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 5_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed", details: observationDetails(startupClock, "daemon-start", child.pid) }); }
|
|
98
98
|
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.state), state.daemon_id); throw error; }
|
|
99
99
|
}
|
|
100
100
|
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(locations(options.stateDir).state) }); }
|
|
101
101
|
|
|
102
102
|
export async function serveDaemon(stateDir) {
|
|
103
103
|
const paths = locations(stateDir); const state = await readState(paths.state); if (!state || state.schema_id !== stateSchema) throw Object.assign(new Error("daemon state missing"), { code: "daemon_state_missing" });
|
|
104
|
-
const bridge = new CodexBridge({ ...state.config, env: { ...codexBridgeEnv(state.config, state.daemon_id), CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await startManagedBridge({ config: state.config, state, bridge, kind: "codex-provider", publish: snapshot => writeState(paths.state, snapshot) }); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
104
|
+
const bridge = new CodexBridge({ ...state.config, env: { ...codexBridgeEnv(state.config, state.daemon_id), CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await startManagedBridge({ config: state.config, state, bridge, kind: "codex-provider", publish: snapshot => writeState(paths.state, snapshot), log: paths.log }); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
105
105
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
106
106
|
const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.catch(() => {}).then(() => writeState(paths.state, snapshot)); return persist; };
|
|
107
107
|
const dispatch = async (operation, params, operationId) => {
|
|
@@ -12,7 +12,7 @@ import { durableDaemonDispatch, inspectDaemonOperation, settleDaemonOperation, c
|
|
|
12
12
|
import { ObservationClock, validateObservationDuration } from "./observation-clock.mjs";
|
|
13
13
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
14
14
|
import { errorRecord } from "./operation-errors.mjs";
|
|
15
|
-
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess } from "./managed-daemon.mjs";
|
|
15
|
+
import { retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess } from "./managed-daemon.mjs";
|
|
16
16
|
|
|
17
17
|
const REQUEST = "dd-droid/daemon-request@1", RESPONSE = "dd-droid/daemon-response@1";
|
|
18
18
|
async function removeSocket(file) { try { if (!(await lstat(file)).isSocket()) throw new DroidError("unsafe_socket", "Refusing to replace non-socket daemon path"); await unlink(file); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
@@ -123,7 +123,18 @@ export async function serveDaemon(stateDir) {
|
|
|
123
123
|
}); });
|
|
124
124
|
trackDaemonConnections(server);
|
|
125
125
|
try { await runtime.start(); scheduleDaemonProcessHeartbeat(state.config, state.resource_process); await save({ shutdown_state: "running" }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); }
|
|
126
|
-
catch (error) {
|
|
126
|
+
catch (error) {
|
|
127
|
+
let cleanup, cleanupError;
|
|
128
|
+
try { cleanup = await runtime.closeTree(true); }
|
|
129
|
+
catch (failure) { cleanupError = errorRecord(failure); error.details = { ...error.details, cleanup_failed: cleanupError }; }
|
|
130
|
+
try { await removeDroidAuth(state.config); }
|
|
131
|
+
catch (failure) { error.details = { ...error.details, auth_cleanup_failed: errorRecord(failure) }; }
|
|
132
|
+
if (!cleanupError) try { await finishDaemonProcess(state.config, state.resource_process, "failed", "startup_failed"); }
|
|
133
|
+
catch (failure) { error.details = { ...error.details, resource_cleanup_failed: errorRecord(failure) }; }
|
|
134
|
+
Object.assign(state, { shutdown_state: cleanupError || error.details?.resource_cleanup_failed || error.details?.auth_cleanup_failed ? "cleanup_failed" : "failed", ...(cleanupError ? { cleanup_error: cleanupError } : { cleanup }) });
|
|
135
|
+
await retainStartupFailure({ state, publish: save, log: paths.log, error });
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
127
138
|
await new Promise(resolve => server.once("close", resolve));
|
|
128
139
|
}
|
|
129
140
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -161,7 +161,7 @@ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 3
|
|
|
161
161
|
|
|
162
162
|
function sameConfig(state, config) { return ["cwd", "journal", "bin", "authSource", "model", "reasoning", "mode", "ddFlowBin", "ddFlowHome", "projectRoot", "sessionArchive", "resourceHome", "noFlow"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null)); }
|
|
163
163
|
export function grokStartupFailure(state) {
|
|
164
|
-
if (state?.shutdown_state
|
|
164
|
+
if (!["failed", "cleanup_failed"].includes(state?.shutdown_state) || !state.startup_error) return null;
|
|
165
165
|
const failure = state.startup_error;
|
|
166
166
|
return new DaemonError(failure.code ?? "daemon_start_failed", failure.message ?? "Grok daemon failed before becoming ready", failure.retryable === true, failure.details);
|
|
167
167
|
}
|
|
@@ -462,7 +462,7 @@ export async function serveDaemon(stateDir) {
|
|
|
462
462
|
const session = runtime.sessions.get(sessionId);
|
|
463
463
|
const rootProviderSessionId = session?.root_provider_session_id ?? sessionId;
|
|
464
464
|
await observeAcpToolCall(state.config, { daemonId: state.daemon_id, rootProviderSessionId, parentProviderSessionId: session?.parent_provider_session_id ?? null, observedProfile: bridge.modelProfiles?.get(sessionId) ?? {} }, message);
|
|
465
|
-
} });
|
|
465
|
+
} }); const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "grok-provider", publish: snapshot => writeState(paths.state, snapshot), log: paths.log }); runtime = new Runtime(paths, state, bridge, initialized);
|
|
466
466
|
for (const fact of await readModelObservations(state.config.journal)) {
|
|
467
467
|
if (fact.harness === "grok-acp" && fact.source?.channel === "x.ai/subagent_spawned" && runtime.sessions.has(fact.session_id)
|
|
468
468
|
&& (runtime.descendants.get(fact.session_id)?.attempt_id ?? null) === (fact.source.attempt_id ?? null) && fact.observed?.model) {
|
|
@@ -21,7 +21,7 @@ import { writeJsonAtomic } from "./runner-events.mjs";
|
|
|
21
21
|
import { OpenCodeClient, OpenCodeError, historyDigest, usageSnapshot, nativeModel } from "./dd-opencode.mjs";
|
|
22
22
|
import { safeProviderError } from "./provider-overload-policy.mjs";
|
|
23
23
|
import { acceptNativeHookRequest, hookBudget, readNativeHookFailures } from "./native-hook-command.mjs";
|
|
24
|
-
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
24
|
+
import { assertStartupChild, retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
25
25
|
|
|
26
26
|
const REQUEST_SCHEMA = "dd-opencode/daemon-request@1", RESPONSE_SCHEMA = "dd-opencode/daemon-response@1", STATE_SCHEMA = "dd-opencode/daemon-state@2", ARCHIVE_SCHEMA = "dd-opencode/session-archive@2";
|
|
27
27
|
class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
|
|
@@ -114,7 +114,7 @@ async function bootstrapCredentials(config, client) {
|
|
|
114
114
|
if ((entry || legacy) && !selected) throw new DaemonError("opencode_credentials_unproven", "Selected portable credential is not active in the private server");
|
|
115
115
|
return selected ? "active_private" : "absent";
|
|
116
116
|
}
|
|
117
|
-
async function importArchive(config, client) {
|
|
117
|
+
async function importArchive(config, client, onProgress = async () => {}) {
|
|
118
118
|
if (!config.sessionArchive) return null;
|
|
119
119
|
const manifest = JSON.parse(await readFile(config.sessionArchive, "utf8"));
|
|
120
120
|
if (manifest.schema_id !== ARCHIVE_SCHEMA || !manifest.payload || !/^2\./.test(manifest.opencode_version)) throw new DaemonError("invalid_session_archive", "Use the archived v1 engine for legacy OpenCode archives");
|
|
@@ -133,6 +133,7 @@ async function importArchive(config, client) {
|
|
|
133
133
|
if (info?.id !== transfer.info.id || info.location?.directory !== config.cwd || (info.parentID ?? null) !== (transfer.info.parentID ?? null))
|
|
134
134
|
throw new DaemonError("session_import_unproven", "OpenCode import did not preserve native identity/location");
|
|
135
135
|
records.push({ provider_session_id: info.id, parent_provider_session_id: info.id === root ? null : info.parentID, root_provider_session_id: root, cwd: config.cwd });
|
|
136
|
+
await onProgress(info.id);
|
|
136
137
|
}
|
|
137
138
|
return records;
|
|
138
139
|
}
|
|
@@ -169,10 +170,10 @@ export async function startDaemon(options) {
|
|
|
169
170
|
const state = { schema_id: STATE_SCHEMA, native_protocol: 2, daemon_id: daemonId, shutdown_state: "starting", active_tree: false, auth_status: config.authStatus, versions: { opencode: config.version }, config, sessions: previous?.sessions ?? [], operations: previous?.operations ?? {}, resource_process: resourceProcess }; await writeJson(paths.state, state);
|
|
170
171
|
const log = await open(paths.log, "a", 0o600), entry = absolute(options.entryPath, "dd-opencode entry"); const child = spawn(process.execPath, [entry, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
171
172
|
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id); while (!startupClock.sample(await startupProgress())) {
|
|
172
|
-
|
|
173
|
+
assertStartupChild(child, state, await readState(paths.dir), paths.log);
|
|
173
174
|
await new Promise(resolve => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; }
|
|
174
175
|
} throw Object.assign(new DaemonError("daemon_start_failed", "daemon did not become ready", true), { details: observationDetails(startupClock, 'daemon-start', child.pid) }); }
|
|
175
|
-
catch (error) { await
|
|
176
|
+
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), daemonId); throw error; }
|
|
176
177
|
}
|
|
177
178
|
export const stopDaemon = async options => { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(options.stateDir) }); };
|
|
178
179
|
|
|
@@ -358,9 +359,9 @@ export class Runtime {
|
|
|
358
359
|
}
|
|
359
360
|
if (operation === "daemon.doctor") {
|
|
360
361
|
const info = await this.client.health();
|
|
362
|
+
if (info.pid !== this.child?.pid || info.version !== this.state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode endpoint is not the owned process");
|
|
361
363
|
await this.client.call("model.list", { location: { directory: this.state.config.cwd } });
|
|
362
364
|
const plugins = await this.client.call("plugin.list", { location: { directory: this.state.config.cwd } });
|
|
363
|
-
if (info.pid !== this.child?.pid || info.version !== this.state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode endpoint is not the owned process");
|
|
364
365
|
const pluginID = this.state.config.noFlow ? "dd-flow-security" : "dd-flow";
|
|
365
366
|
if (!plugins.data.some(plugin => plugin.id === pluginID && plugin.state?.status === "active")) throw new DaemonError("opencode_plugin_missing", "Owned v2 plugin is not active");
|
|
366
367
|
return { compatible: true, native_protocol: 2, observed_runtime: this.state.versions, productive_hooks: !this.state.config.noFlow, versions: this.state.versions, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status };
|
|
@@ -459,6 +460,54 @@ export class Runtime {
|
|
|
459
460
|
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
460
461
|
}
|
|
461
462
|
}
|
|
463
|
+
|
|
464
|
+
/** Read-only readiness probes share the startup quiet window. A repeated
|
|
465
|
+
* ServiceUnavailable response is not activation progress. */
|
|
466
|
+
export async function awaitOpenCodeStartup(client, state, child, { timeoutMs = SETUP_STARTUP_MS, publish = async () => {} } = {}) {
|
|
467
|
+
const clock = new ObservationClock({ timeoutMs });
|
|
468
|
+
let phase = "server", attempt = 0; const observedPhases = new Set();
|
|
469
|
+
const mark = async value => {
|
|
470
|
+
if (state.startup_phase === value) return;
|
|
471
|
+
state.startup_phase = value; await publish(state);
|
|
472
|
+
if (!observedPhases.has(value)) { observedPhases.add(value); clock.sample(value); }
|
|
473
|
+
};
|
|
474
|
+
const remaining = () => {
|
|
475
|
+
if (clock.sample()) throw new DaemonError("daemon_start_failed", "OpenCode startup made no further progress", false, observationDetails(clock, "native-readiness", child?.pid));
|
|
476
|
+
if (child && (child.exitCode !== null || child.signalCode !== null)) throw new DaemonError("opencode_unavailable", "Owned OpenCode process exited during startup", false, { exit_code: child.exitCode, signal: child.signalCode });
|
|
477
|
+
return Math.max(1, Math.ceil(clock.timeoutMs - clock.elapsed));
|
|
478
|
+
};
|
|
479
|
+
client.readObservation = () => ({ timeoutMs: remaining() });
|
|
480
|
+
try {
|
|
481
|
+
while (true) {
|
|
482
|
+
remaining();
|
|
483
|
+
try {
|
|
484
|
+
if (phase === "server") {
|
|
485
|
+
const identity = await client.health({ timeoutMs: remaining() });
|
|
486
|
+
if (identity.pid !== child?.pid || identity.version !== state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode startup returned another server");
|
|
487
|
+
await mark("owned_server_ready"); phase = "location";
|
|
488
|
+
}
|
|
489
|
+
if (phase === "location") {
|
|
490
|
+
await client.call("model.list", { location: { directory: state.config.cwd } });
|
|
491
|
+
await mark("location_models_ready"); phase = "plugin";
|
|
492
|
+
}
|
|
493
|
+
const plugins = await client.call("plugin.list", { location: { directory: state.config.cwd } });
|
|
494
|
+
if (!Array.isArray(plugins?.data)) throw new DaemonError("opencode_configuration_invalid", "OpenCode returned invalid plugin readiness");
|
|
495
|
+
const id = state.config.noFlow ? "dd-flow-security" : "dd-flow", plugin = plugins.data.find(value => value.id === id);
|
|
496
|
+
if (["failed", "error", "missing", "disabled"].includes(plugin?.state?.status)) throw new DaemonError("opencode_plugin_failed", "Required owned plugin failed activation", false, { plugin_id: id, status: plugin.state.status });
|
|
497
|
+
if (plugin?.state?.status === "active") { await mark("required_plugin_active"); return { remaining, clock }; }
|
|
498
|
+
// A status transition of this required plugin is real new evidence.
|
|
499
|
+
if (["loading", "activating", "registered"].includes(plugin?.state?.status)) await mark(`required_plugin_${plugin.state.status}`);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
const transient = error.code === "opencode_unavailable" && error.retryable === true
|
|
502
|
+
&& (error.details?.native_type === "ServiceUnavailableError" || phase === "server" && ["TypeError", "ConnectionError", "Error"].includes(error.details?.native_type));
|
|
503
|
+
if (!transient) throw error;
|
|
504
|
+
}
|
|
505
|
+
const pause = Math.min([250, 500, 1000, 2000][Math.min(attempt++, 3)], remaining());
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, pause));
|
|
507
|
+
}
|
|
508
|
+
} catch (error) { delete client.readObservation; throw error; }
|
|
509
|
+
}
|
|
510
|
+
|
|
462
511
|
export async function serveDaemon(stateDir) {
|
|
463
512
|
const paths = locations(stateDir), state = await readState(paths.dir); if (state?.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing");
|
|
464
513
|
let child, providerProcess;
|
|
@@ -470,13 +519,14 @@ export async function serveDaemon(stateDir) {
|
|
|
470
519
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
471
520
|
scheduleDaemonProcessHeartbeat(state.config, providerProcess);
|
|
472
521
|
state.provider_process = providerProcess; state.provider_pid = child.pid; await writeJson(paths.state, state);
|
|
473
|
-
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd });
|
|
474
|
-
const
|
|
475
|
-
if (identity.pid !== child.pid || identity.version !== state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode startup returned another server");
|
|
522
|
+
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd });
|
|
523
|
+
const startup = await awaitOpenCodeStartup(client, state, child, { publish: snapshot => writeJson(paths.state, snapshot) });
|
|
476
524
|
state.auth_status = await bootstrapCredentials(state.config, client);
|
|
477
|
-
|
|
525
|
+
state.startup_phase = "credentials_ready"; await writeJson(paths.state, state); startup.clock.sample(state.startup_phase);
|
|
526
|
+
const imported = await importArchive(state.config, client, async id => { state.startup_phase = `session_imported:${id}`; await writeJson(paths.state, state); startup.clock.sample(state.startup_phase); }); if (imported) state.sessions.push(...imported);
|
|
527
|
+
startup.remaining(); delete client.readObservation;
|
|
478
528
|
await chmod(state.config.database, 0o600);
|
|
479
|
-
const runtime = new Runtime(paths, state, client, child, providerProcess); await
|
|
529
|
+
const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
|
|
480
530
|
const server = net.createServer(connection => {
|
|
481
531
|
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
482
532
|
connection.on("data", chunk => {
|
|
@@ -499,8 +549,8 @@ export async function serveDaemon(stateDir) {
|
|
|
499
549
|
trackDaemonConnections(server); runtime.server = server;
|
|
500
550
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false, server_port: port }); return await new Promise(() => {});
|
|
501
551
|
} catch (error) {
|
|
502
|
-
await
|
|
503
|
-
await
|
|
552
|
+
await cleanupFailedStart(state.config, providerProcess, child, error);
|
|
553
|
+
await retainStartupFailure({ state, publish: snapshot => writeJson(paths.state, snapshot), log: paths.log, error }); throw error;
|
|
504
554
|
}
|
|
505
555
|
}
|
|
506
556
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -51,7 +51,7 @@ export class OpenCodeClient {
|
|
|
51
51
|
async call(method, input, { timeoutMs, progress, onDispatched, productive = false, signal } = {}) {
|
|
52
52
|
const observation = this.readObservation?.();
|
|
53
53
|
timeoutMs ??= observation?.timeoutMs ?? this.timeoutMs; progress ??= observation?.progress;
|
|
54
|
-
const
|
|
54
|
+
const controller = new AbortController();
|
|
55
55
|
let timer;
|
|
56
56
|
const abort = () => { controller.abort(); clearInterval(timer); };
|
|
57
57
|
signal?.addEventListener("abort", abort, { once: true });
|
|
@@ -59,6 +59,13 @@ export class OpenCodeClient {
|
|
|
59
59
|
timer = observedTimeout(() => controller.abort(), timeoutMs, { progress });
|
|
60
60
|
if (signal?.aborted) clearInterval(timer);
|
|
61
61
|
try {
|
|
62
|
+
// Cover initialization as well as RPC. Abort still reaches the owned
|
|
63
|
+
// request; an unresolved module import cannot start a late RPC.
|
|
64
|
+
let rejectAbort;
|
|
65
|
+
const aborted = new Promise((_, reject) => { rejectAbort = () => reject(Object.assign(new Error("OpenCode probe aborted"), { name: "AbortError" })); controller.signal.addEventListener("abort", rejectAbort, { once: true }); });
|
|
66
|
+
let api;
|
|
67
|
+
try { if (controller.signal.aborted) rejectAbort(); api = await Promise.race([this.ready, aborted]); }
|
|
68
|
+
finally { controller.signal.removeEventListener("abort", rejectAbort); }
|
|
62
69
|
const keys = method.split("."), name = keys.pop(), domain = keys.reduce((value, key) => value?.[key], api);
|
|
63
70
|
if (typeof domain?.[name] !== "function") throw new OpenCodeError("opencode_capability_missing", `OpenCode v2 does not expose ${method}`);
|
|
64
71
|
const options = { signal: controller.signal };
|
|
@@ -70,13 +77,15 @@ export class OpenCodeClient {
|
|
|
70
77
|
if (cause._tag === "SessionNotFoundError" || cause._tag === "NotFoundError") throw new OpenCodeError("session_not_found", "OpenCode native Session is unavailable");
|
|
71
78
|
if (productive && ["InvalidRequestError", "ConflictError", "UnauthorizedError", "ForbiddenError"].includes(cause._tag))
|
|
72
79
|
throw new OpenCodeError("opencode_admission_rejected", "OpenCode rejected native admission", false, { effect: "no_effect", native_type: cause._tag });
|
|
80
|
+
if (!productive && ["UnauthorizedError", "ForbiddenError"].includes(cause._tag)) throw new OpenCodeError("opencode_auth_rejected", "OpenCode rejected authentication", false, { method, native_type: cause._tag });
|
|
81
|
+
if (!productive && ["InvalidRequestError", "ConflictError"].includes(cause._tag)) throw new OpenCodeError("opencode_configuration_invalid", "OpenCode rejected the diagnostic request", false, { method, native_type: cause._tag });
|
|
73
82
|
if (productive || controller.signal.aborted) throw Object.assign(lost(`OpenCode observer lost during ${method}`, { method }), { cause });
|
|
74
83
|
throw Object.assign(new OpenCodeError("opencode_unavailable", `OpenCode ${method} observation failed`, true,
|
|
75
84
|
{ method, native_type: cause._tag ?? cause.name ?? null, native_reason: cause.reason ?? null }), { cause });
|
|
76
85
|
} finally { clearInterval(timer); signal?.removeEventListener("abort", abort); }
|
|
77
86
|
}
|
|
78
|
-
async health() {
|
|
79
|
-
const info = await this.call("server.info", undefined, { timeoutMs: 5_000 });
|
|
87
|
+
async health(options = {}) {
|
|
88
|
+
const info = await this.call("server.info", undefined, { timeoutMs: 5_000, ...options });
|
|
80
89
|
if (!/^2\./.test(info?.version) || !Number.isInteger(info.pid)) throw new OpenCodeError("unsupported_version", "OpenCode v2 server identity is unavailable");
|
|
81
90
|
return info;
|
|
82
91
|
}
|
|
@@ -10,7 +10,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "
|
|
|
10
10
|
import net from "node:net";
|
|
11
11
|
import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
13
|
+
import { assertStartupChild, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
14
14
|
import { acceptNativeHookRequest, hookBudget, readNativeHookFailures } from "./native-hook-command.mjs";
|
|
15
15
|
import { parseLifecycleCommand, continuationCommand } from "./lifecycle-command.mjs";
|
|
16
16
|
import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
|
|
@@ -186,6 +186,7 @@ export async function startDaemon(options) {
|
|
|
186
186
|
await confirmDaemonProcess(config, resourceProcess, child);
|
|
187
187
|
const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id);
|
|
188
188
|
while (!startupClock.sample(await startupProgress())) {
|
|
189
|
+
assertStartupChild(child, state, await readState(paths.dir), paths.log);
|
|
189
190
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
190
191
|
try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
|
|
191
192
|
catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
@@ -704,7 +705,7 @@ export async function serveDaemon(stateDir) {
|
|
|
704
705
|
const state = await readState(paths.dir);
|
|
705
706
|
if (!state || state.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing or incompatible");
|
|
706
707
|
const bridge = new AcpBridge({ ...state.config, env: zcodeBridgeEnv(state.config, state.daemon_id) });
|
|
707
|
-
const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "zcode-provider", publish: snapshot => writeState(paths.state, snapshot) });
|
|
708
|
+
const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "zcode-provider", publish: snapshot => writeState(paths.state, snapshot), log: paths.log });
|
|
708
709
|
const runtime = new DaemonRuntime(paths, state, bridge, initialized);
|
|
709
710
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
710
711
|
await removeSocket(paths.socket, paths.dir);
|
|
@@ -11,3 +11,6 @@ export function finishDaemonProcess(config: RuntimeConfig, record: ProcessBindin
|
|
|
11
11
|
export function cleanupFailedDaemonStart(config: RuntimeConfig, record: ProcessBinding | null, child: ChildProcess | null, original: Error & { details?: Record<string, unknown> }, readState: () => Promise<Record<string, unknown>>, daemonId: string): Promise<void>;
|
|
12
12
|
export function completeBudgetOperation(stateDir: string, request: { id: string; operation: string; params?: { sessionId?: string } }, result: unknown, observe?: () => Promise<Record<string, unknown>>): Promise<Record<string, unknown>>;
|
|
13
13
|
export function stopProcessGroup(child: ChildProcess | null | undefined, graceMs?: number, options?: { detached?: boolean; settlementMs?: number }): Promise<void>;
|
|
14
|
+
export function assertStartupChild(child: Pick<ChildProcess, "pid" | "exitCode" | "signalCode">, expected: Record<string, unknown>, observed: Record<string, unknown> | null, log: string): void;
|
|
15
|
+
export function retainStartupFailure(input: { state: Record<string, unknown>; publish: (state: Record<string, unknown>) => Promise<unknown>; log: string; error: Error & { code?: string; details?: Record<string, unknown> } }): Promise<void>;
|
|
16
|
+
export function startManagedBridge(input: { config: RuntimeConfig; state: Record<string, unknown>; bridge: { child: ChildProcess | null; options: { onSpawn?: (child: ChildProcess) => Promise<void> }; start: () => Promise<unknown> }; kind: string; publish: (state: Record<string, unknown>) => Promise<unknown>; log?: string }): Promise<unknown>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { lstat, readFile, readdir, mkdir } from "node:fs/promises";
|
|
1
|
+
import { lstat, readFile, readdir, mkdir, appendFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { commandJson } from "./process-json.mjs";
|
|
5
|
-
import { errorRecord } from "./operation-errors.mjs";
|
|
5
|
+
import { errorRecord, reportedError, diagnosticJson } from "./operation-errors.mjs";
|
|
6
6
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
7
7
|
import { runBoundedCommand } from "./native-hook-command.mjs";
|
|
8
8
|
import { createRenewalState, maintenanceRetryable, renewalConfirmed, renewalDelay, renewalFailure, renewalRemaining, RENEWAL_POLICY } from "./lease-renewal.mjs";
|
|
@@ -352,11 +352,12 @@ export async function confirmDaemonProcess(config, record, child) {
|
|
|
352
352
|
|
|
353
353
|
/** Provider bridges use a different process group from their daemon. Publish
|
|
354
354
|
* the exact binding before initialize can start productive provider traffic. */
|
|
355
|
-
export async function startManagedBridge({ config, state, bridge, kind, publish }) {
|
|
356
|
-
|
|
357
|
-
await publish(state);
|
|
358
|
-
const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
355
|
+
export async function startManagedBridge({ config, state, bridge, kind, publish, log }) {
|
|
356
|
+
let provider;
|
|
359
357
|
try {
|
|
358
|
+
state.pid = process.pid;
|
|
359
|
+
await publish(state);
|
|
360
|
+
provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
360
361
|
state.provider_process = provider;
|
|
361
362
|
state.startup_phase = "owner_registered";
|
|
362
363
|
await publish(state);
|
|
@@ -372,7 +373,35 @@ export async function startManagedBridge({ config, state, bridge, kind, publish
|
|
|
372
373
|
await publish(state);
|
|
373
374
|
return initialized;
|
|
374
375
|
}
|
|
375
|
-
catch (error) {
|
|
376
|
+
catch (error) {
|
|
377
|
+
await cleanupFailedStart(config, provider, bridge.child, error);
|
|
378
|
+
state.shutdown_state = error.details?.cleanup_failed ? "cleanup_failed" : "failed";
|
|
379
|
+
await retainStartupFailure({ state, publish, log, error });
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A retained cause belongs to this startup generation, never a previous PID. */
|
|
385
|
+
export function assertStartupChild(child, expected, observed, log) {
|
|
386
|
+
const sameGeneration = observed?.daemon_id === expected.daemon_id && (observed?.generation ?? null) === (expected.generation ?? null)
|
|
387
|
+
&& (observed?.config?.runtime_owner?.owner_id ?? null) === (expected.config?.runtime_owner?.owner_id ?? null)
|
|
388
|
+
&& (observed?.config?.runtime_owner?.operation_id ?? null) === (expected.config?.runtime_owner?.operation_id ?? null);
|
|
389
|
+
if (sameGeneration && observed?.startup_error) throw reportedError(observed.startup_error, "Native startup failed");
|
|
390
|
+
if (child.exitCode !== null || child.signalCode !== null) throw Object.assign(new Error("Native daemon exited before readiness"), {
|
|
391
|
+
code: "daemon_start_failed", retryable: false, details: { daemon_id: expected.daemon_id,
|
|
392
|
+
pid: child.pid ?? null, exit_code: child.exitCode, signal: child.signalCode, daemon_log: log }
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function retainStartupFailure({ state, publish, log, error }) {
|
|
397
|
+
state.startup_error = JSON.parse(diagnosticJson(errorRecord(error)));
|
|
398
|
+
try { await publish(state); }
|
|
399
|
+
catch (publication) {
|
|
400
|
+
error.details = { ...error.details, startup_publication_error: errorRecord(publication), daemon_log: log };
|
|
401
|
+
// No credentials/config/argv: the log is only a bounded error record fallback.
|
|
402
|
+
try { if (log) await appendFile(log, `${diagnosticJson({ daemon_id: state.daemon_id, startup_error: errorRecord(error) })}\n`, { mode: 0o600 }); }
|
|
403
|
+
catch (logging) { error.details.startup_log_error = errorRecord(logging); }
|
|
404
|
+
}
|
|
376
405
|
}
|
|
377
406
|
|
|
378
407
|
export async function closeManagedBridge(config, state, bridge) {
|
|
@@ -9,6 +9,8 @@ export interface ErrorRecord {
|
|
|
9
9
|
cleanup_error?: ErrorRecord;
|
|
10
10
|
}
|
|
11
11
|
export function errorRecord(error: unknown, depth?: number): ErrorRecord;
|
|
12
|
+
export function redactDiagnostic(value: string): string;
|
|
13
|
+
export function diagnosticJson(value: unknown): string;
|
|
12
14
|
export function isObservationLoss(error: unknown): boolean;
|
|
13
15
|
export function isProvenShellCorrection(error: unknown): boolean;
|
|
14
16
|
export function reportedError(value: Record<string, unknown>, fallback: string): Error & ErrorRecord;
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// An observer losing its response does not establish that a provider Turn failed.
|
|
2
2
|
export const OPERATION_ERROR_CONTRACT_VERSION = "operation-errors@2";
|
|
3
|
+
export function redactDiagnostic(value) {
|
|
4
|
+
return value.replace(/\b(Bearer|Basic)\s+[^\s"']+/gi, "$1 <redacted>")
|
|
5
|
+
.replace(/(^|[^\w-])([\w-]+)(["']?\s*[=:]\s*["']?)([^\s"',;&}]+)/g,
|
|
6
|
+
(whole, prefix, key, separator) => /token|secret|password|api[_-]?key|authorization/i.test(key)
|
|
7
|
+
? `${prefix}${key}${separator}<redacted>` : whole)
|
|
8
|
+
.replace(/(\b[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1<redacted>@");
|
|
9
|
+
}
|
|
10
|
+
export function diagnosticJson(value) {
|
|
11
|
+
return JSON.stringify(value, (key, item) => /token|secret|password|api[_-]?key|authorization/i.test(key)
|
|
12
|
+
? "<redacted>" : typeof item === "string" ? redactDiagnostic(item) : item, 2);
|
|
13
|
+
}
|
|
3
14
|
// Only use after the native delivery/operation owner has been verified.
|
|
4
15
|
export function isProvenShellCorrection(error) {
|
|
5
16
|
const details = error?.details;
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { parse } from "shell-quote";
|
|
4
6
|
import { AppError } from "../shared/errors.js";
|
|
5
7
|
import { validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
|
|
6
8
|
function object(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
|
|
7
9
|
function boundedString(value, bytes) { return typeof value === "string" && Buffer.byteLength(value) <= bytes; }
|
|
10
|
+
const validatedBrowserProofs = new WeakSet();
|
|
11
|
+
function relativeProofPath(value) { return typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= 4096 && !path.isAbsolute(value) && !value.includes("\\") && value.split("/").every(part => part !== "" && part !== "." && part !== ".."); }
|
|
12
|
+
function proofArtifact(value) { return object(value) && relativeProofPath(value.relative_path) && typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256); }
|
|
13
|
+
function browserProofShape(proof) {
|
|
14
|
+
const attempt = proof.attempt, step = proof.step;
|
|
15
|
+
return proof.native_code === "PLAYWRIGHT_ACTION_INACTIVITY" && object(attempt) && boundedString(attempt.invocation_id, 4096) && boundedString(attempt.test_id, 4096) && Boolean(attempt.test_id)
|
|
16
|
+
&& [attempt.retry, attempt.repeat_each_index, attempt.worker_index].every(value => Number.isSafeInteger(value) && value >= 0)
|
|
17
|
+
&& object(step) && boundedString(step.id, 4096) && Boolean(step.id) && ["pw:api", "expect"].includes(String(step.category)) && step.file === proof.file && Number.isSafeInteger(step.line) && step.line > 0
|
|
18
|
+
&& proofArtifact(proof.worker_snapshot) && proofArtifact(proof.native_result);
|
|
19
|
+
}
|
|
8
20
|
export function runnerFailureValid(value) {
|
|
9
21
|
if (!object(value) || typeof value.category !== "string" || !["assertion", "tooling", "unclassified"].includes(value.category))
|
|
10
22
|
return false;
|
|
23
|
+
if (value.diagnostic_artifact !== undefined && !proofArtifact(value.diagnostic_artifact))
|
|
24
|
+
return false;
|
|
11
25
|
for (const key of ["code", "message", "file", "test_id", "native_code", "classification_reason"])
|
|
12
26
|
if (value[key] !== undefined && !boundedString(value[key], 4096))
|
|
13
27
|
return false;
|
|
@@ -20,6 +34,10 @@ export function runnerFailureValid(value) {
|
|
|
20
34
|
const world = proof.world;
|
|
21
35
|
if (world.schema_id !== "dd-tasks/test-world-ready@1" || !boundedString(world.invocation_id, 4096) || typeof world.world_id !== "string" || !/^[a-f0-9]{32}$/.test(world.world_id) || typeof world.migrations_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(world.migrations_sha256) || world.ready !== true || world.setup_completed !== true || !boundedString(world.role, 128) || !world.role)
|
|
22
36
|
return false;
|
|
37
|
+
if (proof.rule === "candidate_browser_action_inactivity" && !browserProofShape(proof))
|
|
38
|
+
return false;
|
|
39
|
+
if (!["candidate_sql_integrity", "candidate_browser_action_inactivity"].includes(String(proof.rule)))
|
|
40
|
+
return false;
|
|
23
41
|
}
|
|
24
42
|
return true;
|
|
25
43
|
}
|
|
@@ -44,12 +62,14 @@ function candidateFailure(failure, evidence, root, capturedFiles) {
|
|
|
44
62
|
if (failure.category === "assertion")
|
|
45
63
|
return failure.origin === undefined || failure.origin === "candidate" && failure.file !== undefined && owned(failure.file);
|
|
46
64
|
const proof = failure.repair_evidence;
|
|
65
|
+
if (proof?.rule === "candidate_browser_action_inactivity")
|
|
66
|
+
return validatedBrowserProofs.has(proof) && failure.category === "unclassified" && failure.phase === "test" && failure.origin === "candidate" && failure.native_code === proof.native_code && failure.file === proof.file && failure.test_id === proof.attempt.test_id && proof.attempt.invocation_id === evidence.binding.invocation_id && owned(proof.file);
|
|
47
67
|
return failure.category === "unclassified" && failure.origin === "candidate" && ["test", "setup"].includes(failure.phase ?? "") && proof?.rule === "candidate_sql_integrity" && ["23502", "23503", "23505", "23514"].includes(proof.native_code) && failure.native_code === proof.native_code && failure.file === proof.file && proof.world.invocation_id === evidence.binding.invocation_id && owned(proof.file);
|
|
48
68
|
}
|
|
49
69
|
export function runnerObservationDurations(environment) {
|
|
50
70
|
// Both observers leave the launcher its bounded physical cleanup tail.
|
|
51
71
|
return { startupMs: validateObservationDuration(validateObservationDuration(Number(environment.DD_FLOW_TEST_STARTUP_SILENCE_MS ?? 120_000)) + 10_000),
|
|
52
|
-
executionMs: validateObservationDuration(validateObservationDuration(Number(environment.DD_FLOW_TEST_EXECUTION_SILENCE_MS ?? 600_000)) +
|
|
72
|
+
executionMs: validateObservationDuration(validateObservationDuration(Number(environment.DD_FLOW_TEST_EXECUTION_SILENCE_MS ?? 600_000)) + 15_000) };
|
|
53
73
|
}
|
|
54
74
|
/** Baseline uses argv rather than a shell alias. Recognize only a literal,
|
|
55
75
|
* unique execution contract; never guess shell expansion or compound syntax. */
|
|
@@ -72,35 +92,90 @@ export function runnerContractForArgv(profile, command, args) {
|
|
|
72
92
|
throw new AppError("runner_evidence_ambiguous", "Baseline argv matches conflicting runner evidence contracts", 2, { aliases: matches.map(match => match.alias) });
|
|
73
93
|
return matches[0] ?? null;
|
|
74
94
|
}
|
|
75
|
-
/** Shared by CODE and EVAL baseline.
|
|
76
|
-
*
|
|
95
|
+
/** Shared by CODE and EVAL baseline. The sole publisher owns this structured
|
|
96
|
+
* journal; logs belong on stdout/stderr. Partial final lines await append. */
|
|
77
97
|
export function readRunnerProgress(file, invocationId, lastSequence = 0, now = Date.now()) {
|
|
78
98
|
try {
|
|
79
|
-
const
|
|
80
|
-
if (!stat.isFile() || stat.size > 1024 * 1024)
|
|
81
|
-
return [];
|
|
99
|
+
const text = readBoundedProofFile(file, 4 * 1024 * 1024).toString("utf8");
|
|
82
100
|
const accepted = [];
|
|
83
|
-
for (const line of
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
catch {
|
|
101
|
+
for (const line of text.split("\n").slice(0, -1)) {
|
|
102
|
+
if (Buffer.byteLength(line) > 16 * 1024)
|
|
103
|
+
throw new Error("Progress record exceeds 16 KiB");
|
|
104
|
+
const value = JSON.parse(line);
|
|
105
|
+
if (value && typeof value === "object" && !Array.isArray(value) && typeof value.invocation_id === "string" && value.invocation_id !== invocationId)
|
|
89
106
|
continue;
|
|
90
|
-
}
|
|
91
107
|
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema_id !== "dd-flow/check-runner-progress@1" || value.invocation_id !== invocationId
|
|
92
|
-
|| !Number.isSafeInteger(value.sequence) || value.sequence <=
|
|
108
|
+
|| !Number.isSafeInteger(value.sequence) || value.sequence <= 0 || typeof value.at !== "string" || !Number.isFinite(Date.parse(value.at))
|
|
93
109
|
|| !["startup", "execution"].includes(value.phase)
|
|
94
110
|
|| !["setup_step_completed", "run_started", "module_collected", "test_started", "test_completed", "step_completed", "run_completed"].includes(value.event))
|
|
111
|
+
throw new Error("Malformed official progress record");
|
|
112
|
+
if (value.sequence <= lastSequence || Date.parse(value.at) > now)
|
|
95
113
|
continue;
|
|
96
114
|
accepted.push(value);
|
|
97
115
|
lastSequence = value.sequence;
|
|
98
116
|
}
|
|
99
117
|
return accepted;
|
|
100
118
|
}
|
|
101
|
-
catch {
|
|
102
|
-
|
|
119
|
+
catch (cause) {
|
|
120
|
+
if (cause.code === "ENOENT")
|
|
121
|
+
return [];
|
|
122
|
+
throw new AppError("runner_progress_invalid", "Runner progress cannot be safely observed", 1, { cause: cause.code ?? cause.message });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function readBoundedProofFile(file, limit) {
|
|
126
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
127
|
+
try {
|
|
128
|
+
const stat = fs.fstatSync(fd);
|
|
129
|
+
if (!stat.isFile() || stat.size > limit)
|
|
130
|
+
throw new Error("Evidence is not a bounded regular file");
|
|
131
|
+
const buffer = Buffer.alloc(Math.min(limit + 1, stat.size + 1));
|
|
132
|
+
let length = 0;
|
|
133
|
+
while (length < buffer.length) {
|
|
134
|
+
const read = fs.readSync(fd, buffer, length, buffer.length - length, null);
|
|
135
|
+
if (!read)
|
|
136
|
+
break;
|
|
137
|
+
length += read;
|
|
138
|
+
}
|
|
139
|
+
if (length > limit || length > stat.size)
|
|
140
|
+
throw new Error("Evidence grew during observation");
|
|
141
|
+
return buffer.subarray(0, length);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
fs.closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function readProofArtifact(root, ref, limit = 256 * 1024) {
|
|
148
|
+
let current = root;
|
|
149
|
+
if (fs.lstatSync(current).isSymbolicLink())
|
|
150
|
+
throw new Error("Symlink evidence root");
|
|
151
|
+
for (const part of ref.relative_path.split("/")) {
|
|
152
|
+
current = path.join(current, part);
|
|
153
|
+
if (fs.lstatSync(current).isSymbolicLink())
|
|
154
|
+
throw new Error("Symlink proof artifact");
|
|
103
155
|
}
|
|
156
|
+
const bytes = readBoundedProofFile(current, limit);
|
|
157
|
+
if (crypto.createHash("sha256").update(bytes).digest("hex") !== ref.sha256)
|
|
158
|
+
throw new Error("Browser proof hash differs");
|
|
159
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
160
|
+
if (!object(value))
|
|
161
|
+
throw new Error("Invalid proof object");
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
function matches(actual, expected) { return object(actual) && Object.entries(expected).every(([key, value]) => actual[key] === value); }
|
|
165
|
+
function validateBrowserArtifacts(root, proof, binding) {
|
|
166
|
+
const snapshot = readProofArtifact(root, proof.worker_snapshot), native = readProofArtifact(root, proof.native_result);
|
|
167
|
+
if (!Array.isArray(native.errors) || native.errors.length < 1 || native.errors.length > 64 || native.errors.some(error => !boundedString(error, 4096) || !/^(?:Error: )?(?:[A-Za-z][\w.]*: )?Test timeout of [1-9]\d*ms exceeded\.(?:\nCall log:[\s\S]*)?$/.test(stripVTControlCharacters(String(error)))))
|
|
168
|
+
return false;
|
|
169
|
+
if (!matches(snapshot.binding, binding) || !matches(native.binding, binding) || !matches(snapshot.attempt, proof.attempt) || !matches(native.attempt, proof.attempt)
|
|
170
|
+
|| !matches(snapshot.step, proof.step) || !matches(native.step, proof.step) || !matches(snapshot.world, proof.world) || proof.world.invocation_id !== binding.invocation_id
|
|
171
|
+
|| native.status !== "timedOut" || native.phase !== "test" || native.file !== proof.file || snapshot.phase !== "body" || snapshot.observation_gap !== false || !matches(snapshot.browser, { connected: true, page_closed: false, crashed: false })
|
|
172
|
+
|| !matches(snapshot.world_health, { api: true, web: true, db: true }))
|
|
173
|
+
return false;
|
|
174
|
+
const quiet = snapshot.quiet_ms, last = Date.parse(String(snapshot.last_progress_at)), deadline = Date.parse(String(snapshot.deadline_at)), observed = Date.parse(String(snapshot.observed_at)), health = Date.parse(String(snapshot.health_observed_at)), ended = Date.parse(String(native.ended_at));
|
|
175
|
+
return Number.isSafeInteger(quiet) && quiet > 0 && [last, deadline, observed, health, ended].every(Number.isFinite) && Math.abs(deadline - last - quiet) <= 2 && observed >= last && observed <= deadline && health <= observed && health >= last && deadline - health <= Math.min(1000, quiet / 4) && ended >= deadline && ended <= Date.now() && ended - deadline <= 5000;
|
|
176
|
+
}
|
|
177
|
+
export function runnerProofArtifacts(evidence) {
|
|
178
|
+
return [...new Set((evidence?.failures ?? []).flatMap(failure => { const proof = failure.repair_evidence; return [...(failure.diagnostic_artifact ? [failure.diagnostic_artifact.relative_path] : []), ...(proof?.rule === "candidate_browser_action_inactivity" ? [proof.worker_snapshot.relative_path, proof.native_result.relative_path] : [])]; }))];
|
|
104
179
|
}
|
|
105
180
|
export function runnerContractsValid(value) {
|
|
106
181
|
if (value === undefined)
|
|
@@ -124,16 +199,23 @@ export function readRunnerEvidence(root, contract, binding) {
|
|
|
124
199
|
if (fs.lstatSync(current).isSymbolicLink())
|
|
125
200
|
return null;
|
|
126
201
|
}
|
|
127
|
-
const
|
|
128
|
-
if (!stat.isFile() || stat.size > 1024 * 1024)
|
|
129
|
-
return null;
|
|
130
|
-
const evidence = JSON.parse(fs.readFileSync(current, "utf8"));
|
|
202
|
+
const evidence = JSON.parse(readBoundedProofFile(current, 1024 * 1024).toString("utf8"));
|
|
131
203
|
if (evidence.schema_id !== contract.schema_id || !evidence.binding || Object.entries(binding).some(([key, value]) => evidence.binding[key] !== value)
|
|
132
204
|
|| typeof evidence.runner?.name !== "string" || !evidence.runner.name || typeof evidence.runner.version !== "string" || !evidence.runner.version || typeof evidence.completed !== "boolean" || !evidence.counts
|
|
133
205
|
|| ![evidence.counts.passed, evidence.counts.failed, evidence.counts.skipped].every(n => Number.isSafeInteger(n) && n >= 0)
|
|
134
206
|
|| !Array.isArray(evidence.failures) || evidence.failures.length > 64 || evidence.failures.some(f => !runnerFailureValid(f))
|
|
135
207
|
|| evidence.counts.failed > evidence.failures.length || evidence.observation_unknown !== undefined && typeof evidence.observation_unknown !== "boolean")
|
|
136
208
|
return null;
|
|
209
|
+
for (const failure of evidence.failures) {
|
|
210
|
+
if (failure.diagnostic_artifact && !matches(readProofArtifact(root, failure.diagnostic_artifact, 2 * 1024 * 1024).binding, binding))
|
|
211
|
+
return null;
|
|
212
|
+
const proof = failure.repair_evidence;
|
|
213
|
+
if (proof?.rule === "candidate_browser_action_inactivity") {
|
|
214
|
+
if (!validateBrowserArtifacts(root, proof, binding))
|
|
215
|
+
return null;
|
|
216
|
+
validatedBrowserProofs.add(proof);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
137
219
|
return evidence;
|
|
138
220
|
}
|
|
139
221
|
catch {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import { candidateFailureFiles, checkRepairDisposition, readRunnerEvidence, readRunnerProgress, runnerContractsValid, runnerEvidencePass, runnerObservationDurations } from "./check-runner-evidence.js";
|
|
2
|
+
import { candidateFailureFiles, checkRepairDisposition, readRunnerEvidence, readRunnerProgress, runnerContractsValid, runnerEvidencePass, runnerObservationDurations, runnerProofArtifacts } from "./check-runner-evidence.js";
|
|
3
3
|
export { assertSourceRepairEligible, checkRepairDisposition } from "./check-runner-evidence.js";
|
|
4
4
|
import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -622,10 +622,12 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
622
622
|
}
|
|
623
623
|
const runnerEvidence = group.runnerEvidence ? readRunnerEvidence(evidenceDir, group.runnerEvidence, { invocation_id: id, check_id: id, command: group.command, input_hash: inputHash, verification_epoch: epoch }) : undefined;
|
|
624
624
|
const physicalFailures = executionFailures({ exitCode: result.exitCode, launcherError: result.error ?? undefined, cleanupUnconfirmed: result.cleanupUnconfirmed, missing: artifacts.missing, mutations: mutationPaths, invalidRunner: Boolean(group.runnerEvidence && !runnerEvidence) });
|
|
625
|
+
if (result.failure)
|
|
626
|
+
physicalFailures.push(result.failure);
|
|
625
627
|
const candidateFiles = candidateFailureFiles(runnerEvidence, input.workspaceRoot);
|
|
626
628
|
if (group.runnerEvidence)
|
|
627
629
|
retainExecutionOutcome(evidenceDir, { id, command: group.command, input_hash: inputHash, verification_epoch: epoch }, input.workspaceRoot, physicalFailures, candidateFiles);
|
|
628
|
-
const requiredArtifacts = [...new Set([...group.requiredArtifacts, ...(group.runnerEvidence ? ["engine-outcome.json"] : [])])];
|
|
630
|
+
const requiredArtifacts = [...new Set([...group.requiredArtifacts, ...runnerProofArtifacts(runnerEvidence), ...(group.runnerEvidence ? ["engine-outcome.json"] : [])])];
|
|
629
631
|
artifacts = collectCheckArtifacts(evidenceDir, requiredArtifacts);
|
|
630
632
|
const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 && (!group.runnerEvidence || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
|
|
631
633
|
const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
|
|
@@ -714,7 +716,7 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
714
716
|
}
|
|
715
717
|
}
|
|
716
718
|
retainExecutionOutcome(evidenceDir, receipt, input.workspaceRoot, physicalFailures, candidateFiles);
|
|
717
|
-
receipt.required_artifacts = [...new Set([...(receipt.required_artifacts ?? []), "engine-outcome.json"])];
|
|
719
|
+
receipt.required_artifacts = [...new Set([...(receipt.required_artifacts ?? []), ...runnerProofArtifacts(runnerEvidence), "engine-outcome.json"])];
|
|
718
720
|
artifacts = collectCheckArtifacts(evidenceDir, receipt.required_artifacts);
|
|
719
721
|
}
|
|
720
722
|
const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete && physicalFailures.length === 0 && (!receipt?.runner_evidence_required || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
|
|
@@ -796,7 +798,7 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
796
798
|
}
|
|
797
799
|
function sourceRepairContractMatches(receipt, group, profileHash) {
|
|
798
800
|
return checkRepairDisposition(receipt) === "source_repair" && group.command === receipt.command && profileHash === receipt.profile_hash && isDeepStrictEqual(group.inputs, receipt.inputs)
|
|
799
|
-
&& isDeepStrictEqual(
|
|
801
|
+
&& isDeepStrictEqual(derivedReceiptArtifacts(group, receipt).sort(), [...receipt.required_artifacts].sort()) && isDeepStrictEqual([...group.ports].sort(), Object.keys(receipt.resources.ports).sort());
|
|
800
802
|
}
|
|
801
803
|
function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, ...(input.status === "aborted" ? { abort_reason: input.result.error } : {}), exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
|
|
802
804
|
function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
@@ -1136,7 +1138,8 @@ function explicitInputFiles(root, inputs, ignoredRoots) {
|
|
|
1136
1138
|
return [...result.entries()];
|
|
1137
1139
|
}
|
|
1138
1140
|
function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint, evidenceContext, checkEnvironment) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.reusable ? null : group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, ...(group.runnerEvidence ? { runner_evidence: group.runnerEvidence } : {}), profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot, group.environmentInputs, checkEnvironment), input_fingerprint: inputFingerprint, ...(group.environmentInputs === undefined ? {} : { environment_inputs: group.environmentInputs }), ...(evidenceContext === undefined ? {} : { evidence_context: evidenceContext }) })).digest("hex"); }
|
|
1139
|
-
function
|
|
1141
|
+
function derivedReceiptArtifacts(group, receipt) { return [...new Set([...group.requiredArtifacts, ...runnerProofArtifacts(receipt.runner_evidence), ...(receipt.required_artifacts.includes("engine-outcome.json") ? ["engine-outcome.json"] : [])])]; }
|
|
1142
|
+
function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: derivedReceiptArtifacts(group, receipt), inputs: group.inputs }; }
|
|
1140
1143
|
export function checkReceiptEvidenceValid(receipt) { return checkReceiptEvidenceValidForMode(receipt); }
|
|
1141
1144
|
function checkReceiptEvidenceValidForMode(receipt, mode) {
|
|
1142
1145
|
try {
|
|
@@ -1239,6 +1242,7 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
|
|
|
1239
1242
|
const started = Date.now();
|
|
1240
1243
|
const child = spawn("/bin/sh", ["-c", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
1241
1244
|
let error = null;
|
|
1245
|
+
let failure;
|
|
1242
1246
|
let stopping = false;
|
|
1243
1247
|
let closed = false;
|
|
1244
1248
|
let progress;
|
|
@@ -1259,6 +1263,8 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
|
|
|
1259
1263
|
return;
|
|
1260
1264
|
stopping = true;
|
|
1261
1265
|
error = cause instanceof Error ? cause.message : String(cause);
|
|
1266
|
+
if (cause instanceof AppError)
|
|
1267
|
+
failure = { code: cause.code, message: cause.message };
|
|
1262
1268
|
if (progress) {
|
|
1263
1269
|
clearInterval(progress);
|
|
1264
1270
|
progress = undefined;
|
|
@@ -1270,22 +1276,28 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
|
|
|
1270
1276
|
cleanup = stopProcessGroup(child, 5_000).catch(cause => {
|
|
1271
1277
|
cleanupUnconfirmed = true;
|
|
1272
1278
|
error = `${error}; cleanup: ${JSON.stringify(errorRecord(cause))}`;
|
|
1273
|
-
resolve({ exitCode: null, error, aborted: true, cleanupUnconfirmed });
|
|
1279
|
+
resolve({ exitCode: null, error, aborted: true, cleanupUnconfirmed, ...(failure ? { failure } : {}) });
|
|
1274
1280
|
});
|
|
1275
1281
|
};
|
|
1276
1282
|
const observationTimer = setInterval(() => {
|
|
1277
1283
|
if (stopping || closed || observation.phase?.() === "serving")
|
|
1278
1284
|
return;
|
|
1279
1285
|
if (runnerClock && runnerProgress) {
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1286
|
+
try {
|
|
1287
|
+
for (const event of readRunnerProgress(runnerProgress, environment.DD_FLOW_CHECK_INVOCATION_ID ?? "", runnerSequence)) {
|
|
1288
|
+
runnerSequence = event.sequence;
|
|
1289
|
+
if (runnerPhase !== "execution")
|
|
1290
|
+
runnerPhase = event.phase;
|
|
1291
|
+
runnerClock.sample(runnerSequence, Date.parse(event.at));
|
|
1292
|
+
}
|
|
1293
|
+
// The outer guard includes the runner's bounded cleanup tail.
|
|
1294
|
+
runnerClock.timeoutMs = runnerPhase === "execution" ? runnerExecutionMs : runnerStartupMs;
|
|
1295
|
+
if (runnerClock.sample(runnerSequence))
|
|
1296
|
+
stop(new AppError("operation_observation_lost", "Structured test runner stopped making progress", 1, { phase: runnerPhase, sequence: runnerSequence }));
|
|
1297
|
+
}
|
|
1298
|
+
catch (cause) {
|
|
1299
|
+
stop(cause);
|
|
1284
1300
|
}
|
|
1285
|
-
// The outer guard includes the runner's bounded cleanup tail.
|
|
1286
|
-
runnerClock.timeoutMs = runnerPhase === "execution" ? runnerExecutionMs : runnerStartupMs;
|
|
1287
|
-
if (runnerClock.sample(runnerSequence))
|
|
1288
|
-
stop(new AppError("operation_observation_lost", "Structured test runner stopped making progress", 1, { phase: runnerPhase, sequence: runnerSequence }));
|
|
1289
1301
|
return;
|
|
1290
1302
|
}
|
|
1291
1303
|
if (clock.sample(outputCursor))
|
|
@@ -1313,7 +1325,7 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
|
|
|
1313
1325
|
clearInterval(observationTimer);
|
|
1314
1326
|
await cleanup;
|
|
1315
1327
|
await Promise.all(flushed);
|
|
1316
|
-
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error), ...(cleanupUnconfirmed ? { cleanupUnconfirmed } : {}) });
|
|
1328
|
+
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error), ...(cleanupUnconfirmed ? { cleanupUnconfirmed } : {}), ...(failure ? { failure } : {}) });
|
|
1317
1329
|
});
|
|
1318
1330
|
if (!child.pid) {
|
|
1319
1331
|
error = "check process did not provide a PID";
|
|
@@ -13,7 +13,7 @@ import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
|
|
|
13
13
|
import { usageForRun } from "./usage.js";
|
|
14
14
|
import { boundCanonicalPlan, prepareCanonicalPlan } from "./plan-runtime.js";
|
|
15
15
|
import { prepareJsonFile, prepareJsonSchema, prepareOutputDirectory, prepareOutputFile, prepareTextFile } from "../cli/input-preparation.js";
|
|
16
|
-
import { AppError } from "../shared/errors.js";
|
|
16
|
+
import { AppError, errorRecord, processEvidence, diagnosticJson } from "../shared/errors.js";
|
|
17
17
|
import { planJsonPath, projectHome, resolveProjectRoot } from "../storage/paths.js";
|
|
18
18
|
import { resolveCanonRoot } from "./canon.js";
|
|
19
19
|
import { preflightMemoryPermissions } from "./memory-permissions.js";
|
|
@@ -295,7 +295,7 @@ export async function finishStage(context, input, prepared = prepareStageFinish(
|
|
|
295
295
|
if (coverage.jobs.missing?.length) {
|
|
296
296
|
throw new AppError("worker_jobs_incomplete", "Stage finish requires all declared worker jobs", 1, { coverage });
|
|
297
297
|
}
|
|
298
|
-
const lint = await runTargetedMemoryBankLint(projectRoot);
|
|
298
|
+
const lint = await runTargetedMemoryBankLint(projectRoot, undefined, path.join(stageRoot, "diagnostics"));
|
|
299
299
|
if (planFinish)
|
|
300
300
|
boundCanonicalPlan(context, { projectId: view.run.project_id, protocolId: view.run.subject.id, planPath: planFinish.plan_path }, planFinish.canonical);
|
|
301
301
|
const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
|
|
@@ -836,7 +836,7 @@ function workerCoverage(context, projectId, runId) {
|
|
|
836
836
|
jobs: { expected: jobs.map((job) => job.job_id), missing: missingJobs }
|
|
837
837
|
};
|
|
838
838
|
}
|
|
839
|
-
export async function runTargetedMemoryBankLint(projectRoot, selectedFiles) {
|
|
839
|
+
export async function runTargetedMemoryBankLint(projectRoot, selectedFiles, diagnosticRoot) {
|
|
840
840
|
const files = [...new Set((selectedFiles ?? gitChangedFiles(projectRoot)).filter((file) => file.startsWith(".memory-bank/") && file.toLowerCase().endsWith(".md") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
|
|
841
841
|
for (const file of files)
|
|
842
842
|
ensureWithin(projectRoot, path.resolve(projectRoot, file), "lint target");
|
|
@@ -844,27 +844,71 @@ export async function runTargetedMemoryBankLint(projectRoot, selectedFiles) {
|
|
|
844
844
|
return { ok: true, status: "not_applicable_no_changed_docs", files, finding_count: 0 };
|
|
845
845
|
}
|
|
846
846
|
const binary = process.env.MB_LINT_BIN ?? "mb-lint";
|
|
847
|
+
const fail = (code, message, details) => {
|
|
848
|
+
const error = new AppError(code, message, 1, { binary, files, ...details });
|
|
849
|
+
if (diagnosticRoot) {
|
|
850
|
+
try {
|
|
851
|
+
fs.mkdirSync(diagnosticRoot, { recursive: true });
|
|
852
|
+
const file = path.join(diagnosticRoot, `lint-${crypto.randomUUID()}.json`);
|
|
853
|
+
if (details.report) {
|
|
854
|
+
const reportFile = file.replace(/\.json$/, ".report.json"), report = diagnosticJson(details.report);
|
|
855
|
+
const reportBytes = Buffer.from(report);
|
|
856
|
+
fs.writeFileSync(reportFile, reportBytes.length <= 1024 * 1024 ? report : JSON.stringify({ truncated: true, bytes: reportBytes.length, excerpt: reportBytes.subarray(0, 128 * 1024).toString("utf8") }), { flag: "wx", mode: 0o600 });
|
|
857
|
+
error.details.report_path = reportFile;
|
|
858
|
+
}
|
|
859
|
+
delete error.details.report;
|
|
860
|
+
fs.writeFileSync(file, diagnosticJson(errorRecord(error)), { flag: "wx", mode: 0o600 });
|
|
861
|
+
error.details.diagnostic_path = file;
|
|
862
|
+
}
|
|
863
|
+
catch (capture) {
|
|
864
|
+
delete error.details.report;
|
|
865
|
+
error.details.capture_error = errorRecord(capture);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
delete error.details.report;
|
|
869
|
+
throw error;
|
|
870
|
+
};
|
|
847
871
|
let result;
|
|
848
872
|
try {
|
|
849
873
|
result = await commandResult(binary, ["--root", projectRoot, "--files", ...files, "--format", "json"], { cwd: projectRoot, startupMs: SETUP_STARTUP_MS });
|
|
850
874
|
}
|
|
851
875
|
catch (cause) {
|
|
852
|
-
const
|
|
853
|
-
|
|
876
|
+
const recorded = errorRecord(cause), unavailable = ["ENOENT", "EACCES", "ENOEXEC"].includes(recorded.code);
|
|
877
|
+
fail(unavailable ? "lint_gate_unavailable" : "lint_gate_tool_failed", "Selected-file mb-lint could not execute", { cause: recorded });
|
|
854
878
|
}
|
|
855
|
-
|
|
879
|
+
const evidence = processEvidence(result);
|
|
880
|
+
if (result.signal || result.exit_code !== 0 && result.exit_code !== 1)
|
|
881
|
+
fail("lint_gate_tool_failed", "Selected-file mb-lint did not complete normally", { process: evidence });
|
|
882
|
+
let parsed;
|
|
856
883
|
try {
|
|
857
|
-
parsed =
|
|
884
|
+
parsed = JSON.parse(result.stdout);
|
|
858
885
|
}
|
|
859
886
|
catch {
|
|
860
|
-
|
|
887
|
+
fail(result.exit_code === 0 ? "lint_gate_invalid_output" : "lint_gate_tool_failed", "Selected-file mb-lint did not return a report", { process: evidence });
|
|
861
888
|
}
|
|
862
|
-
|
|
863
|
-
|
|
889
|
+
const report = parsed;
|
|
890
|
+
const severities = { error: "errors", warning: "warnings", info: "info", suggestion: "suggestions" };
|
|
891
|
+
const matchingRoot = () => { try {
|
|
892
|
+
return typeof report?.root === "string" && fs.realpathSync(path.resolve(projectRoot, report.root)) === fs.realpathSync(projectRoot);
|
|
864
893
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
894
|
+
catch {
|
|
895
|
+
return false;
|
|
896
|
+
} };
|
|
897
|
+
const valid = report && report.tool === "mb-lint" && typeof report.version === "string" && report.version.trim()
|
|
898
|
+
&& matchingRoot()
|
|
899
|
+
&& report.memoryBank === ".memory-bank" && report.summary && Array.isArray(report.findings)
|
|
900
|
+
&& report.findings.every(finding => finding && typeof finding === "object" && typeof finding.severity === "string" && Object.hasOwn(severities, finding.severity)
|
|
901
|
+
&& [finding.ruleId, finding.file, finding.message].every(value => typeof value === "string" && value.trim()))
|
|
902
|
+
&& Object.entries(severities).every(([severity, key]) => Number.isSafeInteger(report.summary[key]) && Number(report.summary[key]) >= 0
|
|
903
|
+
&& report.summary[key] === report.findings.filter(finding => finding.severity === severity).length);
|
|
904
|
+
if (!valid)
|
|
905
|
+
fail(result.exit_code === 0 ? "lint_gate_invalid_output" : "lint_gate_tool_failed", "Selected-file mb-lint returned an invalid report", { process: evidence });
|
|
906
|
+
const errors = Number(report.summary.errors);
|
|
907
|
+
if ((errors > 0 ? 1 : 0) !== result.exit_code)
|
|
908
|
+
fail("lint_gate_invalid_output", "Selected-file mb-lint report contradicts its exit status", { process: evidence });
|
|
909
|
+
if (errors > 0)
|
|
910
|
+
fail("lint_gate_failed", "Selected-file mb-lint reported blocking findings", { process: evidence, report: parsed, summary: report.summary, finding_count: report.findings.length });
|
|
911
|
+
const findingCount = report.findings.length;
|
|
868
912
|
return { ok: true, status: "passed", binary, files, finding_count: findingCount, result: parsed };
|
|
869
913
|
}
|
|
870
914
|
function renderMarkdown(report) {
|
package/dist/services/tooling.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { commandResult, commandText } from '../harness-runtime/lib/process-json.mjs';
|
|
6
6
|
import { publishSetup, SETUP_STARTUP_MS } from '../harness-runtime/lib/setup-progress.mjs';
|
|
7
|
-
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { AppError, processEvidence } from "../shared/errors.js";
|
|
8
8
|
import { ensureDir } from "../storage/paths.js";
|
|
9
9
|
const manifestRelativePath = path.join("tools", "worktrunk-manifest.json");
|
|
10
10
|
const managedToolRootName = "wt";
|
|
@@ -206,24 +206,26 @@ function verifyCachedWorktrunk(context, manifest, platform) {
|
|
|
206
206
|
binary_sha256: actualBinaryChecksum
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
-
async function extractArchive(archive, destination, format) {
|
|
209
|
+
export async function extractArchive(archive, destination, format) {
|
|
210
210
|
if (format === "tar.xz") {
|
|
211
211
|
const listing = await commandResult("tar", ["-tJf", archive], { startupMs: SETUP_STARTUP_MS });
|
|
212
|
-
if (listing.exit_code !== 0 || listing.
|
|
213
|
-
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive
|
|
214
|
-
|
|
212
|
+
if (listing.exit_code !== 0 || listing.signal)
|
|
213
|
+
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive listing failed", 1, { process: processEvidence(listing) });
|
|
214
|
+
if (listing.stdout.split("\n").some(unsafeArchivePath))
|
|
215
|
+
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive has an unsafe file list", 1, { phase: "archive_path_validation" });
|
|
215
216
|
const extracted = await commandResult("tar", ["-xJvf", archive, "-C", destination], { startupMs: SETUP_STARTUP_MS });
|
|
216
|
-
if (extracted.exit_code !== 0)
|
|
217
|
-
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive extraction failed", 1, {
|
|
217
|
+
if (extracted.exit_code !== 0 || extracted.signal)
|
|
218
|
+
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive extraction failed", 1, { process: processEvidence(extracted) });
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
220
221
|
const listing = await commandResult("unzip", ["-Z1", archive], { startupMs: SETUP_STARTUP_MS });
|
|
221
|
-
if (listing.exit_code !== 0 || listing.
|
|
222
|
-
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive
|
|
223
|
-
|
|
222
|
+
if (listing.exit_code !== 0 || listing.signal)
|
|
223
|
+
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive listing failed", 1, { process: processEvidence(listing) });
|
|
224
|
+
if (listing.stdout.split("\n").some(unsafeArchivePath))
|
|
225
|
+
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive has an unsafe file list", 1, { phase: "archive_path_validation" });
|
|
224
226
|
const extracted = await commandResult("unzip", [archive, "-d", destination], { startupMs: SETUP_STARTUP_MS });
|
|
225
|
-
if (extracted.exit_code !== 0)
|
|
226
|
-
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive extraction failed", 1, {
|
|
227
|
+
if (extracted.exit_code !== 0 || extracted.signal)
|
|
228
|
+
throw new AppError("tool_integrity_failed", "Pinned Worktrunk archive extraction failed", 1, { process: processEvidence(extracted) });
|
|
227
229
|
}
|
|
228
230
|
function findBinary(root) {
|
|
229
231
|
const expected = process.platform === "win32" ? "wt.exe" : "wt";
|
|
@@ -52,9 +52,10 @@ export function renderVerificationMatrix(matrix) {
|
|
|
52
52
|
return [`# Verification matrix`, "", `RUN: ${escape(h.run_id)}; stage: ${escape(h.stage)} / ${escape(h.stage_attempt)}; role: ${h.role}; completeness: ${h.completeness}.`, "", "This generated view is not semantic acceptance. A retained passed check proves only its declared contour and proof limits.", "", "## Requirements", "", "| Protocol | Requirement | Statement | PLAN items / checks |", "| --- | --- | --- | --- |", ...matrix.requirements.map(row => `| ${escape(row.protocol_id)} | ${escape(row.id)} | ${escape(row.statement)} | ${escape(row.plan_items.map(item => `${item.id}: ${item.check_refs.join(", ")}`).join("; "))} |`), "", "## Acceptance criteria", "", "| Protocol | Criterion | Gate | Checks / native results | Proof limits |", "| --- | --- | --- | --- | --- |", ...matrix.criteria.map(row => `| ${escape(row.protocol_id)} | ${escape(row.criterion_id)} | ${escape(row.gate ?? "not declared")} | ${escape(row.checks.map(check => `${check.canonical_ref}: ${status(check)}`).join("; "))} | ${escape(JSON.stringify(row.declaration?.proof_limits ?? []))} |`), "", "## Policy checks", "", ...matrix.policy_checks.map(check => `- ${escape(check.canonical_ref)}: ${escape(status(check))}`), "", "## Sources", "", ...matrix.sources.map(source => `- ${escape(source.role)}: ${escape(`${source.root}:${source.path}`)} (${source.sha256})`), ""].join("\n");
|
|
53
53
|
}
|
|
54
54
|
export function verificationMatrixInstruction(metadata, runHome, task) {
|
|
55
|
+
const contract = `<verification_evidence_contract>\nCheck prerequisites must be executable in the check command or supported project policy; purpose prose and list order do not prepare an environment. A readiness gate is final verification, not implicit setup before the first CODE Work. PLAN and PLAN-REVIEW must compare claimed prerequisites with the actual launch path: a description saying migrate-before-check cannot repair a command that checks before migration. Prefer the existing self-contained project verification alias.\nBrowser checks must prove the expected surface exists and the measured set is nonempty before asserting layout geometry. An empty DOM is not evidence of no overlap. Derive navigation from the actual UI and accepted specification. Review whether the test can fail for the intended defect; a green named contour does not prove complete product semantics. Keep native test outcome separate from observation/cleanup: passed assertions with unknown cleanup are neither PASS nor a product assertion failure. Use retained native diagnostics and proof limits; generic timeouts or infrastructure errors do not authorize source repair. Do not patch the candidate product outside the assigned flow Work.\n</verification_evidence_contract>\n`;
|
|
55
56
|
if (!metadata)
|
|
56
|
-
return
|
|
57
|
-
return
|
|
57
|
+
return contract;
|
|
58
|
+
return `${contract}<verification_matrix>\nRuntime generated the technical verification matrix: ${safePath(runHome, metadata.markdown.path, false)}; structured data: ${safePath(runHome, metadata.json.path, false)}. Source fingerprint: ${metadata.source_fingerprint}; completeness: ${metadata.completeness}. Read it before ${task}. This is a derived view of the named accepted requirements, PLAN and receipts, not semantic acceptance. Do not edit these generated files or manually duplicate receipt IDs/hashes/results. Record insufficient proof or violated criteria in the ordinary Stage/Work result with evidence references; modify only your assigned source/test/result documents. Review inputs remain read-only. A passed check proves only its named contour and limits, not all business semantics or production readiness.\n</verification_matrix>\n`;
|
|
58
59
|
}
|
|
59
60
|
/** Attachment and packet authority share a short transaction, then both files
|
|
60
61
|
* must be visible before the prompt claims they are ready. */
|
|
@@ -120,8 +120,7 @@ export function startVnextPlan(context, input, prepared = prepareVnextPlanStart(
|
|
|
120
120
|
: `PLAN-REVIEW mode is ${planReviewMode}: include review groups only for genuinely applicable aspects; an empty set is allowed.`;
|
|
121
121
|
fs.writeFileSync(promptPath, `${prompt}\n<review_mode_rule>${reviewModeRule}</review_mode_rule>\n<verification_rule>Every flow check must be an autonomous executable command. Do not declare external/manual proof or a human review as a check, evidence substitute, DEF, or gate. The only user interaction supported by this flow is an explicit stage pause for a material unanswered question.</verification_rule>\n${stageLifecycleInstruction}\n`);
|
|
122
122
|
const externalContext = applyExternalStageContext({ context, stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
123
|
-
|
|
124
|
-
fs.appendFileSync(promptPath, verificationMatrixInstruction(prepared.matrix.metadata, home, "designing the source PLAN and its tests"));
|
|
123
|
+
fs.appendFileSync(promptPath, verificationMatrixInstruction(prepared.matrix?.metadata ?? null, home, "designing the source PLAN and its tests"));
|
|
125
124
|
fs.writeFileSync(path.join(root, "work-context.json"), JSON.stringify({ schema_id: "dd-flow/work-context@1", system: { run_id: run.id, work_id: planWorkId, stage: "plan" }, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root }, artifacts: artifactMaterialization, input: { protocols, owned_obligations: Object.fromEntries(owned) } }, null, 2));
|
|
126
125
|
const binding = bindStageCoordinatorWork(context, { workId: planWorkId, hookEventId: input.hookEventId, stage: "plan", promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
127
126
|
const attachmentInput = { projectRoot, runId: run.id, stage: "plan", dir: "03-plan", status: "running", dataSchemaId: "dd-flow/protocol-plan@6" };
|
|
@@ -5,7 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { parse as parseYaml } from "yaml";
|
|
8
|
-
import { AppError } from "../shared/errors.js";
|
|
8
|
+
import { AppError, errorRecord, redactDiagnostic, diagnosticJson } from "../shared/errors.js";
|
|
9
9
|
import { projectFeatureWorktreePath, resolveProjectRoot } from "../storage/paths.js";
|
|
10
10
|
import { previewNextEntityId } from "./ids.js";
|
|
11
11
|
import { registerProtocol } from "./protocols.js";
|
|
@@ -291,6 +291,7 @@ async function prepareProtocolizeIntent(context, run, resultFile, result, obliga
|
|
|
291
291
|
const protocolIds = allocateProtocolIds(context, run.project_id, projectRoot, result.delivery.members.map(member => member.slug));
|
|
292
292
|
const psetId = result.delivery.kind === "protocol_set" ? allocatePsetId(context, run.project_id, projectRoot, result.delivery.slug) : null;
|
|
293
293
|
const draft = fs.mkdtempSync(path.join(os.tmpdir(), "dd-flow-protocolize-"));
|
|
294
|
+
let draftFailure;
|
|
294
295
|
try {
|
|
295
296
|
// A draft must never follow a workspace symlink back into the live tree.
|
|
296
297
|
fs.cpSync(path.join(projectRoot, ".memory-bank"), path.join(draft, ".memory-bank"), { recursive: true, filter: source => !fs.lstatSync(source).isSymbolicLink() });
|
|
@@ -321,9 +322,24 @@ async function prepareProtocolizeIntent(context, run, resultFile, result, obliga
|
|
|
321
322
|
return candidate;
|
|
322
323
|
});
|
|
323
324
|
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
draftFailure = error;
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
324
329
|
finally {
|
|
330
|
+
cleanupProtocolizeDraft(draft, draftFailure);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function cleanupProtocolizeDraft(draft, failure) {
|
|
334
|
+
try {
|
|
325
335
|
fs.rmSync(draft, { recursive: true, force: true });
|
|
326
336
|
}
|
|
337
|
+
catch (cleanup) {
|
|
338
|
+
if (!failure)
|
|
339
|
+
throw new AppError("protocolize_draft_cleanup_failed", "PROTOCOLIZE draft cleanup failed", 1, { cause: errorRecord(cleanup), draft_path: draft, recoverable: false, effect: "unknown" });
|
|
340
|
+
const primary = failure;
|
|
341
|
+
primary.details = { ...primary.details, draft_cleanup_error: errorRecord(cleanup), draft_path: draft, recoverable: false };
|
|
342
|
+
}
|
|
327
343
|
}
|
|
328
344
|
function readProtocolizeIntent(context, projectId, runId, attempt) {
|
|
329
345
|
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.protocolize_intent' ORDER BY id DESC", [projectId]);
|
|
@@ -465,20 +481,115 @@ async function materialize(projectRoot, runId, runHome, result, obligations, ids
|
|
|
465
481
|
const absolute = path.resolve(projectRoot, file);
|
|
466
482
|
return created.some((directory) => absolute.startsWith(`${directory}${path.sep}`)) || absolute === psetPath;
|
|
467
483
|
});
|
|
468
|
-
await runTargetedMemoryBankLint(projectRoot, materializedFiles);
|
|
484
|
+
await runTargetedMemoryBankLint(projectRoot, materializedFiles, path.join(runHome, "diagnostics"));
|
|
469
485
|
return { files, before: Object.fromEntries([...originals].map(([file, bytes]) => [path.relative(projectRoot, file), bytes === null ? null : sha256(bytes)])) };
|
|
470
486
|
}
|
|
471
487
|
catch (error) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
fs.writeFileSync(file, content);
|
|
488
|
+
const primary = error instanceof Error ? error : new AppError("protocolize_materialization_failed", String(error));
|
|
489
|
+
const facts = {};
|
|
490
|
+
try {
|
|
491
|
+
facts.diagnostic_path = captureMaterializationFailure(projectRoot, runHome, [...changed].sort(), primary);
|
|
477
492
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
493
|
+
catch (capture) {
|
|
494
|
+
facts.capture_error = errorRecord(capture);
|
|
495
|
+
}
|
|
496
|
+
const rollback = rollbackMaterialization(projectRoot, originals, created);
|
|
497
|
+
const target = primary;
|
|
498
|
+
target.details = { ...target.details, ...facts, rollback,
|
|
499
|
+
...(rollback.unrestored.length ? { effect: "unknown", recoverable: false, disposition: "tooling_blocker" } : {}) };
|
|
500
|
+
throw primary;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
export function rollbackMaterialization(projectRoot, originals, created) {
|
|
504
|
+
const restored = [], unrestored = [];
|
|
505
|
+
const restore = (file, action) => {
|
|
506
|
+
try {
|
|
507
|
+
action();
|
|
508
|
+
restored.push(path.relative(projectRoot, file));
|
|
509
|
+
}
|
|
510
|
+
catch (cause) {
|
|
511
|
+
unrestored.push({ path: path.relative(projectRoot, file), error: errorRecord(cause) });
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
for (const [file, content] of originals)
|
|
515
|
+
restore(file, () => { if (content === null)
|
|
516
|
+
fs.rmSync(file, { force: true });
|
|
517
|
+
else
|
|
518
|
+
fs.writeFileSync(file, content); });
|
|
519
|
+
for (const dir of [...created].reverse())
|
|
520
|
+
restore(dir, () => fs.rmSync(dir, { recursive: true, force: true }));
|
|
521
|
+
return { restored, unrestored };
|
|
522
|
+
}
|
|
523
|
+
export function captureMaterializationFailure(projectRoot, runHome, files, error) {
|
|
524
|
+
const directory = path.join(runHome, "diagnostics", `protocolize-${crypto.randomUUID()}`);
|
|
525
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
526
|
+
let remaining = 1024 * 1024;
|
|
527
|
+
const documents = files.slice(0, 64).map(relative => {
|
|
528
|
+
const absolute = path.resolve(projectRoot, relative);
|
|
529
|
+
if (!absolute.startsWith(`${path.resolve(projectRoot)}${path.sep}`))
|
|
530
|
+
return { path: relative, unavailable: "outside_workspace" };
|
|
531
|
+
try {
|
|
532
|
+
assertNoSymlinkPath(path.resolve(projectRoot), absolute);
|
|
533
|
+
inside(fs.realpathSync(projectRoot), fs.realpathSync(absolute));
|
|
534
|
+
const info = fs.lstatSync(absolute);
|
|
535
|
+
if (!info.isFile())
|
|
536
|
+
return { path: relative, unavailable: "not_regular_file" };
|
|
537
|
+
// ponytail: bounded diagnostic hash, never chase a growing live file.
|
|
538
|
+
// Larger inputs retain a prefix hash explicitly marked incomplete.
|
|
539
|
+
const hash = crypto.createHash("sha256"), fd = fs.openSync(absolute, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
540
|
+
const chunk = Buffer.alloc(64 * 1024), parts = [];
|
|
541
|
+
let kept = 0, offset = 0, complete = false;
|
|
542
|
+
try {
|
|
543
|
+
const opened = fs.fstatSync(fd);
|
|
544
|
+
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino)
|
|
545
|
+
throw new AppError("diagnostic_input_changed", "Diagnostic input changed before capture");
|
|
546
|
+
const limit = Math.min(opened.size, 1024 * 1024);
|
|
547
|
+
while (offset < limit) {
|
|
548
|
+
const read = fs.readSync(fd, chunk, 0, Math.min(chunk.length, limit - offset), offset);
|
|
549
|
+
if (!read)
|
|
550
|
+
break;
|
|
551
|
+
offset += read;
|
|
552
|
+
hash.update(chunk.subarray(0, read));
|
|
553
|
+
if (kept < Math.min(remaining, 65536)) {
|
|
554
|
+
const part = Buffer.from(chunk.subarray(0, Math.min(read, Math.min(remaining, 65536) - kept)));
|
|
555
|
+
parts.push(part);
|
|
556
|
+
kept += part.length;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const after = fs.fstatSync(fd), current = fs.lstatSync(absolute);
|
|
560
|
+
complete = offset === opened.size && after.size === opened.size && after.mtimeMs === opened.mtimeMs && after.ctimeMs === opened.ctimeMs
|
|
561
|
+
&& current.dev === opened.dev && current.ino === opened.ino;
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
fs.closeSync(fd);
|
|
565
|
+
}
|
|
566
|
+
const content = Buffer.from(redactDiagnostic(Buffer.concat(parts).toString("utf8"))).subarray(0, Math.min(remaining, 65536)).toString("utf8");
|
|
567
|
+
remaining -= Buffer.byteLength(content);
|
|
568
|
+
const digest = hash.digest("hex");
|
|
569
|
+
return { path: relative, sha256: complete ? digest : null, ...(!complete ? { prefix_sha256: digest, hashed_bytes: offset } : {}), hash_complete: complete, size: info.size, truncated: kept < info.size, content };
|
|
570
|
+
}
|
|
571
|
+
catch (cause) {
|
|
572
|
+
return { path: relative, unavailable: errorRecord(cause) };
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
const file = path.join(directory, "failure.json");
|
|
576
|
+
const capture = { schema_id: "dd-flow/protocolize-failure@1", error: errorRecord(error), documents, omitted_documents: Math.max(0, files.length - documents.length) };
|
|
577
|
+
let bytes = diagnosticJson(capture);
|
|
578
|
+
while (Buffer.byteLength(bytes) > 1024 * 1024) {
|
|
579
|
+
const contentLength = (document) => "content" in document ? String(document.content ?? "").length : 0;
|
|
580
|
+
const largest = documents.filter(document => contentLength(document) > 0).sort((a, b) => contentLength(b) - contentLength(a))[0];
|
|
581
|
+
if (largest && "content" in largest) {
|
|
582
|
+
largest.content = String(largest.content).slice(0, Math.floor(String(largest.content).length / 2));
|
|
583
|
+
largest.truncated = true;
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
documents.pop();
|
|
587
|
+
capture.omitted_documents++;
|
|
588
|
+
}
|
|
589
|
+
bytes = diagnosticJson(capture);
|
|
481
590
|
}
|
|
591
|
+
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
592
|
+
return file;
|
|
482
593
|
}
|
|
483
594
|
function protocolMarkdown(id, member, blockedBy, runId, psetPath, featurePath, result, obligations, rawIntakePath) {
|
|
484
595
|
const a = member.primary_acceptance;
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { flowContractForState } from "../domain/flow-contract.js";
|
|
5
5
|
import { requireStage } from "../domain/validation.js";
|
|
6
|
-
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { AppError, processEvidence, redactDiagnostic } from "../shared/errors.js";
|
|
7
7
|
import { canonicalPath, projectCheckoutRoot, resolveProjectRoot } from "../storage/paths.js";
|
|
8
8
|
import { appendAudit } from "./audit.js";
|
|
9
9
|
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
@@ -259,20 +259,23 @@ function activeWorktreeRecord(context, projectId, protocolId) {
|
|
|
259
259
|
async function runExternal(command) {
|
|
260
260
|
const [bin, ...args] = command;
|
|
261
261
|
if (!bin) {
|
|
262
|
-
return { exit_code: null, stdout: "", stderr: "Missing executable." };
|
|
262
|
+
return { exit_code: null, signal: null, stdout: "", stderr: "Missing executable." };
|
|
263
263
|
}
|
|
264
264
|
const result = await commandResult(bin, args, { startupMs: SETUP_STARTUP_MS });
|
|
265
|
+
const evidence = processEvidence(result);
|
|
265
266
|
return {
|
|
266
267
|
exit_code: result.exit_code,
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
signal: result.signal,
|
|
269
|
+
stdout: result.exit_code === 0 && !result.signal ? sanitizeOutput(result.stdout) : evidence.stdout.text,
|
|
270
|
+
stderr: evidence.stderr.text,
|
|
271
|
+
output_truncated: { stdout: result.exit_code !== 0 && evidence.stdout.truncated, stderr: evidence.stderr.truncated }
|
|
269
272
|
};
|
|
270
273
|
}
|
|
271
274
|
function sanitizeCommand(command) {
|
|
272
275
|
return command.map((part) => (part.includes("=") ? part.replace(/=.*/, "=<redacted>") : part));
|
|
273
276
|
}
|
|
274
277
|
function sanitizeOutput(value) {
|
|
275
|
-
return value
|
|
278
|
+
return redactDiagnostic(value);
|
|
276
279
|
}
|
|
277
280
|
function parseWorktrunkResponse(stdout, command) {
|
|
278
281
|
let parsed;
|
package/dist/shared/errors.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { errorRecord as nativeErrorRecord } from "../harness-runtime/lib/operation-errors.mjs";
|
|
1
|
+
import { errorRecord as nativeErrorRecord, redactDiagnostic } from "../harness-runtime/lib/operation-errors.mjs";
|
|
2
|
+
export { redactDiagnostic, diagnosticJson } from "../harness-runtime/lib/operation-errors.mjs";
|
|
2
3
|
export class AppError extends Error {
|
|
3
4
|
code;
|
|
4
5
|
exitCode;
|
|
@@ -29,3 +30,12 @@ export function errorRecord(error, depth = 0) {
|
|
|
29
30
|
export function isAppError(error) {
|
|
30
31
|
return error instanceof AppError;
|
|
31
32
|
}
|
|
33
|
+
/** Bounded process diagnostics are observations, never command/retry authority. */
|
|
34
|
+
export function processEvidence(result) {
|
|
35
|
+
const excerpt = (value, limit) => {
|
|
36
|
+
const safe = redactDiagnostic(value), bytes = Buffer.from(safe);
|
|
37
|
+
return { text: new TextDecoder().decode(bytes.subarray(0, limit), { stream: true }), truncated: bytes.length > limit, bytes: bytes.length };
|
|
38
|
+
};
|
|
39
|
+
return { exit_code: result.exit_code, signal: result.signal ?? null,
|
|
40
|
+
stdout: excerpt(result.stdout, 8192), stderr: excerpt(result.stderr, 16384) };
|
|
41
|
+
}
|