botmux-workflow-core 3.10.0
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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/cjs/control.cjs +80 -0
- package/dist/cjs/control.cjs.map +7 -0
- package/dist/cjs/engine.cjs +380 -0
- package/dist/cjs/engine.cjs.map +7 -0
- package/dist/cjs/events.cjs +19 -0
- package/dist/cjs/events.cjs.map +7 -0
- package/dist/cjs/gate-policy.cjs +55 -0
- package/dist/cjs/gate-policy.cjs.map +7 -0
- package/dist/cjs/host-bindings.cjs +285 -0
- package/dist/cjs/host-bindings.cjs.map +7 -0
- package/dist/cjs/host-contract.cjs +19 -0
- package/dist/cjs/host-contract.cjs.map +7 -0
- package/dist/cjs/index.cjs +1523 -0
- package/dist/cjs/index.cjs.map +7 -0
- package/dist/cjs/runtime.cjs +6393 -0
- package/dist/cjs/runtime.cjs.map +7 -0
- package/dist/cjs/schema.cjs +1190 -0
- package/dist/cjs/schema.cjs.map +7 -0
- package/dist/esm/control.js +52 -0
- package/dist/esm/control.js.map +7 -0
- package/dist/esm/engine.js +352 -0
- package/dist/esm/engine.js.map +7 -0
- package/dist/esm/events.js +1 -0
- package/dist/esm/events.js.map +7 -0
- package/dist/esm/gate-policy.js +26 -0
- package/dist/esm/gate-policy.js.map +7 -0
- package/dist/esm/host-bindings.js +252 -0
- package/dist/esm/host-bindings.js.map +7 -0
- package/dist/esm/host-contract.js +1 -0
- package/dist/esm/host-contract.js.map +7 -0
- package/dist/esm/index.js +1481 -0
- package/dist/esm/index.js.map +7 -0
- package/dist/esm/runtime.js +6426 -0
- package/dist/esm/runtime.js.map +7 -0
- package/dist/esm/schema.js +1140 -0
- package/dist/esm/schema.js.map +7 -0
- package/dist/types/packages/workflow-core/src/control.d.ts +1 -0
- package/dist/types/packages/workflow-core/src/engine.d.ts +1 -0
- package/dist/types/packages/workflow-core/src/events.d.ts +1 -0
- package/dist/types/packages/workflow-core/src/gate-policy.d.ts +1 -0
- package/dist/types/packages/workflow-core/src/host-bindings.d.ts +1 -0
- package/dist/types/packages/workflow-core/src/host-contract.d.ts +1 -0
- package/dist/types/packages/workflow-core/src/index.d.ts +1 -0
- package/dist/types/packages/workflow-core/src/runtime.d.ts +7 -0
- package/dist/types/packages/workflow-core/src/schema.d.ts +1 -0
- package/dist/types/src/workflows/v3/artifact-contract.d.ts +40 -0
- package/dist/types/src/workflows/v3/core-control.d.ts +15 -0
- package/dist/types/src/workflows/v3/dag.d.ts +341 -0
- package/dist/types/src/workflows/v3/event-contract.d.ts +272 -0
- package/dist/types/src/workflows/v3/gate-policy.d.ts +11 -0
- package/dist/types/src/workflows/v3/host-bindings.d.ts +39 -0
- package/dist/types/src/workflows/v3/in-process-attempt-lease.d.ts +9 -0
- package/dist/types/src/workflows/v3/orchestrator.d.ts +174 -0
- package/dist/types/src/workflows/v3/portable-final-outputs.d.ts +36 -0
- package/dist/types/src/workflows/v3/portable-runtime.d.ts +82 -0
- package/dist/types/src/workflows/v3/runtime-host-contract.d.ts +181 -0
- package/dist/types/src/workflows/v3/shared-runtime.d.ts +16 -0
- package/package.json +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) botmux contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# botmux-workflow-core
|
|
2
|
+
|
|
3
|
+
Daemon-independent Workflow v3 contracts generated from Botmux's canonical
|
|
4
|
+
implementation. The package exposes the DAG schema, deterministic scheduler,
|
|
5
|
+
loop/revisit control, gate policy, and host runtime contracts. Authoring
|
|
6
|
+
surfaces such as host bindings use explicit subpath exports.
|
|
7
|
+
|
|
8
|
+
It deliberately does not export the Botmux daemon driver, Feishu cards,
|
|
9
|
+
ephemeral worker pool, PTY/session integration, or provider executors. A host
|
|
10
|
+
such as Botmux Desktop supplies those execution adapters itself.
|
|
11
|
+
|
|
12
|
+
## Exports
|
|
13
|
+
|
|
14
|
+
- `botmux-workflow-core/schema`
|
|
15
|
+
- `botmux-workflow-core/engine`
|
|
16
|
+
- `botmux-workflow-core/control`
|
|
17
|
+
- `botmux-workflow-core/gate-policy`
|
|
18
|
+
- `botmux-workflow-core/host-contract`
|
|
19
|
+
- `botmux-workflow-core/runtime`
|
|
20
|
+
- `botmux-workflow-core/events`
|
|
21
|
+
- `botmux-workflow-core/host-bindings`
|
|
22
|
+
|
|
23
|
+
The root export is the reviewed daemon-independent runtime surface. Authoring
|
|
24
|
+
APIs are intentionally available only through their explicit subpaths. Every
|
|
25
|
+
export provides ESM, CommonJS, and TypeScript declaration targets.
|
|
26
|
+
|
|
27
|
+
`runtime` is the daemon-independent Node runtime. The host injects agent
|
|
28
|
+
execution, manifest validation, human-gate resolution, and host-action
|
|
29
|
+
executors. `runWorkflow` is an alias of `runPortableWorkflow`; neither starts
|
|
30
|
+
the Botmux daemon nor imports Botmux session or Feishu adapters. A successful
|
|
31
|
+
terminal outcome includes `finalOutputs` for every sink (manifest/output
|
|
32
|
+
directory, validated manifest snapshot/hash, and optional `result.json`
|
|
33
|
+
path/hash/parsed snapshot). Recovery code can derive the same projection with
|
|
34
|
+
`await readPortableWorkflowFinalOutputs(dag, runDir, validateManifest)`
|
|
35
|
+
without parsing the internal journal. Returned paths are canonical and bound
|
|
36
|
+
to the owning attempt; consumers should use the returned snapshots or verify
|
|
37
|
+
the hashes again before reopening a path that another process can mutate.
|
|
38
|
+
|
|
39
|
+
The first drive writes an immutable, normalized run snapshot under
|
|
40
|
+
`baseDir/dag.runId`. Reusing that location resumes only when both the DAG and
|
|
41
|
+
every execution profile exactly match the snapshot; model, executor, working
|
|
42
|
+
directory, profile identity, or adapter-data drift fails before dispatch.
|
|
43
|
+
Because execution profiles are durable run metadata, `adapterData` must contain
|
|
44
|
+
stable non-secret references rather than credentials.
|
|
45
|
+
|
|
46
|
+
Saved Workflow persistence and authoring are intentionally not exported from
|
|
47
|
+
this core package: the current Botmux implementation still owns Node filesystem
|
|
48
|
+
and legacy runtime contracts. It can move to a separate authoring package once
|
|
49
|
+
that boundary is independently clean.
|
|
50
|
+
|
|
51
|
+
## Local Desktop consumption
|
|
52
|
+
|
|
53
|
+
Build a real npm tarball from the Botmux checkout:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd /path/to/botmux
|
|
57
|
+
pnpm workflow-core:pack
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The command prints the absolute `.tgz` path. Install that artifact from the
|
|
61
|
+
Desktop project with an exact dependency:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cd /path/to/botmux-clients/desktop
|
|
65
|
+
pnpm add --save-exact /absolute/path/to/botmux/packages/workflow-core/.packs/botmux-workflow-core-0.0.0.tgz
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
CI can install this package directly from a pinned Botmux commit and package
|
|
69
|
+
subdirectory. The package `prepare` script builds the missing `dist` files:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"botmux-workflow-core": "github:deepcoldy/botmux#FULL_COMMIT_SHA&path:/packages/workflow-core"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
When adding it from a shell, quote the spec because `&` is a shell operator:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pnpm add --save-exact 'github:deepcoldy/botmux#FULL_COMMIT_SHA&path:/packages/workflow-core'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Always pin the full commit SHA. Do not depend on the whole Botmux package or
|
|
86
|
+
deep-import `botmux/dist/workflows/v3/*`; that couples Desktop to daemon and
|
|
87
|
+
native runtime dependencies.
|
|
88
|
+
|
|
89
|
+
For a published release, use an exact version such as:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"dependencies": {
|
|
94
|
+
"botmux-workflow-core": "3.8.0"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Desktop should bundle this package into its Electron main-process output rather
|
|
100
|
+
than adding it to the unpacked runtime dependency closure.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// packages/workflow-core/src/control.ts
|
|
21
|
+
var control_exports = {};
|
|
22
|
+
__export(control_exports, {
|
|
23
|
+
matchLoopExitWhen: () => matchLoopExitWhen,
|
|
24
|
+
revisitBudgetStatus: () => revisitBudgetStatus
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(control_exports);
|
|
27
|
+
|
|
28
|
+
// src/workflows/v3/dag.ts
|
|
29
|
+
var DEFAULT_REVISIT_BUDGET_PER_PAIR = 1;
|
|
30
|
+
var DEFAULT_REVISIT_BUDGET_PER_RUN = 8;
|
|
31
|
+
|
|
32
|
+
// src/workflows/v3/core-control.ts
|
|
33
|
+
function matchLoopExitWhen(when, value) {
|
|
34
|
+
if (when.equals !== void 0) return value === when.equals;
|
|
35
|
+
if (when.notEquals !== void 0) return value !== when.notEquals;
|
|
36
|
+
if (typeof value !== "number") return false;
|
|
37
|
+
if (when.gt !== void 0) return value > when.gt;
|
|
38
|
+
if (when.gte !== void 0) return value >= when.gte;
|
|
39
|
+
if (when.lt !== void 0) return value < when.lt;
|
|
40
|
+
if (when.lte !== void 0) return value <= when.lte;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function revisitBudgetStatus(events, sourceNodeId, toNodeId) {
|
|
44
|
+
let pairUsed = 0;
|
|
45
|
+
let runUsed = 0;
|
|
46
|
+
let pairGranted = 0;
|
|
47
|
+
let runGranted = 0;
|
|
48
|
+
for (const event of events) {
|
|
49
|
+
if (event.type === "nodeRevisitRequested") {
|
|
50
|
+
runUsed++;
|
|
51
|
+
if (event.nodeId === sourceNodeId && event.toNodeId === toNodeId) pairUsed++;
|
|
52
|
+
} else if (event.type === "revisitBudgetGranted") {
|
|
53
|
+
if (event.sourceNodeId === sourceNodeId && event.toNodeId === toNodeId) pairGranted++;
|
|
54
|
+
else if (event.sourceNodeId === void 0 && event.toNodeId === void 0) runGranted++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const pairLimit = DEFAULT_REVISIT_BUDGET_PER_PAIR + pairGranted;
|
|
58
|
+
const runLimit = DEFAULT_REVISIT_BUDGET_PER_RUN + runGranted;
|
|
59
|
+
if (pairUsed >= pairLimit) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
tier: "pair",
|
|
63
|
+
detail: `revisit budget exhausted for ${sourceNodeId}->${toNodeId} (${pairUsed}/${pairLimit}) \u2014 grant +1 (this pair) to continue`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (runUsed >= runLimit) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
tier: "run",
|
|
70
|
+
detail: `run-wide revisit budget exhausted (${runUsed}/${runLimit}) \u2014 grant +1 (run) to continue`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
76
|
+
0 && (module.exports = {
|
|
77
|
+
matchLoopExitWhen,
|
|
78
|
+
revisitBudgetStatus
|
|
79
|
+
});
|
|
80
|
+
//# sourceMappingURL=control.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/control.ts", "../../../../src/workflows/v3/dag.ts", "../../../../src/workflows/v3/core-control.ts"],
|
|
4
|
+
"sourcesContent": ["export * from '../../../src/workflows/v3/core-control.js';\n", "/**\n * v3 DAG definition \u2014 schema, loader, validator, topological order.\n *\n * The v3 runtime (LLM-driven workflow) loads a hand-written `dag.json`,\n * validates it, and walks it in topological order with deps gating. This\n * module is the *schema half* of the engine: pure data + validation. Node.js\n * filesystem loading lives in `dag-loader.ts`.\n *\n * Deliberately standalone from v0.2's `definition.ts` \u2014 v3 nodes are a much\n * smaller surface (goal / host, no loop / decision / fanout) and coupling the\n * two schemas would drag v0.2's complexity into the new engine. See\n * `docs/design/2026-06-01-v3-mvp-engine-split.md` \u00A73 for the authored shape.\n */\n\nimport { collectV3HostBindingRefs, V3HostBindingError } from './host-bindings.js';\n\n// \u2500\u2500\u2500 Schema \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `goal` \u2014 an LLM node driven by the `botmux-goal` skill (single goal, one\n * ephemeral worker). `host` \u2014 a deterministic side-effect node (feishu-send\n * / base write / schedule) that does NOT route through an LLM. MVP runs\n * `goal` nodes end to end; `host` is reserved in the schema so the runtime\n * can grow into it without a breaking change (it is rejected at validate\n * time until the executor lands \u2014 see `validateDag`).\n * `loop` \u2014 a composite node wrapping a bounded sub-pipeline (structured rework:\n * `code -> test` until the test's structured result passes). The outer DAG\n * stays acyclic \u2014 rework NEVER appears as a back-edge; it only exists inside\n * an explicit loop body. See docs/design/2026-06-06-v3-structured-loop-design.md.\n */\nexport type V3NodeType = 'goal' | 'host' | 'loop';\n\nexport const NODE_KINDS: readonly V3NodeType[] = ['goal', 'host', 'loop'];\n\n/** First host slice: every registered executor is side-effecting and must be\n * approved against its frozen runtime input. Keep this list in lockstep with\n * the shared host-executor registry. */\nexport const V3_HOST_EXECUTORS = ['feishu-send', 'feishu-reply', 'botmux-schedule'] as const;\nexport type V3HostExecutorName = typeof V3_HOST_EXECUTORS[number];\n\n/** Default per-node wall-clock budget when a node omits `timeoutSec`.\n * Generous on purpose: completion is detected by the manifest watcher\n * (seconds after the agent finishes), so the timeout only fires for hung\n * nodes \u2014 a long default costs nothing on the happy path. The architect is\n * prompted to set per-node `timeoutSec` explicitly for long tasks. */\nexport const DEFAULT_NODE_TIMEOUT_SEC = 1800;\n\n/** Hard ceiling for per-node `timeoutSec` (4h) \u2014 rejects runaway budgets the\n * architect might hallucinate while still allowing genuinely long tasks. */\nexport const MAX_NODE_TIMEOUT_SEC = 14400;\n\n/** A humanGate frozen at authoring time \u2014 the runtime never lets a node\n * add / skip a gate at runtime (design Q10). */\nexport interface V3HumanGate {\n /** Approval-card body shown to the human reviewer. */\n prompt: string;\n /** Button option keys shown on the approval card. */\n options?: string[];\n /** Selecting any of these options maps to `resolution:'approved'`. */\n approveOptions?: string[];\n /** Empty = any operator allowed by the outer daemon permission gate. */\n approvers?: string[];\n}\n\nexport const DEFAULT_HUMAN_GATE_OPTIONS: readonly string[] = ['approve', 'reject'];\nexport const MAX_HUMAN_GATE_OPTIONS = 8;\nexport const MAX_HUMAN_GATE_OPTION_LENGTH = 32;\n\n/**\n * Declares that this node consumes an upstream node's products. MVP pulls the\n * upstream node's *whole* manifest (all files) into this node's `inputs.json`;\n * a per-file selector is deferred (design \u00A72.3). Invariant: `from` MUST also\n * appear in the node's `depends` \u2014 you can only read outputs of a node you\n * wait for.\n */\nexport interface V3InputRef {\n /** Upstream nodeId whose manifest files become this node's inputs. */\n from: string;\n /** P3 per-file selector: pull ONE named product instead of the whole\n * manifest. Exactly one of `name` (manifest logical name) / `path`\n * (manifest relative path) when present. A selector that matches nothing\n * at dispatch time is surfaced to the agent via `GoalInputs.omitted`\n * (reason 'selectorMiss') \u2014 absence reads as a contract gap, not silence. */\n select?: { name?: string; path?: string };\n}\n\n/**\n * A normalized incoming edge (edge-activation design 2026-06-06 \u00A71.1).\n * Authored as either a plain string (`\"build\"`) or an object\n * (`{ \"from\": \"review\", \"when\": {...} }`); validateDag normalizes both to this\n * shape. No `when` = unconditional (source `done` \u21D2 active). With `when`,\n * the edge's activation is decided ONCE by the runtime reading the source's\n * `result.json` and journaled as `edgeResolved` \u2014 never re-read afterwards.\n *\n * `from` values are deduped per node: P0 supports at most ONE edge per\n * (from, to) pair, so `(from, to)` is a stable idempotency key for\n * `edgeResolved`. Express OR over outcomes inside the source's structured\n * result instead of authoring parallel conditional edges.\n */\nexport interface V3DependRef {\n from: string;\n /** Predicate over the SOURCE node's structured result \u2014 same shape and\n * validation as a loop exit predicate (`result.<key>` + exactly one\n * comparison operator, declared + required + type-compatible). */\n when?: V3EdgeWhen;\n}\n\n/** Edge predicates reuse the loop-exit predicate shape verbatim. */\nexport type V3EdgeWhen = V3LoopExitWhen;\n\n/**\n * Join semantics over a node's incoming edges (design \u00A71.2). Evaluated ONCE,\n * only after every incoming edge has settled (source done/skipped and any\n * predicate journaled) \u2014 no early release, no loser cancellation in P0.\n */\nexport type V3TriggerRule = 'all_success' | 'one_success' | { quorum: number };\n\n/**\n * Per-node capability override (P2, edge-activation follow-up). Merged onto\n * the bot's frozen `BotSnapshot` at dispatch time:\n * - `model` picks a different model for THIS node (cost control: cheap\n * models for research nodes, strong models for code nodes);\n * - `systemPromptAppend` adds node-specific instructions to the goal file.\n * Permission is deliberately not overridable: every workflow worker requires\n * CLI bypass permission, and bots configured to disable it are rejected.\n * `toolsSubset` is deferred \u2014 it needs a per-CLI capability matrix across the\n * daemon init/worker/adapter chain (P2b).\n */\nexport interface V3CapabilityOverride {\n model?: string;\n systemPromptAppend?: string;\n}\n\nexport const MAX_OVERRIDE_MODEL_LENGTH = 64;\nexport const MAX_OVERRIDE_SYSTEM_PROMPT_APPEND = 8000;\n\n/**\n * Opt-in structured-output contract \u2014 a deliberately TINY subset of\n * JSON-Schema (flat object, primitive-typed properties, optional required\n * list). Hand-validated (no deps, repo style); anything outside the subset\n * is rejected at validateDag time so the architect can never author a schema\n * the runtime's validator cannot execute.\n *\n * NOT supported (first slice): nested schemas, array item types, patterns.\n * `type:'array'|'object'` properties validate the TOP-LEVEL type only.\n * `enum` is supported on STRING properties only (edge-activation design \u00A71.3)\n * \u2014 it is the decision-vocabulary anchor for edge predicates: validateDag\n * cross-checks `equals`/`notEquals` operands against the source field's enum,\n * so a typo'd decision value fails at validate time, not at runtime.\n */\nexport interface V3ResultSchema {\n type: 'object';\n properties: Record<string, { type: V3ResultFieldType; enum?: string[] }>;\n required?: string[];\n}\n\nexport type V3ResultFieldType = 'string' | 'number' | 'boolean' | 'array' | 'object';\n\nconst RESULT_FIELD_TYPES: readonly V3ResultFieldType[] = ['string', 'number', 'boolean', 'array', 'object'];\n\n/** Caps on the resultSchema subset (anti-runaway: a giant schema bloats the\n * goal prompt and the validator). Checked at validateDag time. */\nexport const RESULT_SCHEMA_MAX_PROPERTIES = 32;\nexport const RESULT_SCHEMA_MAX_BYTES = 4096;\n\n/** Caps on a string property's `enum` (anti prompt-bloat; counted inside the\n * 4KB schema budget like everything else). */\nexport const RESULT_ENUM_MAX_VALUES = 16;\nexport const RESULT_ENUM_MAX_VALUE_LENGTH = 64;\n\n/** Backstop ceiling for `maxIterations` \u2014 like the timeout cap, it rejects a\n * runaway budget the architect might hallucinate; a human can still grant\n * extra iterations one at a time once the loop blocks. */\nexport const MAX_LOOP_ITERATIONS = 20;\n\n/** Cross-node revisit budgets (anti-infinite-loop). Two tiers:\n * - PER-PAIR (source\u2192target): how many times one node may revisit one ancestor\n * before the run blocks \u2014 default 1 (a node sends each ancestor back once;\n * expected multi-round rework belongs in a structured loop, not ad-hoc\n * revisit). Pinpoints which edge is ping-ponging.\n * - PER-RUN: total revisits across the whole run \u2014 a generous backstop so many\n * distinct pairs (or many nodes revisiting) can't run away.\n * Exhaustion blocks the run; a human grants +1 (revisitBudgetGranted). */\nexport const DEFAULT_REVISIT_BUDGET_PER_PAIR = 1;\nexport const DEFAULT_REVISIT_BUDGET_PER_RUN = 8;\n\n// \u2500\u2500\u2500 Loop schema \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Exit predicate over the exit node's structured result. Deliberately tiny:\n * `path` is fixed to `result.<key>` (the resultSchema subset is flat, so there\n * is nothing deeper to address) and exactly ONE comparison operator must be\n * set. validateDag cross-checks the key against the exit node's resultSchema\n * (declared AND required, operator type-compatible), so \"field missing at\n * runtime\" is a validate-time impossibility, not a runtime branch.\n *\n * No `continue.when` counterpart \u2014 when the predicate does not match, the loop\n * implicitly continues (until maxIterations). Two independent predicates\n * would create undefined both-match / neither-match states.\n */\nexport interface V3LoopExitWhen {\n /** `result.<key>` \u2014 a key of the exit node's resultSchema. */\n path: string;\n equals?: string | number | boolean;\n notEquals?: string | number | boolean;\n gt?: number;\n gte?: number;\n lt?: number;\n lte?: number;\n}\n\nconst LOOP_WHEN_OPERATORS = ['equals', 'notEquals', 'gt', 'gte', 'lt', 'lte'] as const;\n\nexport interface V3LoopExit {\n /** Body nodeId whose structured result decides the loop's exit. */\n node: string;\n when: V3LoopExitWhen;\n}\n\n/** Which body node's final-iteration manifest is the loop's outward product\n * (what downstream `inputs: [{from: <loopId>}]` reads). Defaults to the\n * exit node, but a repair loop usually exports the WORKER's product (`code`),\n * not the gate's (`test`). */\nexport interface V3LoopOutput {\n from: string;\n}\n\nexport interface V3Node {\n /** Unique within the DAG; also used as a runDir path segment, so it is\n * constrained to `[A-Za-z0-9._-]`. */\n id: string;\n type: V3NodeType;\n /** Required + non-empty for `goal` nodes; the single-sentence objective. */\n goal?: string;\n /** Which bot/CLI runs this node. MVP dogfoods a single CLI, but the field\n * is per-node so a mixed-backend DAG is a non-breaking extension. */\n bot?: string;\n /** Normalized incoming edges. Authored as `string | {from, when?}`;\n * validateDag normalizes to `V3DependRef[]` (edge-activation design \u00A71.1).\n * Unconditional edges gate on source `done`; `when` edges additionally\n * gate on the journaled `edgeResolved` verdict. */\n depends: V3DependRef[];\n /** Join semantics over incoming edges; defaults to 'all_success' (exactly\n * today's behavior). Only meaningful on nodes with \u22651 incoming edge. */\n triggerRule?: V3TriggerRule;\n /** Per-node capability override (restrict/redirect only \u2014 see\n * V3CapabilityOverride). Goal nodes (incl. loop body nodes) only; a loop\n * composite never spawns a worker, so it rejects this field. */\n override?: V3CapabilityOverride;\n /** Upstream products to thread in as inputs (every `from` \u2286 `depends`). */\n inputs: V3InputRef[];\n /** Wall-clock budget in seconds; falls back to DEFAULT_NODE_TIMEOUT_SEC. */\n timeoutSec?: number;\n /** Optional human approval gate, evaluated *before* the node's work runs. */\n humanGate?: V3HumanGate | null;\n /** Opt-in structured-output contract: when set, the node must write a\n * `result.json` (listed in its manifest files) matching this schema; a\n * violation blocks (not fails) the node. Absent \u2192 zero behavior change. */\n resultSchema?: V3ResultSchema;\n /** Definition-level revisit exits (cross-node\u56DE\u6EAF). When this node's\n * `result.json` returns `{ \"status\": \"revisit\", \"revisitTo\": \"<A>\" }`, the\n * runtime may revisit ancestor node `<A>` \u2014 but ONLY if `<A>` is listed\n * here. Default (absent / empty) = the node cannot revisit anything.\n * validateDag enforces every entry is an ANCESTOR (transitive `depends`),\n * so a revisit can never create a forward jump or a cycle in the run. */\n revisitTo?: string[];\n\n // \u2500\u2500 host-only fields (type === 'host') \u2500\u2500\n /** Deterministic executor invoked by the host runtime (never an LLM). */\n executor?: V3HostExecutorName;\n /** Frozen before the runtime gate; supports typed host bindings. */\n input?: unknown;\n\n // \u2500\u2500 loop-only fields (type === 'loop'; see V3LoopNode) \u2500\u2500\n /** Hard iteration bound; the loop blocks (recoverable, human can grant +1)\n * when it is exhausted without the exit predicate matching. */\n maxIterations?: number;\n /** The per-iteration sub-pipeline. Goal nodes only \u2014 no nesting, no\n * humanGate inside a body (both first-cut restrictions). */\n body?: { nodes: V3Node[] };\n /** Structured exit condition; not matching \u21D2 implicit continue. */\n exit?: V3LoopExit;\n /** Previous-iteration products threaded into the NEXT iteration's inputs.\n * Entries are `<bodyId>.result` | `<bodyId>.files` | `<bodyId>.manifest`. */\n feedback?: string[];\n /** Outward product projection (defaults to exit.node). */\n output?: V3LoopOutput;\n /** Only supported value (and the default): 'blocked'. */\n onExhausted?: 'blocked';\n /** Only supported value (and the default): 'fresh' \u2014 every iteration's every\n * body node runs a fresh ephemeral worker. `resumeWithinLoop` is deferred. */\n sessionPolicy?: 'fresh';\n}\n\n/** A `V3Node` narrowed to a goal node \u2014 `goal` is guaranteed present. This is\n * what crosses into `runNode` (the pool only ever runs goal nodes in MVP). */\nexport interface V3GoalNode extends V3Node {\n type: 'goal';\n goal: string;\n}\n\n/** Narrowing guard: a validated goal node always has a non-empty `goal`. */\nexport function isGoalNode(node: V3Node): node is V3GoalNode {\n return node.type === 'goal' && typeof node.goal === 'string' && node.goal.length > 0;\n}\n\nexport interface V3HostNode extends V3Node {\n type: 'host';\n executor: V3HostExecutorName;\n input: unknown;\n humanGate: V3HumanGate;\n}\n\nexport function isHostNode(node: V3Node): node is V3HostNode {\n return node.type === 'host' &&\n typeof node.executor === 'string' &&\n (V3_HOST_EXECUTORS as readonly string[]).includes(node.executor) &&\n node.humanGate !== null;\n}\n\n/** A `V3Node` narrowed to a loop node \u2014 validateDag guarantees every loop\n * field is present and normalized (output defaulted to exit.node, feedback\n * defaulted to `[]`). */\nexport interface V3LoopNode extends V3Node {\n type: 'loop';\n maxIterations: number;\n body: { nodes: V3Node[] };\n exit: V3LoopExit;\n feedback: string[];\n output: V3LoopOutput;\n}\n\n/** Narrowing guard for validated loop nodes. */\nexport function isLoopNode(node: V3Node): node is V3LoopNode {\n return node.type === 'loop';\n}\n\n/**\n * The expanded id a body node instance runs under in iteration N:\n * `repairLoop.i001.code`. Path-safe by construction (loopId/bodyId are\n * SEGMENT_RE, `.` is in the charset) and free of the `:` the blocked-card\n * nonce uses as a separator. OPAQUE \u2014 never parse this string back; journal\n * events carry a structured `loop: {loopId, iteration, bodyNodeId}` instead.\n */\nexport function loopInstanceId(loopId: string, iteration: number, bodyNodeId: string): string {\n return `${loopId}.i${String(iteration).padStart(3, '0')}.${bodyNodeId}`;\n}\n\nexport interface V3Dag {\n /** Stable id for this run; used as the runDir name, so path-segment safe. */\n runId: string;\n nodes: V3Node[];\n}\n\n// \u2500\u2500\u2500 Validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Thrown by `validateDag` / `loadDag` with every problem found, not just the\n * first \u2014 authoring a DAG by hand is iterative, so surface the full list. */\nexport class DagValidationError extends Error {\n constructor(public readonly problems: string[]) {\n super(`Invalid v3 dag.json:\\n - ${problems.join('\\n - ')}`);\n this.name = 'DagValidationError';\n }\n}\n\n/** Node ids and runId double as filesystem path segments under the runDir. */\nexport const V3_DAG_SEGMENT_RE = /^[A-Za-z0-9._-]+$/;\nconst SEGMENT_RE = V3_DAG_SEGMENT_RE;\n\nfunction isObject(v: unknown): v is Record<string, unknown> {\n return typeof v === 'object' && v !== null && !Array.isArray(v);\n}\n\n/**\n * Validate an untrusted parsed value into a `V3Dag`. Pure \u2014 throws\n * `DagValidationError` with the full problem list on any violation, otherwise\n * returns a normalized dag (defaults filled, `humanGate: undefined` \u2192 `null`).\n *\n * Checks: runId shape; non-empty unique path-safe node ids; known `type`;\n * `goal` non-empty for goal nodes; host executor/input/gate policy;\n * `depends` reference existing nodes, no self-dep, no dup `from` (P0: one\n * edge per (from,to)); edge predicates validated against the SOURCE's\n * resultSchema (goal-with-schema sources only); `triggerRule` shape/bounds;\n * `inputs.from` reference existing nodes AND appear in `depends`; acyclic\n * (delegated to `topologicalOrder`, conditional edges included).\n */\nexport function validateDag(raw: unknown): V3Dag {\n const problems: string[] = [];\n\n if (!isObject(raw)) {\n throw new DagValidationError(['root must be a JSON object']);\n }\n if (typeof raw.runId !== 'string' || !SEGMENT_RE.test(raw.runId)) {\n problems.push(`runId must be a path-safe string matching ${SEGMENT_RE} (got ${JSON.stringify(raw.runId)})`);\n }\n if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {\n throw new DagValidationError([...problems, 'nodes must be a non-empty array']);\n }\n\n const ids = new Set<string>();\n const nodes: V3Node[] = [];\n // Edge predicates parked until every node (and thus every source's\n // resultSchema) is collected \u2014 validated in the cross-node pass below.\n const pendingWhens: PendingWhen[] = [];\n\n for (let i = 0; i < raw.nodes.length; i++) {\n const n = raw.nodes[i];\n const where = `nodes[${i}]`;\n if (!isObject(n)) {\n problems.push(`${where} must be an object`);\n continue;\n }\n const id = n.id;\n if (typeof id !== 'string' || !SEGMENT_RE.test(id)) {\n problems.push(`${where}.id must be a path-safe string matching ${SEGMENT_RE} (got ${JSON.stringify(id)})`);\n continue;\n }\n if (ids.has(id)) {\n problems.push(`duplicate node id \"${id}\"`);\n continue;\n }\n ids.add(id);\n\n const type = n.type;\n if (type !== 'goal' && type !== 'host' && type !== 'loop') {\n problems.push(`node \"${id}\".type must be one of ${NODE_KINDS.join(' | ')} (got ${JSON.stringify(type)})`);\n continue;\n }\n const depends = normDepends(n.depends, `node \"${id}\"`, problems, { ownerId: id, list: pendingWhens });\n const fromList = depends.map((d) => d.from);\n if (fromList.includes(id)) problems.push(`node \"${id}\" depends on itself`);\n if (new Set(fromList).size !== fromList.length) problems.push(`node \"${id}\".depends has duplicates`);\n\n const triggerRule = normTriggerRule(n.triggerRule, depends.length, `node \"${id}\"`, problems);\n\n const inputs = normInputs(n.inputs, id, problems);\n\n if (type === 'loop') {\n const loopFields = normLoopFields(n, id, problems);\n if (loopFields) {\n nodes.push({\n id,\n type,\n goal: typeof n.goal === 'string' ? n.goal : undefined,\n bot: typeof n.bot === 'string' ? n.bot : undefined,\n depends,\n triggerRule,\n inputs,\n humanGate: null,\n ...loopFields,\n });\n }\n continue;\n }\n\n if (type === 'host') {\n if (n.goal !== undefined) problems.push(`host node \"${id}\".goal is not supported`);\n if (n.bot !== undefined) problems.push(`host node \"${id}\".bot is not supported \u2014 host nodes do not spawn a CLI`);\n if (n.override !== undefined) problems.push(`host node \"${id}\".override is not supported`);\n if (n.revisitTo !== undefined) problems.push(`host node \"${id}\".revisitTo is not supported`);\n if (n.resultSchema !== undefined) {\n problems.push(`host node \"${id}\".resultSchema is not supported \u2014 host output uses the trusted executor result contract`);\n }\n if (inputs.length > 0) {\n problems.push(`host node \"${id}\".inputs must be empty \u2014 use typed bindings in host input`);\n }\n // `undefined` is the normalized/default all-success rule (and is the\n // only legal representation for a root node with no incoming edges).\n // Reject only an explicitly different trigger policy.\n if (triggerRule !== undefined && triggerRule !== 'all_success') {\n problems.push(\n `host node \"${id}\".triggerRule must be \"all_success\"; ` +\n 'P0 host bindings do not accept skipped/omitted dependencies',\n );\n }\n const executor = typeof n.executor === 'string' ? n.executor : '';\n if (!(V3_HOST_EXECUTORS as readonly string[]).includes(executor)) {\n problems.push(\n `host node \"${id}\".executor must be one of ${V3_HOST_EXECUTORS.join(' | ')} ` +\n `(got ${JSON.stringify(n.executor)})`,\n );\n }\n if (!Object.prototype.hasOwnProperty.call(n, 'input')) {\n problems.push(`host node \"${id}\".input is required`);\n } else {\n validateHostInputShape(executor as V3HostExecutorName, n.input, id, problems);\n try {\n collectV3HostBindingRefs(n.input);\n } catch (err) {\n problems.push(\n `host node \"${id}\".input is invalid: ${err instanceof V3HostBindingError ? err.message : String(err)}`,\n );\n }\n }\n if (n.timeoutSec !== undefined) {\n problems.push(\n `host node \"${id}\".timeoutSec is not supported \u2014 abandoning an in-flight provider call would make its effect outcome unknown`,\n );\n }\n const humanGate = normHumanGate(n.humanGate, `host node \"${id}\"`, problems);\n if (!humanGate) {\n problems.push(\n `host node \"${id}\" must declare a humanGate; v3 P0 does not allow ungated external side effects`,\n );\n } else {\n // Host gates authorize an external side effect. Do not inherit the\n // generic gate's legacy \"first option means approve\" fallback: it can\n // turn a button labelled `reject` into an approve action. P0 requires\n // the reserved choices to have explicit, invariant semantics.\n if (!humanGate.options?.includes('approve')) {\n problems.push(\n `host node \"${id}\".humanGate.options must include \"approve\" explicitly; ` +\n 'host side effects cannot use an implicit first-option approval',\n );\n }\n if (\n humanGate.approveOptions?.length !== 1 ||\n humanGate.approveOptions[0] !== 'approve'\n ) {\n problems.push(\n `host node \"${id}\".humanGate.approveOptions must be exactly [\"approve\"]; ` +\n 'custom-labelled choices cannot authorize a host side effect',\n );\n }\n }\n nodes.push({\n id,\n type,\n executor: executor as V3HostExecutorName,\n input: n.input,\n depends,\n triggerRule,\n inputs: [],\n humanGate,\n });\n continue;\n }\n\n if (typeof n.goal !== 'string' || n.goal.trim() === '') {\n problems.push(`goal node \"${id}\".goal must be a non-empty string`);\n }\n\n const timeoutSec = normTimeoutSec(n.timeoutSec, `node \"${id}\"`, problems);\n\n const resultSchema = normResultSchema(n.resultSchema, id, problems);\n\n const humanGate = normHumanGate(n.humanGate, `node \"${id}\"`, problems);\n\n const override = normOverride(n.override, `node \"${id}\"`, problems);\n\n const revisitTo = normRevisitTo(n.revisitTo, id, problems);\n\n nodes.push({\n id,\n type,\n goal: typeof n.goal === 'string' ? n.goal : undefined,\n bot: typeof n.bot === 'string' ? n.bot : undefined,\n depends,\n triggerRule,\n override,\n inputs,\n timeoutSec,\n humanGate,\n resultSchema,\n ...(revisitTo ? { revisitTo } : {}),\n });\n }\n\n // Cross-node reference checks \u2014 only meaningful once ids are collected.\n for (const node of nodes) {\n for (const dep of node.depends) {\n if (!ids.has(dep.from)) problems.push(`node \"${node.id}\" depends on unknown node \"${dep.from}\"`);\n }\n for (const inp of node.inputs) {\n if (!ids.has(inp.from)) {\n problems.push(`node \"${node.id}\".inputs references unknown node \"${inp.from}\"`);\n } else if (!node.depends.some((d) => d.from === inp.from)) {\n problems.push(`node \"${node.id}\".inputs.from \"${inp.from}\" must also be in depends`);\n }\n }\n if (node.type === 'host') {\n try {\n for (const ref of collectV3HostBindingRefs(node.input)) {\n if (ref.kind !== 'result') continue;\n if (!ids.has(ref.nodeId)) {\n problems.push(`host node \"${node.id}\".input references unknown result node \"${ref.nodeId}\"`);\n } else if (!node.depends.some((dep) => dep.from === ref.nodeId)) {\n problems.push(\n `host node \"${node.id}\".input result source \"${ref.nodeId}\" must also be in depends`,\n );\n } else if (!node.depends.some((dep) => dep.from === ref.nodeId && dep.when === undefined)) {\n problems.push(\n `host node \"${node.id}\".input result source \"${ref.nodeId}\" must use an unconditional depends edge; ` +\n 'P0 host bindings do not accept omitted conditional inputs',\n );\n }\n }\n } catch {\n // Per-node validation already reports the malformed binding.\n }\n }\n // revisitTo: each target must exist AND be a (transitive) ANCESTOR of this\n // node \u2014 a revisit only ever jumps BACKWARD, so the definition graph stays\n // acyclic and the supersede cone is well-defined (cross-node\u56DE\u6EAF design).\n if (node.revisitTo && node.revisitTo.length > 0) {\n const ancestors = ancestorsOf(node.id, nodes);\n for (const target of node.revisitTo) {\n if (!ids.has(target)) {\n problems.push(`node \"${node.id}\".revisitTo references unknown node \"${target}\"`);\n } else if (target === node.id) {\n problems.push(`node \"${node.id}\".revisitTo cannot point at itself`);\n } else if (!ancestors.has(target)) {\n problems.push(\n `node \"${node.id}\".revisitTo \"${target}\" must be an ancestor (reachable via depends) \u2014 revisit only jumps backward`,\n );\n }\n }\n }\n }\n\n // Revisit can replay an entire downstream cone. External effects are not\n // replay-safe under a fresh attempt/idempotency key, so P0 forbids a host in\n // any cone that a goal is allowed to revisit.\n for (const requester of nodes) {\n for (const target of requester.revisitTo ?? []) {\n const cone = downstreamCone(target, nodes);\n for (const nodeId of cone) {\n if (nodeByIdUnsafe(nodes, nodeId)?.type === 'host') {\n problems.push(\n `node \"${requester.id}\".revisitTo \"${target}\" would replay host node \"${nodeId}\"; ` +\n 'host nodes are not allowed in a revisit cone',\n );\n }\n }\n }\n }\n\n // Edge-predicate validation (design \u00A72): the source must be a goal node\n // declaring a resultSchema \u2014 loop sources are forbidden in P0 (a loop's\n // outward manifest belongs to its output-projection body node; put an\n // explicit verifier goal after the loop instead). Host sources are also\n // forbidden because their fixed receipt schema has no authored resultSchema.\n // The predicate reuses the\n // loop-exit validator: declared + required key, exactly one operator,\n // type-compatible, enum-reconciled.\n const nodeById = new Map(nodes.map((nn) => [nn.id, nn]));\n for (const pw of pendingWhens) {\n const source = nodeById.get(pw.ref.from);\n if (!source) continue; // unknown `from` already reported above\n if (source.type !== 'goal') {\n problems.push(\n `${pw.where}: conditional edge source \"${pw.ref.from}\" must be a goal node ` +\n `(P0 forbids loop sources \u2014 add a verifier goal after the loop and branch on ITS result)`,\n );\n continue;\n }\n if (!source.resultSchema) {\n problems.push(\n `${pw.where}: conditional edge source \"${pw.ref.from}\" must declare a resultSchema \u2014 the predicate reads its structured result`,\n );\n continue;\n }\n const when = normLoopExitWhen(pw.raw, source.resultSchema, pw.where, problems);\n if (when) pw.ref.when = when;\n }\n\n // Loop expansion namespace guard: iteration instances run under\n // `<loopId>.iNNN.<bodyId>` (see loopInstanceId), so no OTHER top-level id may\n // sit inside a loop's dot-prefix \u2014 an authored `repairLoop.i001.code` node\n // would collide with the expansion. Plain ids may still contain dots.\n for (const node of nodes) {\n if (node.type !== 'loop') continue;\n for (const other of ids) {\n if (other !== node.id && other.startsWith(`${node.id}.`)) {\n problems.push(\n `node id \"${other}\" collides with loop \"${node.id}\" expansion namespace (\"${node.id}.*\")`,\n );\n }\n }\n }\n\n if (problems.length > 0) throw new DagValidationError(problems);\n\n const dag: V3Dag = { runId: raw.runId as string, nodes };\n // Cycle detection: topologicalOrder throws on a cycle. Run it here so\n // loadDag rejects a cyclic DAG up front rather than mid-run.\n topologicalOrder(dag);\n return dag;\n}\n\nfunction validateHostInputShape(\n executor: V3HostExecutorName,\n input: unknown,\n nodeId: string,\n problems: string[],\n): void {\n if (!isObject(input) || Object.prototype.hasOwnProperty.call(input, '$ref')) {\n problems.push(`host node \"${nodeId}\".input must be an object with explicit executor fields`);\n return;\n }\n const shape: Record<V3HostExecutorName, { required: string[]; allowed: string[] }> = {\n 'feishu-send': {\n required: ['larkAppId', 'chatId', 'content'],\n allowed: ['larkAppId', 'chatId', 'content', 'msgType'],\n },\n 'feishu-reply': {\n required: ['larkAppId', 'rootMessageId', 'content'],\n allowed: ['larkAppId', 'rootMessageId', 'content', 'msgType', 'replyInThread'],\n },\n 'botmux-schedule': {\n required: ['name', 'schedule', 'prompt', 'workingDir', 'chatId', 'chatType', 'larkAppId'],\n allowed: [\n 'name', 'schedule', 'prompt', 'workingDir', 'chatId',\n 'chatType', 'rootMessageId', 'scope', 'larkAppId', 'repeat', 'deliver',\n ],\n },\n };\n const expected = shape[executor];\n if (!expected) return;\n for (const field of expected.required) {\n if (!Object.prototype.hasOwnProperty.call(input, field)) {\n problems.push(`host node \"${nodeId}\".input.${field} is required for ${executor}`);\n }\n }\n for (const field of Object.keys(input)) {\n if (!expected.allowed.includes(field)) {\n problems.push(`host node \"${nodeId}\".input.${field} is not supported by ${executor}`);\n }\n }\n const identity: Record<string, string> =\n executor === 'feishu-send' ? { larkAppId: 'larkAppId', chatId: 'chatId' }\n : executor === 'feishu-reply' ? { larkAppId: 'larkAppId', rootMessageId: 'rootMessageId' }\n : { larkAppId: 'larkAppId', chatId: 'chatId', chatType: 'chatType' };\n if (executor === 'botmux-schedule' && Object.prototype.hasOwnProperty.call(input, 'rootMessageId')) {\n identity.rootMessageId = 'rootMessageId';\n }\n if (\n executor === 'botmux-schedule' &&\n Object.prototype.hasOwnProperty.call(input, 'deliver') &&\n input.deliver !== 'origin' &&\n input.deliver !== 'new-topic'\n ) {\n problems.push(\n `host node \"${nodeId}\".input.deliver must be \"origin\" or \"new-topic\"; ` +\n 'v3 P0 does not support local-only schedule delivery',\n );\n }\n for (const [field, contextName] of Object.entries(identity)) {\n const value = input[field];\n if (\n !isObject(value) ||\n Object.keys(value).length !== 1 ||\n value.$ref !== `context.${contextName}`\n ) {\n problems.push(\n `host node \"${nodeId}\".input.${field} must be exact ` +\n `{ \"$ref\": \"context.${contextName}\" }; IM host effects cannot target another bot/chat`,\n );\n }\n }\n}\n\nfunction normHumanGate(raw: unknown, where: string, problems: string[]): V3HumanGate | null {\n if (raw == null) return null;\n if (!isObject(raw) || typeof raw.prompt !== 'string' || raw.prompt.trim() === '') {\n problems.push(`${where}.humanGate must be { prompt: <non-empty string>, options?, approveOptions?, approvers? } or null`);\n return null;\n }\n\n let options = [...DEFAULT_HUMAN_GATE_OPTIONS];\n if (raw.options !== undefined) {\n if (!Array.isArray(raw.options)) {\n problems.push(`${where}.humanGate.options must be a non-empty string array`);\n } else {\n const parsed = parseUniqueStringList(\n raw.options,\n `${where}.humanGate.options`,\n problems,\n { allowEmptyList: false, maxItems: MAX_HUMAN_GATE_OPTIONS, maxLength: MAX_HUMAN_GATE_OPTION_LENGTH },\n );\n if (parsed) options = parsed;\n }\n }\n\n let approveOptions: string[] | undefined;\n if (raw.approveOptions !== undefined) {\n if (!Array.isArray(raw.approveOptions)) {\n problems.push(`${where}.humanGate.approveOptions must be a non-empty string array`);\n } else {\n approveOptions = parseUniqueStringList(\n raw.approveOptions,\n `${where}.humanGate.approveOptions`,\n problems,\n { allowEmptyList: false, maxLength: MAX_HUMAN_GATE_OPTION_LENGTH },\n );\n }\n }\n approveOptions ??= options.includes('approve') ? ['approve'] : [options[0]!];\n for (const opt of approveOptions) {\n if (!options.includes(opt)) {\n problems.push(`${where}.humanGate.approveOptions value ${JSON.stringify(opt)} must also appear in options`);\n }\n }\n\n let approvers: string[] = [];\n if (raw.approvers !== undefined) {\n if (!Array.isArray(raw.approvers)) {\n problems.push(`${where}.humanGate.approvers must be a string array`);\n } else {\n approvers = parseUniqueStringList(\n raw.approvers,\n `${where}.humanGate.approvers`,\n problems,\n { allowEmptyList: true },\n ) ?? [];\n }\n }\n\n return { prompt: raw.prompt, options, approveOptions, approvers };\n}\n\nfunction parseUniqueStringList(\n raw: unknown[],\n where: string,\n problems: string[],\n opts: { allowEmptyList: boolean; maxItems?: number; maxLength?: number },\n): string[] | undefined {\n const values: string[] = [];\n const seen = new Set<string>();\n if (!opts.allowEmptyList && raw.length === 0) {\n problems.push(`${where} must not be empty`);\n }\n if (opts.maxItems !== undefined && raw.length > opts.maxItems) {\n problems.push(`${where} supports at most ${opts.maxItems} entries`);\n }\n for (const item of raw) {\n if (typeof item !== 'string' || item.trim() === '') {\n problems.push(`${where} entries must be non-empty strings`);\n continue;\n }\n if (opts.maxLength !== undefined && item.length > opts.maxLength) {\n problems.push(`${where} entry ${JSON.stringify(item)} exceeds ${opts.maxLength} characters`);\n }\n if (seen.has(item)) {\n problems.push(`${where} has duplicate value ${JSON.stringify(item)}`);\n continue;\n }\n seen.add(item);\n values.push(item);\n }\n return problems.some((p) => p.startsWith(where)) ? undefined : values;\n}\n\nfunction normStringArray(v: unknown, where: string, problems: string[]): string[] {\n if (v === undefined) return [];\n if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) {\n problems.push(`${where} must be an array of strings`);\n return [];\n }\n return v as string[];\n}\n\n/** An edge predicate parked during the per-node pass: validated against the\n * SOURCE node's resultSchema in the cross-node pass, then written back into\n * `ref.when`. */\ninterface PendingWhen {\n where: string;\n ref: V3DependRef;\n raw: Record<string, unknown>;\n}\n\n/**\n * Normalize a `depends` array of `string | { from, when? }` entries into\n * `V3DependRef[]`. `when` objects are NOT validated here (the source's\n * resultSchema may not be collected yet) \u2014 they are parked in `whenSink` for\n * the cross-node pass. `whenSink === undefined` means conditional edges are\n * not allowed in this position (loop bodies, first cut).\n */\nfunction normDepends(\n v: unknown,\n where: string,\n problems: string[],\n whenSink?: { ownerId: string; list: PendingWhen[] },\n): V3DependRef[] {\n if (v === undefined) return [];\n if (!Array.isArray(v)) {\n problems.push(`${where}.depends must be an array of nodeId strings or { from, when? } objects`);\n return [];\n }\n const out: V3DependRef[] = [];\n for (let j = 0; j < v.length; j++) {\n const entry = v[j];\n if (typeof entry === 'string') {\n out.push({ from: entry });\n continue;\n }\n if (isObject(entry) && typeof entry.from === 'string') {\n const extra = Object.keys(entry).filter((k) => k !== 'from' && k !== 'when');\n if (extra.length > 0) {\n problems.push(`${where}.depends[${j}] has unsupported key(s): ${extra.join(', ')} (allowed: from, when)`);\n continue;\n }\n const ref: V3DependRef = { from: entry.from };\n if (entry.when !== undefined) {\n if (!whenSink) {\n problems.push(`${where}.depends[${j}].when: conditional edges are not supported inside a loop body (first cut)`);\n continue;\n }\n if (!isObject(entry.when)) {\n problems.push(`${where}.depends[${j}].when must be an object`);\n continue;\n }\n whenSink.list.push({\n where: `${where}.depends[${j}].when (edge \"${entry.from}\" -> \"${whenSink.ownerId}\")`,\n ref,\n raw: entry.when,\n });\n }\n out.push(ref);\n continue;\n }\n problems.push(`${where}.depends[${j}] must be a nodeId string or { from, when? }`);\n }\n return out;\n}\n\n/**\n * Validate `triggerRule` (design \u00A71.2). Bounds depend on the node's indegree:\n * a join rule on a node with no incoming edges is an authoring error, and a\n * quorum must be satisfiable (1..indegree).\n */\nfunction normTriggerRule(\n v: unknown,\n indegree: number,\n where: string,\n problems: string[],\n): V3TriggerRule | undefined {\n if (v === undefined) return undefined;\n if (v === 'all_success' || v === 'one_success') {\n if (indegree === 0) {\n problems.push(`${where}.triggerRule requires at least one incoming edge (depends is empty)`);\n return undefined;\n }\n return v;\n }\n if (isObject(v)) {\n const extra = Object.keys(v).filter((k) => k !== 'quorum');\n if (extra.length > 0) {\n problems.push(`${where}.triggerRule object only supports { quorum: N } (got extra: ${extra.join(', ')})`);\n return undefined;\n }\n if (indegree === 0) {\n problems.push(`${where}.triggerRule requires at least one incoming edge (depends is empty)`);\n return undefined;\n }\n const q = v.quorum;\n if (typeof q !== 'number' || !Number.isInteger(q) || q < 1 || q > indegree) {\n problems.push(`${where}.triggerRule.quorum must be an integer in [1, ${indegree}] (got ${JSON.stringify(q)})`);\n return undefined;\n }\n return { quorum: q };\n }\n problems.push(`${where}.triggerRule must be 'all_success' | 'one_success' | { quorum: N }`);\n return undefined;\n}\n\n/**\n * Validate the per-node capability override (P2). Fail-loud on unknown keys\n * (incl. the deferred `toolsSubset` \u2014 better an explicit \"not yet\" than a\n * field the runtime silently ignores). Permissions are not part of this\n * object: workflow workers always require CLI bypass permission.\n */\nfunction normOverride(\n v: unknown,\n where: string,\n problems: string[],\n): V3CapabilityOverride | undefined {\n if (v === undefined || v === null) return undefined;\n if (!isObject(v)) {\n problems.push(`${where}.override must be an object`);\n return undefined;\n }\n const known = new Set(['model', 'systemPromptAppend']);\n const extra = Object.keys(v).filter((k) => !known.has(k));\n if (extra.length > 0) {\n const hints: string[] = [];\n if (extra.includes('toolsSubset')) hints.push('toolsSubset is deferred \u2014 P2b');\n if (extra.includes('permissionMode')) {\n hints.push('permissionMode was removed \u2014 v3 workflow workers always require CLI bypass; delete this key');\n }\n const hint = hints.length > 0 ? ` (${hints.join('; ')})` : '';\n problems.push(`${where}.override has unsupported key(s): ${extra.join(', ')}${hint} (allowed: model, systemPromptAppend)`);\n return undefined;\n }\n const out: V3CapabilityOverride = {};\n if (v.model !== undefined) {\n if (typeof v.model !== 'string' || v.model.trim() === '' || v.model.length > MAX_OVERRIDE_MODEL_LENGTH) {\n problems.push(`${where}.override.model must be a non-empty string \u2264${MAX_OVERRIDE_MODEL_LENGTH} chars`);\n return undefined;\n }\n out.model = v.model.trim();\n }\n if (v.systemPromptAppend !== undefined) {\n if (\n typeof v.systemPromptAppend !== 'string' ||\n v.systemPromptAppend.trim() === '' ||\n Buffer.byteLength(v.systemPromptAppend, 'utf-8') > MAX_OVERRIDE_SYSTEM_PROMPT_APPEND\n ) {\n problems.push(`${where}.override.systemPromptAppend must be a non-empty string \u2264${MAX_OVERRIDE_SYSTEM_PROMPT_APPEND} bytes`);\n return undefined;\n }\n out.systemPromptAppend = v.systemPromptAppend;\n }\n if (Object.keys(out).length === 0) {\n problems.push(`${where}.override must set at least one of model / systemPromptAppend`);\n return undefined;\n }\n return out;\n}\n\nfunction normTimeoutSec(v: unknown, where: string, problems: string[]): number | undefined {\n if (v === undefined) return undefined;\n if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {\n problems.push(`${where}.timeoutSec must be a positive number`);\n return undefined;\n }\n if (v > MAX_NODE_TIMEOUT_SEC) {\n problems.push(`${where}.timeoutSec ${v} exceeds the ${MAX_NODE_TIMEOUT_SEC}s (4h) ceiling`);\n return undefined;\n }\n return v;\n}\n\n/** Normalize `revisitTo`: an optional array of non-empty path-safe node ids.\n * Shape only here; the ancestor / existence cross-checks run once all ids are\n * collected (validateDag's cross-node pass). */\nfunction normRevisitTo(v: unknown, id: string, problems: string[]): string[] | undefined {\n if (v === undefined) return undefined;\n if (!Array.isArray(v)) {\n problems.push(`node \"${id}\".revisitTo must be an array of node ids`);\n return undefined;\n }\n const out: string[] = [];\n for (const entry of v) {\n if (typeof entry !== 'string' || entry.trim() === '') {\n problems.push(`node \"${id}\".revisitTo entries must be non-empty node-id strings`);\n continue;\n }\n out.push(entry);\n }\n if (new Set(out).size !== out.length) problems.push(`node \"${id}\".revisitTo has duplicates`);\n return out.length > 0 ? out : undefined;\n}\n\n/** Transitive ancestors of `nodeId` over `depends` edges (the set of nodes\n * from which `nodeId` is reachable downstream). Used to constrain\n * `revisitTo` to backward-only jumps. Pure BFS over the (acyclic-by-design)\n * definition graph. */\nfunction ancestorsOf(nodeId: string, nodes: V3Node[]): Set<string> {\n const byId = new Map(nodes.map((n) => [n.id, n]));\n const seen = new Set<string>();\n const queue = [...(byId.get(nodeId)?.depends.map((d) => d.from) ?? [])];\n while (queue.length > 0) {\n const cur = queue.shift()!;\n if (seen.has(cur)) continue;\n seen.add(cur);\n for (const dep of byId.get(cur)?.depends ?? []) queue.push(dep.from);\n }\n return seen;\n}\n\nfunction downstreamCone(nodeId: string, nodes: V3Node[]): Set<string> {\n const seen = new Set<string>([nodeId]);\n let changed = true;\n while (changed) {\n changed = false;\n for (const node of nodes) {\n if (seen.has(node.id)) continue;\n if (node.depends.some((dep) => seen.has(dep.from))) {\n seen.add(node.id);\n changed = true;\n }\n }\n }\n return seen;\n}\n\nfunction nodeByIdUnsafe(nodes: V3Node[], nodeId: string): V3Node | undefined {\n return nodes.find((node) => node.id === nodeId);\n}\n\n/**\n * Validate + normalize a loop node's composite fields. Self-contained: the\n * body is its own little DAG (goal nodes only, internal refs, acyclic), and\n * exit/feedback/output all reference INTO the body, so every cross-check lives\n * here rather than in the top-level pass. Returns `undefined` (with problems\n * pushed) on any violation.\n */\nfunction normLoopFields(\n n: Record<string, unknown>,\n id: string,\n problems: string[],\n): Pick<V3LoopNode, 'maxIterations' | 'body' | 'exit' | 'feedback' | 'output' | 'onExhausted' | 'sessionPolicy'> | undefined {\n const where = `loop node \"${id}\"`;\n const before = problems.length;\n\n // Fields that make no sense on a composite node \u2014 reject loudly rather than\n // silently ignore (same fail-loud stance as the resultSchema subset).\n if (n.timeoutSec !== undefined) {\n problems.push(`${where}.timeoutSec is not supported \u2014 set timeoutSec on body nodes instead`);\n }\n if (n.resultSchema !== undefined) {\n problems.push(`${where}.resultSchema is not supported \u2014 declare it on the exit body node`);\n }\n if (n.humanGate != null) {\n problems.push(`${where}.humanGate is not supported (first cut) \u2014 gate an upstream node instead`);\n }\n if (n.onExhausted !== undefined && n.onExhausted !== 'blocked') {\n problems.push(`${where}.onExhausted only supports \"blocked\"`);\n }\n if (n.sessionPolicy !== undefined && n.sessionPolicy !== 'fresh') {\n problems.push(`${where}.sessionPolicy only supports \"fresh\" (resumeWithinLoop is deferred)`);\n }\n if (n.override !== undefined) {\n problems.push(`${where}.override is not supported \u2014 a loop composite never spawns a worker; set override on body nodes instead`);\n }\n\n let maxIterations: number | undefined;\n if (typeof n.maxIterations !== 'number' || !Number.isInteger(n.maxIterations) || n.maxIterations < 1) {\n problems.push(`${where}.maxIterations must be a positive integer`);\n } else if (n.maxIterations > MAX_LOOP_ITERATIONS) {\n problems.push(`${where}.maxIterations ${n.maxIterations} exceeds the ${MAX_LOOP_ITERATIONS} ceiling`);\n } else {\n maxIterations = n.maxIterations;\n }\n\n // \u2500\u2500 body: a small inline DAG of goal nodes \u2500\u2500\n const bodyRaw = n.body;\n if (!isObject(bodyRaw) || !Array.isArray(bodyRaw.nodes) || bodyRaw.nodes.length === 0) {\n problems.push(`${where}.body.nodes must be a non-empty array`);\n return undefined; // exit/feedback/output are unverifiable without a body\n }\n const bodyBefore = problems.length;\n const bodyIds = new Set<string>();\n const bodyNodes: V3Node[] = [];\n for (let j = 0; j < bodyRaw.nodes.length; j++) {\n const b = bodyRaw.nodes[j];\n const bwhere = `${where}.body.nodes[${j}]`;\n if (!isObject(b)) {\n problems.push(`${bwhere} must be an object`);\n continue;\n }\n const bid = b.id;\n if (typeof bid !== 'string' || !SEGMENT_RE.test(bid)) {\n problems.push(`${bwhere}.id must be a path-safe string matching ${SEGMENT_RE} (got ${JSON.stringify(bid)})`);\n continue;\n }\n if (bodyIds.has(bid)) {\n problems.push(`${where}.body has duplicate node id \"${bid}\"`);\n continue;\n }\n bodyIds.add(bid);\n if (b.type !== 'goal') {\n problems.push(`${where}.body node \"${bid}\": only \"goal\" nodes are allowed in a loop body (no nested loops, no host)`);\n continue;\n }\n if (typeof b.goal !== 'string' || b.goal.trim() === '') {\n problems.push(`${where}.body node \"${bid}\".goal must be a non-empty string`);\n }\n if (b.humanGate != null) {\n problems.push(`${where}.body node \"${bid}\".humanGate is not supported inside a loop body (first cut)`);\n }\n if (b.triggerRule !== undefined) {\n problems.push(`${where}.body node \"${bid}\".triggerRule is not supported inside a loop body (first cut)`);\n }\n // No whenSink: conditional edges are rejected inside a body (first cut).\n const bdepends = normDepends(b.depends, `${where}.body node \"${bid}\"`, problems);\n const bFromList = bdepends.map((d) => d.from);\n if (bFromList.includes(bid)) problems.push(`${where}.body node \"${bid}\" depends on itself`);\n if (new Set(bFromList).size !== bFromList.length) problems.push(`${where}.body node \"${bid}\".depends has duplicates`);\n const binputs = normInputs(b.inputs, `${id}.body.${bid}`, problems);\n const btimeout = normTimeoutSec(b.timeoutSec, `${where}.body node \"${bid}\"`, problems);\n const bschema = normResultSchema(b.resultSchema, `${id}.body.${bid}`, problems);\n const boverride = normOverride(b.override, `${where}.body node \"${bid}\"`, problems);\n bodyNodes.push({\n id: bid,\n type: 'goal',\n goal: typeof b.goal === 'string' ? b.goal : undefined,\n bot: typeof b.bot === 'string' ? b.bot : undefined,\n depends: bdepends,\n override: boverride,\n inputs: binputs,\n timeoutSec: btimeout,\n humanGate: null,\n resultSchema: bschema,\n });\n }\n // Body-internal references.\n for (const bn of bodyNodes) {\n for (const dep of bn.depends) {\n if (!bodyIds.has(dep.from)) problems.push(`${where}.body node \"${bn.id}\" depends on unknown body node \"${dep.from}\"`);\n }\n for (const inp of bn.inputs) {\n if (!bodyIds.has(inp.from)) {\n problems.push(`${where}.body node \"${bn.id}\".inputs references unknown body node \"${inp.from}\"`);\n } else if (!bn.depends.some((d) => d.from === inp.from)) {\n problems.push(`${where}.body node \"${bn.id}\".inputs.from \"${inp.from}\" must also be in depends`);\n }\n }\n }\n // Body acyclic \u2014 only checkable once its refs are sane (topologicalOrder\n // assumes valid deps).\n if (problems.length === bodyBefore && bodyNodes.length === bodyRaw.nodes.length) {\n try {\n topologicalOrder({ runId: 'body', nodes: bodyNodes });\n } catch (err) {\n if (err instanceof DagValidationError) {\n for (const p of err.problems) problems.push(`${where}.body: ${p}`);\n } else {\n throw err;\n }\n }\n }\n\n // \u2500\u2500 exit \u2500\u2500\n let exit: V3LoopExit | undefined;\n const exitRaw = n.exit;\n if (!isObject(exitRaw) || typeof exitRaw.node !== 'string' || !isObject(exitRaw.when)) {\n problems.push(`${where}.exit must be { node: <bodyId>, when: { path, <operator> } }`);\n } else if (!bodyIds.has(exitRaw.node)) {\n problems.push(`${where}.exit.node \"${exitRaw.node}\" is not a body node`);\n } else {\n const exitNode = bodyNodes.find((b) => b.id === exitRaw.node);\n if (!exitNode?.resultSchema) {\n problems.push(`${where}.exit.node \"${exitRaw.node}\" must declare a resultSchema \u2014 the exit decision reads its structured result`);\n } else {\n const when = normLoopExitWhen(exitRaw.when, exitNode.resultSchema, `${where}.exit.when`, problems);\n if (when) exit = { node: exitRaw.node, when };\n }\n }\n\n // \u2500\u2500 feedback: previous-iteration product references \u2500\u2500\n const feedback: string[] = [];\n if (n.feedback !== undefined) {\n if (!Array.isArray(n.feedback) || n.feedback.some((x) => typeof x !== 'string')) {\n problems.push(`${where}.feedback must be an array of strings`);\n } else {\n for (const ref of n.feedback as string[]) {\n const dot = ref.lastIndexOf('.');\n const bodyId = dot > 0 ? ref.slice(0, dot) : '';\n const kind = dot > 0 ? ref.slice(dot + 1) : '';\n if (!bodyIds.has(bodyId) || !['result', 'files', 'manifest'].includes(kind)) {\n problems.push(`${where}.feedback \"${ref}\" must be <bodyId>.result | <bodyId>.files | <bodyId>.manifest`);\n continue;\n }\n if (kind === 'result' && !bodyNodes.find((b) => b.id === bodyId)?.resultSchema) {\n problems.push(`${where}.feedback \"${ref}\" requires body node \"${bodyId}\" to declare a resultSchema`);\n continue;\n }\n if (feedback.includes(ref)) {\n problems.push(`${where}.feedback has duplicate \"${ref}\"`);\n continue;\n }\n feedback.push(ref);\n }\n }\n }\n\n // \u2500\u2500 output projection (defaults to the exit node) \u2500\u2500\n let output: V3LoopOutput | undefined;\n if (n.output !== undefined) {\n if (!isObject(n.output) || typeof n.output.from !== 'string' || !bodyIds.has(n.output.from)) {\n problems.push(`${where}.output must be { from: <bodyId> }`);\n } else {\n output = { from: n.output.from };\n }\n } else if (exit) {\n output = { from: exit.node };\n }\n\n if (problems.length > before || maxIterations === undefined || !exit || !output) return undefined;\n return {\n maxIterations,\n body: { nodes: bodyNodes },\n exit,\n feedback,\n output,\n onExhausted: 'blocked',\n sessionPolicy: 'fresh',\n };\n}\n\n/**\n * Validate the exit predicate against the exit node's resultSchema:\n * `path` must be `result.<key>` for a DECLARED + REQUIRED key, and the single\n * comparison operator must be type-compatible with the key (boolean/string \u2192\n * equals/notEquals; number \u2192 also gt/gte/lt/lte; array/object \u2192 unusable).\n */\nfunction normLoopExitWhen(\n v: Record<string, unknown>,\n schema: V3ResultSchema,\n where: string,\n problems: string[],\n): V3LoopExitWhen | undefined {\n const unknown = Object.keys(v).filter((k) => k !== 'path' && !(LOOP_WHEN_OPERATORS as readonly string[]).includes(k));\n if (unknown.length > 0) {\n problems.push(`${where} has unsupported keyword(s): ${unknown.join(', ')} (allowed: path + one of ${LOOP_WHEN_OPERATORS.join('/')})`);\n return undefined;\n }\n const m = typeof v.path === 'string' ? /^result\\.([A-Za-z0-9_-]+)$/.exec(v.path) : null;\n if (!m) {\n problems.push(`${where}.path must be \"result.<key>\" (the resultSchema subset is flat \u2014 no deeper paths)`);\n return undefined;\n }\n const key = m[1]!;\n const prop = schema.properties[key];\n if (!prop) {\n problems.push(`${where}.path references \"${key}\", which is not declared in the exit node's resultSchema`);\n return undefined;\n }\n if (!(schema.required ?? []).includes(key)) {\n problems.push(`${where}.path references \"${key}\", which must be in the exit node's resultSchema.required (otherwise the field may be absent at runtime)`);\n return undefined;\n }\n const ops = LOOP_WHEN_OPERATORS.filter((op) => v[op] !== undefined);\n if (ops.length !== 1) {\n problems.push(`${where} must set exactly ONE operator (${LOOP_WHEN_OPERATORS.join('/')})`);\n return undefined;\n }\n const op = ops[0]!;\n const operand = v[op];\n if (prop.type === 'array' || prop.type === 'object') {\n problems.push(`${where}: cannot compare \"${key}\" \u2014 exit predicates only support string/number/boolean fields`);\n return undefined;\n }\n if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') {\n if (prop.type !== 'number') {\n problems.push(`${where}.${op} requires \"${key}\" to be a number field (it is ${prop.type})`);\n return undefined;\n }\n if (typeof operand !== 'number' || !Number.isFinite(operand)) {\n problems.push(`${where}.${op} must be a finite number`);\n return undefined;\n }\n } else {\n // equals / notEquals \u2014 operand must match the field's primitive type.\n if (typeof operand !== prop.type) {\n problems.push(`${where}.${op} must be a ${prop.type} to match \"${key}\"`);\n return undefined;\n }\n // Enum reconciliation (edge-activation design \u00A72.3): when the field\n // declares a vocabulary, an operand outside it is a validate-time typo,\n // not a runtime surprise \u2014 the seedclaw `decision_values` equivalent.\n if (prop.type === 'string' && prop.enum && !prop.enum.includes(operand as string)) {\n problems.push(\n `${where}.${op} value ${JSON.stringify(operand)} is not in \"${key}\"'s enum [${prop.enum.join(', ')}]`,\n );\n return undefined;\n }\n }\n return { path: v.path as string, [op]: operand } as V3LoopExitWhen;\n}\n\n/**\n * Validate the opt-in `resultSchema` against the supported subset. Strict on\n * purpose: unknown keywords are REJECTED (not ignored) so a schema the\n * validator silently wouldn't enforce can never enter a dag (codex v2 of the\n * blocked design). Caps: \u226432 properties, \u22644KB serialized, flat (depth 1).\n */\nfunction normResultSchema(v: unknown, id: string, problems: string[]): V3ResultSchema | undefined {\n if (v === undefined || v === null) return undefined;\n const where = `node \"${id}\".resultSchema`;\n if (!isObject(v)) {\n problems.push(`${where} must be an object`);\n return undefined;\n }\n const knownTop = new Set(['type', 'properties', 'required']);\n for (const key of Object.keys(v)) {\n if (!knownTop.has(key)) {\n problems.push(`${where} has unsupported keyword \"${key}\" (subset allows: type/properties/required)`);\n return undefined;\n }\n }\n if (v.type !== 'object') {\n problems.push(`${where}.type must be \"object\"`);\n return undefined;\n }\n if (!isObject(v.properties) || Object.keys(v.properties).length === 0) {\n problems.push(`${where}.properties must be a non-empty object`);\n return undefined;\n }\n const props = Object.entries(v.properties);\n if (props.length > RESULT_SCHEMA_MAX_PROPERTIES) {\n problems.push(`${where} has ${props.length} properties (max ${RESULT_SCHEMA_MAX_PROPERTIES})`);\n return undefined;\n }\n const properties: Record<string, { type: V3ResultFieldType; enum?: string[] }> = {};\n for (const [name, spec] of props) {\n if (!isObject(spec)) {\n problems.push(`${where}.properties.${name} must be an object`);\n return undefined;\n }\n for (const key of Object.keys(spec)) {\n if (key !== 'type' && key !== 'enum') {\n problems.push(`${where}.properties.${name} has unsupported keyword \"${key}\" (subset allows: type, enum)`);\n return undefined;\n }\n }\n if (!RESULT_FIELD_TYPES.includes(spec.type as V3ResultFieldType)) {\n problems.push(`${where}.properties.${name}.type must be one of ${RESULT_FIELD_TYPES.join(' | ')}`);\n return undefined;\n }\n let enumValues: string[] | undefined;\n if (spec.enum !== undefined) {\n // enum on STRING fields only (edge-activation design \u00A71.3) \u2014 it anchors\n // edge-predicate vocabulary; other types have nothing to enumerate.\n if (spec.type !== 'string') {\n problems.push(`${where}.properties.${name}.enum is only supported on string fields (it is ${String(spec.type)})`);\n return undefined;\n }\n if (!Array.isArray(spec.enum) || spec.enum.length === 0 || spec.enum.some((x) => typeof x !== 'string' || x.length === 0)) {\n problems.push(`${where}.properties.${name}.enum must be a non-empty array of non-empty strings`);\n return undefined;\n }\n if (spec.enum.length > RESULT_ENUM_MAX_VALUES) {\n problems.push(`${where}.properties.${name}.enum has ${spec.enum.length} values (max ${RESULT_ENUM_MAX_VALUES})`);\n return undefined;\n }\n if (new Set(spec.enum).size !== spec.enum.length) {\n problems.push(`${where}.properties.${name}.enum has duplicates`);\n return undefined;\n }\n const tooLong = (spec.enum as string[]).filter((x) => x.length > RESULT_ENUM_MAX_VALUE_LENGTH);\n if (tooLong.length > 0) {\n problems.push(`${where}.properties.${name}.enum value(s) exceed ${RESULT_ENUM_MAX_VALUE_LENGTH} chars: ${tooLong.join(', ')}`);\n return undefined;\n }\n enumValues = spec.enum as string[];\n }\n properties[name] = enumValues ? { type: spec.type as V3ResultFieldType, enum: enumValues } : { type: spec.type as V3ResultFieldType };\n }\n let required: string[] | undefined;\n if (v.required !== undefined) {\n if (!Array.isArray(v.required) || v.required.some((x) => typeof x !== 'string')) {\n problems.push(`${where}.required must be an array of strings`);\n return undefined;\n }\n const unknown = (v.required as string[]).filter((r) => !(r in properties));\n if (unknown.length > 0) {\n problems.push(`${where}.required references undeclared properties: ${unknown.join(', ')}`);\n return undefined;\n }\n if (new Set(v.required).size !== v.required.length) {\n problems.push(`${where}.required has duplicates`);\n return undefined;\n }\n required = v.required as string[];\n }\n const schema: V3ResultSchema = required ? { type: 'object', properties, required } : { type: 'object', properties };\n const bytes = Buffer.byteLength(JSON.stringify(schema), 'utf-8');\n if (bytes > RESULT_SCHEMA_MAX_BYTES) {\n problems.push(`${where} serializes to ${bytes} bytes (max ${RESULT_SCHEMA_MAX_BYTES})`);\n return undefined;\n }\n return schema;\n}\n\nfunction normInputs(v: unknown, id: string, problems: string[]): V3InputRef[] {\n if (v === undefined) return [];\n if (!Array.isArray(v)) {\n problems.push(`node \"${id}\".inputs must be an array`);\n return [];\n }\n const out: V3InputRef[] = [];\n for (let j = 0; j < v.length; j++) {\n const inp = v[j];\n if (!isObject(inp) || typeof inp.from !== 'string') {\n problems.push(`node \"${id}\".inputs[${j}] must be { from: <nodeId>, select? }`);\n continue;\n }\n const extra = Object.keys(inp).filter((k) => k !== 'from' && k !== 'select');\n if (extra.length > 0) {\n problems.push(`node \"${id}\".inputs[${j}] has unsupported key(s): ${extra.join(', ')} (allowed: from, select)`);\n continue;\n }\n if (inp.select === undefined) {\n out.push({ from: inp.from });\n continue;\n }\n // P3 selector: exactly one of name/path, both non-empty strings.\n if (!isObject(inp.select)) {\n problems.push(`node \"${id}\".inputs[${j}].select must be { name: <string> } or { path: <string> }`);\n continue;\n }\n const selKeys = Object.keys(inp.select);\n const badKeys = selKeys.filter((k) => k !== 'name' && k !== 'path');\n if (badKeys.length > 0 || selKeys.length !== 1) {\n problems.push(`node \"${id}\".inputs[${j}].select must set exactly ONE of name / path`);\n continue;\n }\n const selVal = inp.select.name ?? inp.select.path;\n if (typeof selVal !== 'string' || selVal.trim() === '') {\n problems.push(`node \"${id}\".inputs[${j}].select.${selKeys[0]} must be a non-empty string`);\n continue;\n }\n out.push({\n from: inp.from,\n select: inp.select.name !== undefined ? { name: selVal } : { path: selVal },\n });\n }\n return out;\n}\n\n// \u2500\u2500\u2500 Topological order \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Deterministic topological order via Kahn's algorithm. Ties (nodes with the\n * same remaining in-degree available at once) are broken by ascending id so\n * the schedule is stable across runs \u2014 important for reproducible journals.\n * Throws if the graph contains a cycle (lists the offending nodes).\n *\n * Assumes `depends` already reference existing nodes; `validateDag` enforces\n * that before calling here.\n */\nexport function topologicalOrder(dag: V3Dag): string[] {\n const indeg = new Map<string, number>();\n const adj = new Map<string, string[]>(); // dep \u2192 dependents\n for (const node of dag.nodes) {\n indeg.set(node.id, indeg.get(node.id) ?? 0);\n if (!adj.has(node.id)) adj.set(node.id, []);\n }\n for (const node of dag.nodes) {\n // Conditional and unconditional edges alike count for ordering/acyclicity\n // (edge-activation design H2): an edge that may never activate is still a\n // structural edge \u2014 the graph must be acyclic regardless of run outcomes.\n for (const dep of node.depends) {\n indeg.set(node.id, (indeg.get(node.id) ?? 0) + 1);\n adj.get(dep.from)!.push(node.id);\n }\n }\n\n // Ready set kept sorted for deterministic tie-breaking.\n const ready = [...indeg.entries()].filter(([, d]) => d === 0).map(([id]) => id).sort();\n const order: string[] = [];\n while (ready.length > 0) {\n const id = ready.shift()!;\n order.push(id);\n for (const next of adj.get(id) ?? []) {\n const d = indeg.get(next)! - 1;\n indeg.set(next, d);\n if (d === 0) {\n // Insert keeping `ready` sorted.\n const pos = lowerBound(ready, next);\n ready.splice(pos, 0, next);\n }\n }\n }\n\n if (order.length !== dag.nodes.length) {\n const stuck = dag.nodes.map((n) => n.id).filter((id) => !order.includes(id));\n throw new DagValidationError([`dag has a cycle among nodes: ${stuck.join(', ')}`]);\n }\n return order;\n}\n\n/** Index of the first element in sorted `arr` not less than `x`. */\nfunction lowerBound(arr: string[], x: string): number {\n let lo = 0;\n let hi = arr.length;\n while (lo < hi) {\n const mid = (lo + hi) >> 1;\n if (arr[mid]! < x) lo = mid + 1;\n else hi = mid;\n }\n return lo;\n}\n", "import {\n DEFAULT_REVISIT_BUDGET_PER_PAIR,\n DEFAULT_REVISIT_BUDGET_PER_RUN,\n type V3LoopExitWhen,\n} from './dag.js';\nimport type { StoredEvent } from './event-contract.js';\n\n/** Pure loop/conditional predicate evaluation after DAG validation. */\nexport function matchLoopExitWhen(when: V3LoopExitWhen, value: unknown): boolean {\n if (when.equals !== undefined) return value === when.equals;\n if (when.notEquals !== undefined) return value !== when.notEquals;\n if (typeof value !== 'number') return false;\n if (when.gt !== undefined) return value > when.gt;\n if (when.gte !== undefined) return value >= when.gte;\n if (when.lt !== undefined) return value < when.lt;\n if (when.lte !== undefined) return value <= when.lte;\n return false;\n}\n\n/**\n * Pure two-tier anti-runaway policy for cross-node revisits.\n * Grant events extend exactly one pair or the whole run by one.\n */\nexport function revisitBudgetStatus(\n events: StoredEvent[],\n sourceNodeId: string,\n toNodeId: string,\n): { ok: true } | { ok: false; tier: 'pair' | 'run'; detail: string } {\n let pairUsed = 0;\n let runUsed = 0;\n let pairGranted = 0;\n let runGranted = 0;\n for (const event of events) {\n if (event.type === 'nodeRevisitRequested') {\n runUsed++;\n if (event.nodeId === sourceNodeId && event.toNodeId === toNodeId) pairUsed++;\n } else if (event.type === 'revisitBudgetGranted') {\n if (event.sourceNodeId === sourceNodeId && event.toNodeId === toNodeId) pairGranted++;\n else if (event.sourceNodeId === undefined && event.toNodeId === undefined) runGranted++;\n }\n }\n const pairLimit = DEFAULT_REVISIT_BUDGET_PER_PAIR + pairGranted;\n const runLimit = DEFAULT_REVISIT_BUDGET_PER_RUN + runGranted;\n if (pairUsed >= pairLimit) {\n return {\n ok: false,\n tier: 'pair',\n detail: `revisit budget exhausted for ${sourceNodeId}->${toNodeId} (${pairUsed}/${pairLimit}) \u2014 grant +1 (this pair) to continue`,\n };\n }\n if (runUsed >= runLimit) {\n return {\n ok: false,\n tier: 'run',\n detail: `run-wide revisit budget exhausted (${runUsed}/${runLimit}) \u2014 grant +1 (run) to continue`,\n };\n }\n return { ok: true };\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACuLO,IAAM,kCAAkC;AACxC,IAAM,iCAAiC;;;AChLvC,SAAS,kBAAkB,MAAsB,OAAyB;AAC/E,MAAI,KAAK,WAAW,OAAW,QAAO,UAAU,KAAK;AACrD,MAAI,KAAK,cAAc,OAAW,QAAO,UAAU,KAAK;AACxD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,KAAK,OAAO,OAAW,QAAO,QAAQ,KAAK;AAC/C,MAAI,KAAK,QAAQ,OAAW,QAAO,SAAS,KAAK;AACjD,MAAI,KAAK,OAAO,OAAW,QAAO,QAAQ,KAAK;AAC/C,MAAI,KAAK,QAAQ,OAAW,QAAO,SAAS,KAAK;AACjD,SAAO;AACT;AAMO,SAAS,oBACd,QACA,cACA,UACoE;AACpE,MAAI,WAAW;AACf,MAAI,UAAU;AACd,MAAI,cAAc;AAClB,MAAI,aAAa;AACjB,aAAW,SAAS,QAAQ;AAC1B,QAAI,MAAM,SAAS,wBAAwB;AACzC;AACA,UAAI,MAAM,WAAW,gBAAgB,MAAM,aAAa,SAAU;AAAA,IACpE,WAAW,MAAM,SAAS,wBAAwB;AAChD,UAAI,MAAM,iBAAiB,gBAAgB,MAAM,aAAa,SAAU;AAAA,eAC/D,MAAM,iBAAiB,UAAa,MAAM,aAAa,OAAW;AAAA,IAC7E;AAAA,EACF;AACA,QAAM,YAAY,kCAAkC;AACpD,QAAM,WAAW,iCAAiC;AAClD,MAAI,YAAY,WAAW;AACzB,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,QAAQ,gCAAgC,YAAY,KAAK,QAAQ,KAAK,QAAQ,IAAI,SAAS;AAAA,IAC7F;AAAA,EACF;AACA,MAAI,WAAW,UAAU;AACvB,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,QAAQ,sCAAsC,OAAO,IAAI,QAAQ;AAAA,IACnE;AAAA,EACF;AACA,SAAO,EAAE,IAAI,KAAK;AACpB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|