@deksden-com/dd-flow-cli 0.9.0-beta.136 → 0.9.0-beta.137
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 +8 -0
- package/dist/build-info.json +3 -3
- package/dist/harness-runtime/DD-ZCODE.md +13 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +14 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +36 -1
- package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
- package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -1
- package/dist/services/controller-child-observer.js +99 -0
- package/dist/services/controller-fanout.js +9 -2
- package/dist/services/run-controller.js +83 -8
- package/dist/services/stage-lifecycle.js +2 -1
- package/dist/services/stage-report-renderer.js +13 -0
- package/dist/services/vnext-fanout.js +66 -1
- package/dist/services/vnext-protocolize.js +60 -2
- package/package.json +1 -1
- package/dist/cli/command-metadata.js +0 -144
- package/dist/cli/command-option-descriptions.js +0 -186
- package/dist/cli/completion-cli.js +0 -70
- package/dist/cli/completion.js +0 -57
- package/dist/cli/error-output.js +0 -52
- package/dist/cli/executor-onboarding.js +0 -71
- package/dist/cli/harnesses.js +0 -84
- package/dist/cli/models.js +0 -39
- package/dist/cli/operator-profiles.js +0 -61
- package/dist/cli/output-options.js +0 -35
- package/dist/cli/response-file.js +0 -69
- package/dist/cli/setup.js +0 -147
- package/dist/services/cli-upgrade-allowlist.js +0 -27
- package/dist/services/discovery/agy-discovery.js +0 -2
- package/dist/services/discovery/codex-discovery.js +0 -2
- package/dist/services/discovery/droid-discovery.js +0 -2
- package/dist/services/discovery/grok-discovery.js +0 -2
- package/dist/services/discovery/opencode-discovery.js +0 -2
- package/dist/services/discovery/static.js +0 -31
- package/dist/services/discovery/zcode-discovery.js +0 -2
- package/dist/services/executor-onboarding.js +0 -56
- package/dist/services/harness-discovery.js +0 -29
- package/dist/services/harness-doctor.js +0 -75
- package/dist/services/harness-probe-effects.js +0 -51
- package/dist/services/harness-version-probe.js +0 -100
- package/dist/services/hybrid-contracts.js +0 -455
- package/dist/services/installation-lock.js +0 -92
- package/dist/services/model-catalog.js +0 -109
- package/dist/services/model-discovery.js +0 -46
- package/dist/services/operator-profiles.js +0 -236
- package/dist/services/release-archive-producer.js +0 -52
- package/dist/services/release-archive.js +0 -143
- package/dist/services/release-cache.js +0 -142
- package/dist/services/release-compatibility.js +0 -18
- package/dist/services/release-download.js +0 -67
- package/dist/services/release-envelope.js +0 -104
- package/dist/services/release-manifest.js +0 -45
- package/dist/services/release-payload.js +0 -114
- package/dist/services/release-producer.js +0 -63
- package/dist/services/release-provenance.js +0 -160
- package/dist/services/release-registry.js +0 -66
- package/dist/services/release-source.js +0 -194
- package/dist/services/setup.js +0 -377
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.137
|
|
4
|
+
|
|
5
|
+
- Preserve shared reasoning/mode options in OpenCode v2 and support non-generative doctor before daemon startup.
|
|
6
|
+
|
|
7
|
+
### Patch Changes
|
|
8
|
+
|
|
9
|
+
- e65a615: Retain scoped native child failures through read-only ZCode observation and durable dispatch fences. Bind failure provenance to current Work/Session/controller identities. Publish materialized PROTOCOLIZE evidence separately from authored input, and replay completed stages without revalidating an archived worktree or regressing later stages.
|
|
10
|
+
|
|
3
11
|
## 0.9.0-beta.136
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
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.137",
|
|
4
|
+
"cli_commit": "3a1e4cb4a0a24e388228ebb85591350fa4f5886b",
|
|
5
|
+
"built_at": "2026-10-14T05:52:24.329Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.2",
|
|
8
8
|
"commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
|
|
@@ -18,6 +18,19 @@ closed transport is not proof that its native descendants stopped. The adapter
|
|
|
18
18
|
must retain causal errors, missing observations and usage completeness rather
|
|
19
19
|
than turn them into successful/zero-valued results.
|
|
20
20
|
|
|
21
|
+
## Retained child observations
|
|
22
|
+
|
|
23
|
+
Daemon capability `retained_child_observation` enables `session observe-children`
|
|
24
|
+
for its registered root. It reads retained topology and exact terminal metadata,
|
|
25
|
+
never loads/resumes a child or performs semantic work. During fanout the controller
|
|
26
|
+
records failed child facts before dispatching anything else; healthy in-flight
|
|
27
|
+
siblings may settle normally. Observation polling is not productive model activity.
|
|
28
|
+
Ordinary `session inspect` is not a substitute for this read-only operation.
|
|
29
|
+
|
|
30
|
+
Native attribution is a reported fact, not proof that an HTTP request reached a
|
|
31
|
+
provider. If the native store discarded the causal exception, retain unknown;
|
|
32
|
+
do not scrape ambient logs or infer overload/retry authority from an error message.
|
|
33
|
+
|
|
21
34
|
The bridge marks heuristic ACP completion with
|
|
22
35
|
`_meta.zcodeCompletionEvidence: "inferred"` and exhausted failed turns with
|
|
23
36
|
`"failed"`. dd-zcode rejects these before any post-turn materializing read:
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
import { adapterTimeoutOptions } from "../lib/adapter-timeouts.mjs";
|
|
3
3
|
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
|
-
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-opencode-daemon.mjs";
|
|
5
|
+
import { callDaemon, doctor, openCodeProfileOptions, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-opencode-daemon.mjs";
|
|
6
6
|
|
|
7
7
|
function parse(argv) { const positional = [], options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json","cancel-tree","no-flow"].includes(key)) options[key] = true; else { if (argv[i + 1] === undefined) throw new Error(`--${key} requires a value`); options[key] = argv[++i]; } } return { positional, options }; }
|
|
8
8
|
const prompt = async options => options["prompt-file"] ? await readFile(options["prompt-file"], "utf8") : options.prompt;
|
|
9
9
|
try {
|
|
10
|
-
const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model,
|
|
10
|
+
const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model, ...openCodeProfileOptions(options), authPath: options["auth-path"], configPath: options["config-path"], projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, sessionArchive: options["session-archive"], ...adapterTimeoutOptions(options, family === "session" ? command : null) }; let result;
|
|
11
11
|
if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...common, entryPath: process.argv[1] }, startDaemon);
|
|
12
12
|
else if (family === "daemon" && command === "serve") { await serveDaemon(common.stateDir); process.exit(0); }
|
|
13
13
|
else if (family === "daemon" && command === "status") result = await callDaemon(common.stateDir, "daemon.status");
|
|
14
14
|
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(common.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
15
15
|
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(common.stateDir, options["operation-id"]);
|
|
16
16
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(common.stateDir, options["operation-id"]);
|
|
17
|
-
else if (family === "doctor") result = await
|
|
17
|
+
else if (family === "doctor") result = await doctor(common);
|
|
18
18
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: common.stateDir, cancelTree: options["cancel-tree"], timeoutMs: common.timeoutMs });
|
|
19
19
|
else if (family === "session") { const operation = command === "status" ? "inspect" : command; const params = { livenessTimeoutMs: common.livenessTimeoutMs, sessionId: options["session-id"], title: options.title, messageId: options["message-id"], archiveFile: options["archive-file"], ...(command === "prompt" ? { prompt: await prompt(options) } : {}) }; result = await callDaemon(common.stateDir, `session.${operation}`, params, common.timeoutMs ?? 1_800_000); }
|
|
20
20
|
else throw new Error("usage: dd-opencode daemon start|status|stop | session create|prompt|inspect|fork|cancel|archive [options] --json");
|
|
@@ -22,6 +22,19 @@ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmD
|
|
|
22
22
|
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";
|
|
23
23
|
class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
|
|
24
24
|
const absolute = (value, label) => { if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`); return path.resolve(value); };
|
|
25
|
+
export function openCodeProfileOptions(options) {
|
|
26
|
+
for (const [native, shared] of [["variant", "reasoning"], ["agent", "mode"]]) {
|
|
27
|
+
if (options[native] !== undefined && options[shared] !== undefined && options[native] !== options[shared]) throw new DaemonError("profile_conflict", `--${native} and --${shared} disagree`);
|
|
28
|
+
}
|
|
29
|
+
return { variant: options.variant ?? options.reasoning, agent: options.agent ?? options.mode };
|
|
30
|
+
}
|
|
31
|
+
export async function doctor(options) {
|
|
32
|
+
if (options.stateDir) return await callDaemon(options.stateDir, "daemon.doctor");
|
|
33
|
+
const output = (await run(options.bin ?? process.env.DD_OPENCODE_BIN ?? "opencode", ["--version"], { cwd: options.cwd ?? process.cwd() })).trim();
|
|
34
|
+
const version = output.match(/(?:^|\s)v?(2\.\d+\.\d+(?:[-+][\w.-]+)?)(?:\s|$)/)?.[1];
|
|
35
|
+
if (!version) throw new DaemonError("unsupported_version", "dd-opencode supports OpenCode v2 only");
|
|
36
|
+
return { compatible: true, native_protocol: 2, observed_runtime: { opencode: version }, productive_hooks: false, profile_observation: "pending_native_session" };
|
|
37
|
+
}
|
|
25
38
|
function locations(stateDir) { const dir = absolute(stateDir, "--state-dir"), local = path.join(dir, "daemon.sock"); return { dir, socket: Buffer.byteLength(local) < 100 ? local : `/tmp/dd-opencode-${createHash("sha256").update(dir).digest("hex").slice(0, 20)}.sock`, state: path.join(dir, "daemon.json"), journal: path.join(dir, "events.jsonl"), log: path.join(dir, "daemon.log"), data: path.join(dir, "xdg-data"), config: path.join(dir, "xdg-config"), cache: path.join(dir, "xdg-cache"), archive: path.join(dir, "import.json"), db: path.join(dir, "native.db"), xdgState: path.join(dir, "xdg-state") }; }
|
|
26
39
|
const writeJson = writeJsonAtomic;
|
|
27
40
|
async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
|
|
@@ -272,7 +285,7 @@ export class Runtime {
|
|
|
272
285
|
if (info.pid !== this.child?.pid || info.version !== this.state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode endpoint is not the owned process");
|
|
273
286
|
const pluginID = this.state.config.noFlow ? "dd-flow-security" : "dd-flow";
|
|
274
287
|
if (!plugins.data.some(plugin => plugin.id === pluginID && plugin.state?.status === "active")) throw new DaemonError("opencode_plugin_missing", "Owned v2 plugin is not active");
|
|
275
|
-
return { compatible: true, native_protocol: 2, productive_hooks: !this.state.config.noFlow, versions: this.state.versions, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status };
|
|
288
|
+
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 };
|
|
276
289
|
}
|
|
277
290
|
if (operation === "session.create") return this.productive(operation, async () => {
|
|
278
291
|
const info = await this.client.create(params.title, this.state.config);
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
promptSessionWithBridge,
|
|
25
25
|
zcodeChildSessionIds,
|
|
26
26
|
observeClosedZcodeTree,
|
|
27
|
+
readZcodeSubagents,
|
|
27
28
|
} from "./dd-zcode.mjs";
|
|
28
29
|
|
|
29
30
|
const REQUEST_SCHEMA = "dd-zcode/daemon-request@1";
|
|
@@ -203,6 +204,7 @@ export async function stopDaemon(options) {
|
|
|
203
204
|
export class DaemonRuntime {
|
|
204
205
|
constructor(paths, state, bridge, initialized) {
|
|
205
206
|
this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized;
|
|
207
|
+
this.state.capabilities = { ...(this.state.capabilities ?? {}), retained_child_observation: true };
|
|
206
208
|
this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item]));
|
|
207
209
|
this.liveAdapters = new Set();
|
|
208
210
|
this.activeProductive = null; this.server = null; this.persisting = Promise.resolve();
|
|
@@ -439,13 +441,46 @@ export class DaemonRuntime {
|
|
|
439
441
|
|
|
440
442
|
async dispatch(operation, params, operationId = null) {
|
|
441
443
|
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
444
|
+
if (operation === "session.observe-children") {
|
|
445
|
+
assertDaemonDispatch(this.paths.dir);
|
|
446
|
+
const root = this.sessions.get(params.sessionId);
|
|
447
|
+
if (!root || root.close_receipt) throw new DaemonError("session_identity_mismatch", "Retained observation requires a registered open root Session");
|
|
448
|
+
const generation = this.controlGeneration ?? 0;
|
|
449
|
+
const active = this.state.active_request;
|
|
450
|
+
const activeIdentity = value => JSON.stringify([value?.operation_id ?? null, value?.session_id ?? null, value?.adapter_session_id ?? null, value?.generation ?? null]);
|
|
451
|
+
const capturedIdentity = activeIdentity(active);
|
|
452
|
+
if (active && active.session_id !== params.sessionId) throw new DaemonError("session_identity_mismatch", "Retained observation has a foreign active root");
|
|
453
|
+
const cacheScope = `${params.sessionId}:${generation}`;
|
|
454
|
+
if (this.childObservationScope !== cacheScope) { this.childObservationScope = cacheScope; this.childTerminalCache = new Map(); this.childObservationKeys = new Set(); }
|
|
455
|
+
const subagents = await readZcodeSubagents(this.bridge, root.adapter_session_id, 30_000, true, this.childTerminalCache);
|
|
456
|
+
if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "Observation generation was retired");
|
|
457
|
+
if (activeIdentity(this.state.active_request) !== capturedIdentity) throw new DaemonError("operation_cancelled", "Observation belongs to a retired active Turn");
|
|
458
|
+
assertDaemonDispatch(this.paths.dir);
|
|
459
|
+
const receipt = { schema_id: "dd-zcode/child-observation@1", daemon_id: this.state.daemon_id, provider_session_id: params.sessionId,
|
|
460
|
+
generation, active_operation_id: active?.operation_id ?? null, subagents };
|
|
461
|
+
const failed = (subagents.ended?.items ?? subagents.completed ?? []).filter(child => child.status === "failed" && child.terminal_turn_id);
|
|
462
|
+
const fresh = failed.filter(child => !this.childObservationKeys.has(`${child.childSessionId}:${child.terminal_turn_id}`));
|
|
463
|
+
if (fresh.length) {
|
|
464
|
+
await this.bridge.journal.write("native_child_observation", { ...receipt, subagents: { running: [], ended: { total: fresh.length, items: fresh } } });
|
|
465
|
+
await this.bridge.journal.flush();
|
|
466
|
+
if (this.state.config?.journal) {
|
|
467
|
+
const journal = await open(this.state.config.journal, "a");
|
|
468
|
+
try { await journal.sync(); } finally { await journal.close(); }
|
|
469
|
+
}
|
|
470
|
+
for (const child of fresh) this.childObservationKeys.add(`${child.childSessionId}:${child.terminal_turn_id}`);
|
|
471
|
+
}
|
|
472
|
+
if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "Observation generation was retired before ACK");
|
|
473
|
+
if (activeIdentity(this.state.active_request) !== capturedIdentity) throw new DaemonError("operation_cancelled", "Observation Turn changed before ACK");
|
|
474
|
+
assertDaemonDispatch(this.paths.dir);
|
|
475
|
+
return receipt;
|
|
476
|
+
}
|
|
442
477
|
if (operation === "daemon.status") return {
|
|
443
478
|
daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
|
|
444
479
|
cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
|
|
445
480
|
started_at: this.state.started_at, updated_at: this.state.updated_at,
|
|
446
481
|
shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status,
|
|
447
482
|
active_tree: this.state.active_tree, active_operation: this.activeProductive, active_request: this.state.active_request ?? null,
|
|
448
|
-
sessions: [...this.sessions.values()], config: this.state.config,
|
|
483
|
+
sessions: [...this.sessions.values()], config: this.state.config, capabilities: { retained_child_observation: true },
|
|
449
484
|
};
|
|
450
485
|
if (operation === "hook.resolve") {
|
|
451
486
|
const sessionId = String(params.sessionId ?? "");
|
|
@@ -544,7 +544,7 @@ async function resume(bridge, sessionId, cwd) {
|
|
|
544
544
|
|
|
545
545
|
/** Ended children are paginated by the native API (20 by default). A partial
|
|
546
546
|
* directory is never settlement evidence, including during teardown. */
|
|
547
|
-
export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false) {
|
|
547
|
+
export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false, terminalCache = new Map()) {
|
|
548
548
|
const clock = new ObservationClock({ timeoutMs });
|
|
549
549
|
const cursors = new Set(), ended = new Map();
|
|
550
550
|
let first, cursor;
|
|
@@ -575,6 +575,8 @@ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000,
|
|
|
575
575
|
if (child.status !== "failed" || !Number.isSafeInteger(child.startedAt) || child.startedAt <= 0
|
|
576
576
|
|| !Number.isSafeInteger(child.endedAt) || child.endedAt < child.startedAt || child.endedAt > 8.64e15) { items.push(child); continue; }
|
|
577
577
|
// Closed children must never be loaded merely to explain their failure.
|
|
578
|
+
const cacheKey = JSON.stringify([child.childSessionId, child.startedAt, child.endedAt]);
|
|
579
|
+
if (terminalCache.has(cacheKey)) { items.push(terminalCache.get(cacheKey)); continue; }
|
|
578
580
|
let terminal;
|
|
579
581
|
try { terminal = await bridge.request("zcode/session/retainedTerminal", { sessionId: child.childSessionId, startedAt: child.startedAt, endedAt: child.endedAt }, timeoutMs); }
|
|
580
582
|
catch (error) { items.push({ ...child, terminal_metadata_unavailable: error.code ?? "terminal_metadata_unavailable" }); continue; }
|
|
@@ -584,8 +586,10 @@ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000,
|
|
|
584
586
|
|| terminal.startedAt !== child.startedAt || terminal.endedAt !== child.endedAt
|
|
585
587
|
|| !Number.isFinite(observedAt) || observedAt < child.startedAt || observedAt > child.endedAt) fail("ZCode terminal failure metadata has foreign identity", "native_child_turn_conflict");
|
|
586
588
|
const providerError = safeNativeFailure(terminal.provider_error);
|
|
587
|
-
|
|
588
|
-
...(providerError ? { provider_error: providerError } : {}) }
|
|
589
|
+
const item = { ...child, terminal_turn_id: terminal.terminal_turn_id, terminal_observed_at: terminal.terminal_observed_at,
|
|
590
|
+
...(providerError ? { provider_error: providerError } : {}) };
|
|
591
|
+
terminalCache.set(cacheKey, item);
|
|
592
|
+
items.push(item);
|
|
589
593
|
clock.sample(child.childSessionId);
|
|
590
594
|
}
|
|
591
595
|
return { ...first, ended: { total: first.ended.total, items } };
|
|
@@ -29,7 +29,9 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook) {
|
|
|
29
29
|
delete event.env.OPENCODE_PASSWORD; delete event.env.OPENCODE_SERVER_PASSWORD;
|
|
30
30
|
});
|
|
31
31
|
await ctx.tool.hook("execute.before", async event => {
|
|
32
|
-
if (event.tool !== "shell"
|
|
32
|
+
if (event.tool !== "shell") return;
|
|
33
|
+
if (event.input.timeout === undefined) event.input.timeout = 0;
|
|
34
|
+
if (!binding.ddFlowBin) return;
|
|
33
35
|
const budget = hookBudget(), payload = await envelope("before", event, budget);
|
|
34
36
|
event.input = { ...event.input, workdir: payload.session.directory };
|
|
35
37
|
const result = await deliver({ ...payload, input: event.input }, budget);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
|
|
3
|
+
/** A durable observation is a dispatch fence, not authority to complete Work. */
|
|
4
|
+
export function retainedChildObserver(input) {
|
|
5
|
+
let active = true, pending;
|
|
6
|
+
let gap = null;
|
|
7
|
+
const retained = new Map();
|
|
8
|
+
const sameScope = (scope) => scope?.controller_id === input.scope.controller_id && scope.generation === input.scope.generation && scope.stage === input.scope.stage && scope.root_session_id === input.scope.root_session_id;
|
|
9
|
+
const normalized = (child) => {
|
|
10
|
+
if (!child || child.parent_session_id !== input.scope.root_session_id || child.status !== "failed" || typeof child.terminal_turn_id !== "string" || ![child.session_id, child.terminal_turn_id, child.source].every(value => typeof value === "string" && value.length > 0 && value.length <= 256))
|
|
11
|
+
throw new Error("native_child_observation_invalid");
|
|
12
|
+
const provider_error = safeNativeFailure(child.provider_error);
|
|
13
|
+
return { session_id: child.session_id, parent_session_id: child.parent_session_id, status: "failed", source: child.source, terminal_turn_id: child.terminal_turn_id,
|
|
14
|
+
...(typeof child.terminal_observed_at === "string" && Number.isFinite(Date.parse(child.terminal_observed_at)) ? { terminal_observed_at: child.terminal_observed_at } : {}),
|
|
15
|
+
...(provider_error ? { provider_error } : {}) };
|
|
16
|
+
};
|
|
17
|
+
const accept = (children) => {
|
|
18
|
+
for (const raw of children) {
|
|
19
|
+
const child = normalized(raw);
|
|
20
|
+
retained.set(`${child.session_id}:${child.terminal_turn_id}`, child);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
if (fs.existsSync(input.file))
|
|
24
|
+
for (const line of fs.readFileSync(input.file, "utf8").split("\n").filter(Boolean)) {
|
|
25
|
+
const observation = JSON.parse(line);
|
|
26
|
+
if (sameScope(observation.scope)) {
|
|
27
|
+
if (Array.isArray(observation.children))
|
|
28
|
+
accept(observation.children);
|
|
29
|
+
if (Object.hasOwn(observation, "gap"))
|
|
30
|
+
gap = observation.gap ?? null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const complete = (children) => {
|
|
34
|
+
if (!active)
|
|
35
|
+
return;
|
|
36
|
+
input.assertOwner();
|
|
37
|
+
const failed = children.filter(child => child.parent_session_id === input.scope.root_session_id && child.status === "failed" && child.terminal_turn_id && !retained.has(`${child.session_id}:${child.terminal_turn_id}`)).map(normalized);
|
|
38
|
+
if (!failed.length && !gap)
|
|
39
|
+
return;
|
|
40
|
+
const fd = fs.openSync(input.file, "a", 0o600);
|
|
41
|
+
try {
|
|
42
|
+
fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, children: failed, gap: null })}\n`);
|
|
43
|
+
fs.fsyncSync(fd);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
accept(failed);
|
|
49
|
+
gap = null;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
failed: () => [...retained.values()],
|
|
53
|
+
gap: () => gap,
|
|
54
|
+
drain: () => pending ?? Promise.resolve(),
|
|
55
|
+
completePostTurn(receipt) {
|
|
56
|
+
const evidence = receipt.evidence;
|
|
57
|
+
const tree = evidence?.subagents;
|
|
58
|
+
if (receipt.provider_session_id !== input.scope.root_session_id || receipt.observation_error || evidence?.observation_error || !evidence?.read?.projection || !evidence.read.session || !tree || tree.observation_error || !Array.isArray(tree.running))
|
|
59
|
+
return false;
|
|
60
|
+
if (tree.ended ? !Array.isArray(tree.ended.items) || tree.ended.total !== tree.ended.items.length || tree.ended.nextCursor : !Array.isArray(tree.completed))
|
|
61
|
+
return false;
|
|
62
|
+
const children = normalizeNativeChildren(receipt, input.scope.root_session_id);
|
|
63
|
+
if (children.some(child => child.status === "failed" && !child.terminal_turn_id))
|
|
64
|
+
return false;
|
|
65
|
+
complete(children);
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
tick() {
|
|
69
|
+
if (!active || pending)
|
|
70
|
+
return;
|
|
71
|
+
pending = (async () => {
|
|
72
|
+
const children = await input.read();
|
|
73
|
+
if (!active || children === null)
|
|
74
|
+
return;
|
|
75
|
+
complete(children);
|
|
76
|
+
})().catch(error => {
|
|
77
|
+
if (!active)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
input.assertOwner();
|
|
81
|
+
const code = String(error?.code ?? "observation_unavailable");
|
|
82
|
+
gap = /^[\w-]{1,128}$/.test(code) ? code : "observation_unavailable";
|
|
83
|
+
const fd = fs.openSync(input.file, "a", 0o600);
|
|
84
|
+
try {
|
|
85
|
+
fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, gap })}\n`);
|
|
86
|
+
fs.fsyncSync(fd);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
fs.closeSync(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch { /* Lost owner cannot publish; the gap remains an explicit caller error. */ }
|
|
93
|
+
input.gap(error);
|
|
94
|
+
}).finally(() => { pending = undefined; });
|
|
95
|
+
},
|
|
96
|
+
// The caller aborts its request; a late reply cannot publish after closure.
|
|
97
|
+
close() { active = false; return pending ?? Promise.resolve(); },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AppError, errorRecord } from "../shared/errors.js";
|
|
2
2
|
import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
|
|
3
|
-
import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey } from "./vnext-fanout.js";
|
|
3
|
+
import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey, verifiedNativeWorkFailure } from "./vnext-fanout.js";
|
|
4
4
|
import { getFlowRunVariables } from "./runs.js";
|
|
5
5
|
import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
|
|
6
6
|
import { resolveExecutionPolicy } from "./execution-policy.js";
|
|
@@ -54,8 +54,11 @@ export async function nextControllerFanout(context, input) {
|
|
|
54
54
|
throw new AppError("fanout_reconciliation_required", "Native children have unresolved lifecycle ownership", 1, { issues });
|
|
55
55
|
}
|
|
56
56
|
let fanout = getVnextFanoutStatus(context, input).orchestration;
|
|
57
|
-
if (!fanout)
|
|
57
|
+
if (!fanout) {
|
|
58
|
+
if (input.dispatchFence)
|
|
59
|
+
throw new AppError("fanout_reconciliation_required", "Observed native failure has no current graph; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
|
|
58
60
|
return { kind: "none" };
|
|
61
|
+
}
|
|
59
62
|
if (fanout.state === "compatibility_required")
|
|
60
63
|
throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
|
|
61
64
|
if (children.length) {
|
|
@@ -67,6 +70,8 @@ export async function nextControllerFanout(context, input) {
|
|
|
67
70
|
return { kind: "wait", reason: "native_children_unsettled" };
|
|
68
71
|
fanout = getVnextFanoutStatus(context, input).orchestration;
|
|
69
72
|
}
|
|
73
|
+
if (input.dispatchFence && !fanout.works.failed)
|
|
74
|
+
throw new AppError("fanout_reconciliation_required", "Observed native failure has no proven failed Work; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
|
|
70
75
|
if (fanout.state === "dispatch_required") {
|
|
71
76
|
dispatchVnextPlanReview(context, input);
|
|
72
77
|
fanout = getVnextFanoutStatus(context, input).orchestration;
|
|
@@ -88,7 +93,9 @@ export async function nextControllerFanout(context, input) {
|
|
|
88
93
|
}
|
|
89
94
|
catch { /* Missing legacy metadata is unknown. */ }
|
|
90
95
|
const providerError = safeNativeFailure(receipt.provider_error);
|
|
96
|
+
const nativeBinding = verifiedNativeWorkFailure(context, { projectRoot: input.projectRoot, runId: input.runId, stage: input.stage, parentSessionId: input.sessionId, parentWorkId: fanout.parent_work_id, workId: work.work_id, receipt });
|
|
91
97
|
return { work_id: work.work_id, status: work.status, ...(providerError ? { provider_error: providerError } : {}),
|
|
98
|
+
...(nativeBinding ? { native_binding: nativeBinding } : {}),
|
|
92
99
|
...Object.fromEntries(["terminal_turn_id", "terminal_observed_at"].filter(key => typeof receipt[key] === "string" && receipt[key].length <= 256).map(key => [key, receipt[key]])) };
|
|
93
100
|
});
|
|
94
101
|
throw new AppError("fanout_child_failed", "A child Work failed or was cancelled; no retry authority has been issued", 1, { stage: input.stage, run_id: input.runId, parent_work_id: fanout.parent_work_id, failed: fanout.works.failed, cancelled: fanout.works.cancelled, failures, effect: "unknown" });
|
|
@@ -31,6 +31,7 @@ import { requireRun, requireController, controllerReceipt, appendEvent, saveStat
|
|
|
31
31
|
import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
|
|
32
32
|
import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerRecoveryAllowed } from "./run-controller-process.js";
|
|
33
33
|
import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
|
|
34
|
+
import { retainedChildObserver } from "./controller-child-observer.js";
|
|
34
35
|
import { capacityDelay, continueCodexCapacity, eligibleCodexChildOverload } from "./codex-capacity.js";
|
|
35
36
|
import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
|
|
36
37
|
import { captureControllerBoundary } from "./run-controller-capture.js";
|
|
@@ -460,6 +461,52 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
460
461
|
let fatal;
|
|
461
462
|
let stopping;
|
|
462
463
|
const cancellation = new AbortController();
|
|
464
|
+
const observationCancellation = new AbortController();
|
|
465
|
+
const childObservers = new Map();
|
|
466
|
+
let promptPending = false;
|
|
467
|
+
let observedPromptOperation = null;
|
|
468
|
+
const childObserver = (session) => {
|
|
469
|
+
if (runtimeHarness(session.profile) !== "zcode-acp" || !state.stage || !["plan-review", "code", "code-review"].includes(state.stage))
|
|
470
|
+
return undefined;
|
|
471
|
+
if (readNativeDaemonState(session.state_dir).capabilities?.retained_child_observation !== true)
|
|
472
|
+
return undefined;
|
|
473
|
+
const scope = { controller_id: row.controller_id, generation: row.generation, stage: state.stage, root_session_id: session.id };
|
|
474
|
+
const key = JSON.stringify(scope);
|
|
475
|
+
let retained = childObservers.get(key);
|
|
476
|
+
if (!retained) {
|
|
477
|
+
retained = retainedChildObserver({ file: path.join(row.state_dir, "native-child-observations.jsonl"), scope, assertOwner: assertOwnership,
|
|
478
|
+
gap: error => { process.stderr.write(`${JSON.stringify({ event: "native_child_observation_gap", scope, error_code: String(error?.code ?? "observation_unavailable") })}\n`); },
|
|
479
|
+
read: async () => {
|
|
480
|
+
assertOwnership();
|
|
481
|
+
const operation = observedPromptOperation;
|
|
482
|
+
const binding = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id });
|
|
483
|
+
let receipt;
|
|
484
|
+
try {
|
|
485
|
+
receipt = await runHarnessAdapter({ executable: manifest.adapters["zcode-acp"].executable,
|
|
486
|
+
args: ["session", "observe-children", "--session-id", session.id, ...sessionArguments(context, manifest, session)],
|
|
487
|
+
env: controllerEnvironment(context, manifest, session), operationId: crypto.randomUUID(), harness: "zcode", timeoutMs: 30_000,
|
|
488
|
+
signal: AbortSignal.any([cancellation.signal, observationCancellation.signal]) });
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
if (!promptPending || observedPromptOperation !== operation)
|
|
492
|
+
return null;
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
if (!promptPending || observedPromptOperation !== operation || receipt.active_operation_id === null)
|
|
496
|
+
return null;
|
|
497
|
+
if (receipt.schema_id !== "dd-zcode/child-observation@1" || receipt.provider_session_id !== session.id || receipt.daemon_id !== binding.state.daemon_id)
|
|
498
|
+
throw new AppError("native_child_observation_conflict", "Retained child observation has foreign daemon/root identity", 1);
|
|
499
|
+
if (receipt.active_operation_id !== operation)
|
|
500
|
+
throw new AppError("native_child_observation_conflict", "Retained observation has a foreign productive operation", 1);
|
|
501
|
+
const children = controllerNativeChildren(receipt, session.id).filter(child => !(state.native_baseline ?? []).includes(child.session_id));
|
|
502
|
+
if (children.some(child => child.status === "failed" && !child.terminal_turn_id))
|
|
503
|
+
throw new AppError("operation_observation_lost", "Retained child terminal identity is unavailable", 1);
|
|
504
|
+
return children;
|
|
505
|
+
} });
|
|
506
|
+
childObservers.set(key, retained);
|
|
507
|
+
}
|
|
508
|
+
return retained;
|
|
509
|
+
};
|
|
463
510
|
const capacityUncertain = (error) => error instanceof AppError && ["capacity_continuation_blocked", "operation_observation_lost"].includes(error.code) && error.details.capacity_uncertainty === true || Boolean(state.pending_capacity) && (String(error?.code) === "capacity_continuation_blocked" || isObservationLoss(error));
|
|
464
511
|
const stopAfterFailure = (error) => {
|
|
465
512
|
// A retained successor may already be running. Fencing/recovery records
|
|
@@ -492,10 +539,15 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
492
539
|
}).finally(() => cancellation.abort());
|
|
493
540
|
return stopping;
|
|
494
541
|
};
|
|
495
|
-
const setStatus = (status) => {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
542
|
+
const setStatus = (status, terminalData) => {
|
|
543
|
+
context.db.writeTransaction(() => {
|
|
544
|
+
const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
|
|
545
|
+
if (changed.changes) {
|
|
546
|
+
appendEvent(context, row.controller_id, "status_changed", { status, stage: state.stage });
|
|
547
|
+
if (terminalData)
|
|
548
|
+
appendEvent(context, row.controller_id, status, terminalData);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
499
551
|
};
|
|
500
552
|
const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined, undefined, productive ? cancellation.signal : undefined, productive ? renewLease : undefined);
|
|
501
553
|
const confirmStopped = async (stateDir, receipt) => {
|
|
@@ -575,7 +627,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
575
627
|
throw new AppError("capacity_continuation_conflict", "Retained capacity root has no failure receipt", 1);
|
|
576
628
|
throw JSON.parse(root.error_json);
|
|
577
629
|
}
|
|
578
|
-
|
|
630
|
+
if (childObserver(session)?.failed().length)
|
|
631
|
+
throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids another coordinator dispatch", 1);
|
|
632
|
+
promptPending = true;
|
|
633
|
+
observedPromptOperation = firstOperationId;
|
|
634
|
+
try {
|
|
635
|
+
receipt = await send(text, firstOperationId);
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
promptPending = false;
|
|
639
|
+
observedPromptOperation = null;
|
|
640
|
+
}
|
|
579
641
|
}
|
|
580
642
|
catch (error) {
|
|
581
643
|
if (!["codex-desktop", "opencode-server"].includes(runtimeHarness(session.profile)))
|
|
@@ -640,6 +702,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
640
702
|
receipt = resumed;
|
|
641
703
|
}
|
|
642
704
|
checkOutcomes();
|
|
705
|
+
await childObserver(session)?.drain();
|
|
706
|
+
childObserver(session)?.completePostTurn(receipt);
|
|
707
|
+
const observationGap = childObserver(session)?.gap();
|
|
708
|
+
if (observationGap)
|
|
709
|
+
throw new AppError("operation_observation_lost", "In-flight child observation remained unavailable at the safe Turn boundary", 1, { observation_error_code: observationGap, provider_session_id: session.id, stage: state.stage });
|
|
643
710
|
if (fatal)
|
|
644
711
|
throw fatal;
|
|
645
712
|
}
|
|
@@ -661,6 +728,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
661
728
|
return;
|
|
662
729
|
try {
|
|
663
730
|
checkOutcomes();
|
|
731
|
+
if (promptPending && state.current_session !== null)
|
|
732
|
+
childObserver(state.sessions[state.current_session])?.tick();
|
|
664
733
|
}
|
|
665
734
|
catch (error) {
|
|
666
735
|
void stopAfterFailure(error);
|
|
@@ -692,15 +761,16 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
692
761
|
const next = view.continuation;
|
|
693
762
|
terminalFinalization = next.kind === "terminal";
|
|
694
763
|
const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
|
|
764
|
+
if (state.current_session !== null && (completed || next.stage !== state.stage) && childObserver(state.sessions[state.current_session])?.failed().length)
|
|
765
|
+
throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids completing or advancing its Stage", 1);
|
|
695
766
|
if (completed && manifest.request.stop_after === state.stage) {
|
|
696
767
|
// A bounded run is final evidence, not an entry fixture for its successor.
|
|
697
768
|
// Stop owned observers before capture; keep the controller lease/fence.
|
|
698
769
|
await settle();
|
|
699
770
|
if (manifest.request.capture_root)
|
|
700
771
|
await captureControllerBoundary(executionContext, row, manifest, completed, null, assertPhysicalOwner);
|
|
701
|
-
setStatus("stop_target_reached");
|
|
702
772
|
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
|
|
703
|
-
|
|
773
|
+
setStatus("stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
|
|
704
774
|
return;
|
|
705
775
|
}
|
|
706
776
|
if (completed && manifest.request.capture_root && ["start_stage", "terminal"].includes(next.kind)) {
|
|
@@ -839,6 +909,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
839
909
|
const session = state.sessions[state.current_session];
|
|
840
910
|
const baseline = new Set(state.native_baseline ?? []);
|
|
841
911
|
let children = controllerNativeChildren(state.last_receipt ?? {}, session.id).filter(child => !baseline.has(child.session_id));
|
|
912
|
+
const retainedFailures = childObserver(session)?.failed() ?? [];
|
|
913
|
+
children = controllerNativeChildren({ descendants: [...children, ...retainedFailures] }, session.id);
|
|
842
914
|
if (runtimeHarness(session.profile) === "codex-desktop" && children.some(child => child.status === "failed")) {
|
|
843
915
|
const inspected = await call(session, ["session", "inspect", "--session-id", session.id, ...sessionArguments(context, manifest, session)], false);
|
|
844
916
|
state.last_receipt = inspected;
|
|
@@ -941,7 +1013,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
941
1013
|
continue;
|
|
942
1014
|
}
|
|
943
1015
|
}
|
|
944
|
-
const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles });
|
|
1016
|
+
const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles, dispatchFence: retainedFailures.length > 0 });
|
|
945
1017
|
assertOwner();
|
|
946
1018
|
if (fanout.kind === "none") {
|
|
947
1019
|
assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
|
|
@@ -1013,6 +1085,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
1013
1085
|
}
|
|
1014
1086
|
finally {
|
|
1015
1087
|
clearInterval(monitor);
|
|
1088
|
+
const drainedObservations = [...childObservers.values()].map(retained => retained.close());
|
|
1089
|
+
observationCancellation.abort();
|
|
1090
|
+
await Promise.allSettled(drainedObservations);
|
|
1016
1091
|
if (stopping)
|
|
1017
1092
|
await stopping;
|
|
1018
1093
|
observer.db.close?.();
|
|
@@ -741,7 +741,8 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
741
741
|
next_action: stringValue(semantic.next_action) ?? "Resolve the stage result before continuing.",
|
|
742
742
|
...(Array.isArray(semantic.questions) ? { questions: semantic.questions } : {}),
|
|
743
743
|
...(Array.isArray(semantic.reviewer_findings) ? { reviewer_findings: semantic.reviewer_findings } : {}),
|
|
744
|
-
...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {})
|
|
744
|
+
...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {}),
|
|
745
|
+
...(semantic.materialization !== undefined ? { materialization: semantic.materialization } : {})
|
|
745
746
|
},
|
|
746
747
|
mechanical: {
|
|
747
748
|
started_at: startedAt,
|
|
@@ -164,6 +164,19 @@ export function reconcileRunStageReports(context, projectId, runId, exceptStage)
|
|
|
164
164
|
}
|
|
165
165
|
function normalizeStageReport(report) {
|
|
166
166
|
const semantic = object(report.semantic);
|
|
167
|
+
if (semantic.materialization !== undefined) {
|
|
168
|
+
const value = object(semantic.materialization);
|
|
169
|
+
const authored = object(value.authored_result);
|
|
170
|
+
const relative = (item) => typeof item === "string" && item.length > 0 && !path.isAbsolute(item) && !item.split(/[\\/]/).includes("..");
|
|
171
|
+
if (value.schema_id !== "dd-flow/protocolize-materialization@1" || value.run_id !== report.run_id || report.stage !== "protocolize" || value.stage !== "protocolize"
|
|
172
|
+
|| !["create", "link", "update", "not_applicable"].includes(String(value.action)) || value.status !== "passed"
|
|
173
|
+
|| authored.role !== "authored_input" || !relative(authored.run_relative_path) || !/^[a-f0-9]{64}$/.test(String(authored.sha256))
|
|
174
|
+
|| !(value.feature_path === null || relative(value.feature_path)) || !Array.isArray(value.protocol_ids) || value.protocol_ids.some(id => typeof id !== "string")
|
|
175
|
+
|| !Array.isArray(value.documents) || value.documents.some(item => { const ref = object(item); return !relative(ref.path) || !/^[a-f0-9]{64}$/.test(String(ref.sha256)); })
|
|
176
|
+
|| !Array.isArray(value.owned_links) || value.owned_links.some(item => { const edge = object(item); return !relative(edge.feature_path) || !relative(edge.protocol_path) || !value.protocol_ids.includes(edge.protocol_id); })) {
|
|
177
|
+
throw new AppError("stage_report_materialization_invalid", "PROTOCOLIZE materialization receipt is malformed or out of scope", 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
167
180
|
const sourceArtifacts = object(report.artifacts);
|
|
168
181
|
return {
|
|
169
182
|
...report,
|