botmux 2.93.1 → 2.95.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/dist/adapters/backend/tmux-backend.d.ts +18 -2
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +45 -3
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +8 -0
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +1 -1
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +6 -1
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/bot-registry.d.ts +50 -1
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +30 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/workflow.d.ts.map +1 -1
- package/dist/cli/workflow.js +11 -4
- package/dist/cli/workflow.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +146 -1
- package/dist/cli.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +28 -3
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +92 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/inherit-peer.d.ts +5 -0
- package/dist/core/inherit-peer.d.ts.map +1 -1
- package/dist/core/inherit-peer.js +3 -0
- package/dist/core/inherit-peer.js.map +1 -1
- package/dist/core/session-discovery.d.ts +9 -0
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +24 -0
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +7 -6
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/terminal-url.d.ts.map +1 -1
- package/dist/core/terminal-url.js +15 -0
- package/dist/core/terminal-url.js.map +1 -1
- package/dist/core/trigger-session.js +2 -2
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +30 -0
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +296 -23
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +5 -0
- package/dist/dashboard/aggregator.d.ts.map +1 -1
- package/dist/dashboard/aggregator.js +7 -0
- package/dist/dashboard/aggregator.js.map +1 -1
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +7 -1
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +2 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +2 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/connector-api.d.ts.map +1 -1
- package/dist/dashboard/connector-api.js +14 -3
- package/dist/dashboard/connector-api.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +2 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +7 -0
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/v3-runs-api.d.ts +26 -0
- package/dist/dashboard/v3-runs-api.d.ts.map +1 -0
- package/dist/dashboard/v3-runs-api.js +67 -0
- package/dist/dashboard/v3-runs-api.js.map +1 -0
- package/dist/dashboard/web/app.js +21 -9
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js +128 -33
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +42 -6
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/legacy-workflow-link.d.ts +2 -0
- package/dist/dashboard/web/legacy-workflow-link.d.ts.map +1 -0
- package/dist/dashboard/web/legacy-workflow-link.js +12 -0
- package/dist/dashboard/web/legacy-workflow-link.js.map +1 -0
- package/dist/dashboard/web/settings.d.ts.map +1 -1
- package/dist/dashboard/web/settings.js +11 -0
- package/dist/dashboard/web/settings.js.map +1 -1
- package/dist/dashboard/web/v3-terminal.d.ts +11 -0
- package/dist/dashboard/web/v3-terminal.d.ts.map +1 -0
- package/dist/dashboard/web/v3-terminal.js +143 -0
- package/dist/dashboard/web/v3-terminal.js.map +1 -0
- package/dist/dashboard/web/v3.d.ts +2 -0
- package/dist/dashboard/web/v3.d.ts.map +1 -0
- package/dist/dashboard/web/v3.js +564 -0
- package/dist/dashboard/web/v3.js.map +1 -0
- package/dist/dashboard/web/whiteboards.d.ts.map +1 -1
- package/dist/dashboard/web/whiteboards.js +14 -5
- package/dist/dashboard/web/whiteboards.js.map +1 -1
- package/dist/dashboard/web/workflows.d.ts.map +1 -1
- package/dist/dashboard/web/workflows.js +12 -8
- package/dist/dashboard/web/workflows.js.map +1 -1
- package/dist/dashboard-web/app.js +834 -724
- package/dist/dashboard-web/apple-touch-icon.png +0 -0
- package/dist/dashboard-web/favicon.png +0 -0
- package/dist/dashboard-web/index.html +3 -1
- package/dist/dashboard-web/style.css +223 -0
- package/dist/dashboard.js +170 -10
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +10 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +8 -0
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +23 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +23 -3
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +12 -0
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +28 -0
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +3 -12
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +2 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +20 -6
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/grant-command.js +4 -4
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/message-parser.d.ts +29 -1
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +35 -1
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/v3-blocked-card-handler.d.ts +28 -0
- package/dist/im/lark/v3-blocked-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-blocked-card-handler.js +134 -0
- package/dist/im/lark/v3-blocked-card-handler.js.map +1 -0
- package/dist/im/lark/v3-blocked-card.d.ts +75 -0
- package/dist/im/lark/v3-blocked-card.d.ts.map +1 -0
- package/dist/im/lark/v3-blocked-card.js +206 -0
- package/dist/im/lark/v3-blocked-card.js.map +1 -0
- package/dist/im/lark/v3-gate-card-handler.d.ts +31 -0
- package/dist/im/lark/v3-gate-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-gate-card-handler.js +110 -0
- package/dist/im/lark/v3-gate-card-handler.js.map +1 -0
- package/dist/im/lark/v3-gate-card.d.ts +47 -0
- package/dist/im/lark/v3-gate-card.d.ts.map +1 -0
- package/dist/im/lark/v3-gate-card.js +119 -0
- package/dist/im/lark/v3-gate-card.js.map +1 -0
- package/dist/im/lark/v3-loop-grant-card-handler.d.ts +28 -0
- package/dist/im/lark/v3-loop-grant-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-loop-grant-card-handler.js +90 -0
- package/dist/im/lark/v3-loop-grant-card-handler.js.map +1 -0
- package/dist/im/lark/v3-loop-grant-card.d.ts +46 -0
- package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -0
- package/dist/im/lark/v3-loop-grant-card.js +119 -0
- package/dist/im/lark/v3-loop-grant-card.js.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.d.ts +29 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.js +96 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.js.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card.d.ts +52 -0
- package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card.js +123 -0
- package/dist/im/lark/v3-revisit-grant-card.js.map +1 -0
- package/dist/im/lark/workflow-cards.d.ts.map +1 -1
- package/dist/im/lark/workflow-cards.js +5 -1
- package/dist/im/lark/workflow-cards.js.map +1 -1
- package/dist/im/lark/workflow-progress-card.d.ts +1 -1
- package/dist/im/lark/workflow-progress-card.js +1 -1
- package/dist/im/lark/workflow-slash-command.d.ts +28 -0
- package/dist/im/lark/workflow-slash-command.d.ts.map +1 -1
- package/dist/im/lark/workflow-slash-command.js +61 -5
- package/dist/im/lark/workflow-slash-command.js.map +1 -1
- package/dist/platform/binding.d.ts +6 -0
- package/dist/platform/binding.d.ts.map +1 -1
- package/dist/platform/binding.js +17 -0
- package/dist/platform/binding.js.map +1 -1
- package/dist/platform/tunnel-client.d.ts +2 -0
- package/dist/platform/tunnel-client.d.ts.map +1 -1
- package/dist/platform/tunnel-client.js.map +1 -1
- package/dist/services/bot-config-store.d.ts.map +1 -1
- package/dist/services/bot-config-store.js +1 -0
- package/dist/services/bot-config-store.js.map +1 -1
- package/dist/services/card-prefs-store.d.ts +4 -0
- package/dist/services/card-prefs-store.d.ts.map +1 -1
- package/dist/services/card-prefs-store.js +19 -0
- package/dist/services/card-prefs-store.js.map +1 -1
- package/dist/services/oncall-store.d.ts +25 -0
- package/dist/services/oncall-store.d.ts.map +1 -1
- package/dist/services/oncall-store.js +56 -0
- package/dist/services/oncall-store.js.map +1 -1
- package/dist/services/transcript-resolver.d.ts.map +1 -1
- package/dist/services/transcript-resolver.js +21 -4
- package/dist/services/transcript-resolver.js.map +1 -1
- package/dist/setup/bot-config-editor.d.ts +7 -0
- package/dist/setup/bot-config-editor.d.ts.map +1 -1
- package/dist/setup/bot-config-editor.js +33 -0
- package/dist/setup/bot-config-editor.js.map +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +208 -4
- package/dist/skills/definitions.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/terminal-favicon.d.ts +2 -0
- package/dist/utils/terminal-favicon.d.ts.map +1 -0
- package/dist/utils/terminal-favicon.js +5 -0
- package/dist/utils/terminal-favicon.js.map +1 -0
- package/dist/worker.js +152 -8
- package/dist/worker.js.map +1 -1
- package/dist/workflows/definition.d.ts +80 -80
- package/dist/workflows/events/payloads.d.ts +4 -4
- package/dist/workflows/events/schema.d.ts +16 -16
- package/dist/workflows/v3/architect.d.ts +37 -0
- package/dist/workflows/v3/architect.d.ts.map +1 -0
- package/dist/workflows/v3/architect.js +226 -0
- package/dist/workflows/v3/architect.js.map +1 -0
- package/dist/workflows/v3/bot-resolve.d.ts +21 -0
- package/dist/workflows/v3/bot-resolve.d.ts.map +1 -0
- package/dist/workflows/v3/bot-resolve.js +48 -0
- package/dist/workflows/v3/bot-resolve.js.map +1 -0
- package/dist/workflows/v3/cli-run.d.ts +24 -0
- package/dist/workflows/v3/cli-run.d.ts.map +1 -0
- package/dist/workflows/v3/cli-run.js +265 -0
- package/dist/workflows/v3/cli-run.js.map +1 -0
- package/dist/workflows/v3/contract.d.ts +302 -0
- package/dist/workflows/v3/contract.d.ts.map +1 -0
- package/dist/workflows/v3/contract.js +91 -0
- package/dist/workflows/v3/contract.js.map +1 -0
- package/dist/workflows/v3/daemon-run.d.ts +311 -0
- package/dist/workflows/v3/daemon-run.d.ts.map +1 -0
- package/dist/workflows/v3/daemon-run.js +775 -0
- package/dist/workflows/v3/daemon-run.js.map +1 -0
- package/dist/workflows/v3/dag.d.ts +332 -0
- package/dist/workflows/v3/dag.d.ts.map +1 -0
- package/dist/workflows/v3/dag.js +1040 -0
- package/dist/workflows/v3/dag.js.map +1 -0
- package/dist/workflows/v3/ephemeral-pool.d.ts +29 -0
- package/dist/workflows/v3/ephemeral-pool.d.ts.map +1 -0
- package/dist/workflows/v3/ephemeral-pool.js +331 -0
- package/dist/workflows/v3/ephemeral-pool.js.map +1 -0
- package/dist/workflows/v3/grill-state.d.ts +92 -0
- package/dist/workflows/v3/grill-state.d.ts.map +1 -0
- package/dist/workflows/v3/grill-state.js +151 -0
- package/dist/workflows/v3/grill-state.js.map +1 -0
- package/dist/workflows/v3/host.d.ts +88 -0
- package/dist/workflows/v3/host.d.ts.map +1 -0
- package/dist/workflows/v3/host.js +349 -0
- package/dist/workflows/v3/host.js.map +1 -0
- package/dist/workflows/v3/human-gate.d.ts +85 -0
- package/dist/workflows/v3/human-gate.d.ts.map +1 -0
- package/dist/workflows/v3/human-gate.js +153 -0
- package/dist/workflows/v3/human-gate.js.map +1 -0
- package/dist/workflows/v3/journal.d.ts +207 -0
- package/dist/workflows/v3/journal.d.ts.map +1 -0
- package/dist/workflows/v3/journal.js +74 -0
- package/dist/workflows/v3/journal.js.map +1 -0
- package/dist/workflows/v3/manifest.d.ts +17 -0
- package/dist/workflows/v3/manifest.d.ts.map +1 -0
- package/dist/workflows/v3/manifest.js +266 -0
- package/dist/workflows/v3/manifest.js.map +1 -0
- package/dist/workflows/v3/ops-projection.d.ts +110 -0
- package/dist/workflows/v3/ops-projection.d.ts.map +1 -0
- package/dist/workflows/v3/ops-projection.js +0 -0
- package/dist/workflows/v3/ops-projection.js.map +1 -0
- package/dist/workflows/v3/orchestrator.d.ts +168 -0
- package/dist/workflows/v3/orchestrator.d.ts.map +1 -0
- package/dist/workflows/v3/orchestrator.js +417 -0
- package/dist/workflows/v3/orchestrator.js.map +1 -0
- package/dist/workflows/v3/runtime.d.ts +193 -0
- package/dist/workflows/v3/runtime.d.ts.map +1 -0
- package/dist/workflows/v3/runtime.js +1284 -0
- package/dist/workflows/v3/runtime.js.map +1 -0
- package/dist/workflows/v3/spec.d.ts +32 -0
- package/dist/workflows/v3/spec.d.ts.map +1 -0
- package/dist/workflows/v3/spec.js +173 -0
- package/dist/workflows/v3/spec.js.map +1 -0
- package/dist/workflows/v3/state.d.ts +74 -0
- package/dist/workflows/v3/state.d.ts.map +1 -0
- package/dist/workflows/v3/state.js +353 -0
- package/dist/workflows/v3/state.js.map +1 -0
- package/package.json +2 -2
- package/dist/dashboard/web/workflow-catalog.d.ts +0 -2
- package/dist/dashboard/web/workflow-catalog.d.ts.map +0 -1
- package/dist/dashboard/web/workflow-catalog.js +0 -323
- package/dist/dashboard/web/workflow-catalog.js.map +0 -1
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v3 DAG definition — schema, loader, validator, topological order.
|
|
3
|
+
*
|
|
4
|
+
* The v3 runtime (LLM-driven workflow) loads a hand-written `dag.json`,
|
|
5
|
+
* validates it, and walks it in topological order with deps gating. This
|
|
6
|
+
* module is the *schema half* of the engine: pure data + validation, no IO
|
|
7
|
+
* side effects beyond reading the file in `loadDag`.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately standalone from v0.2's `definition.ts` — v3 nodes are a much
|
|
10
|
+
* smaller surface (goal / host, no loop / decision / fanout) and coupling the
|
|
11
|
+
* two schemas would drag v0.2's complexity into the new engine. See
|
|
12
|
+
* `docs/design/2026-06-01-v3-mvp-engine-split.md` §3 for the authored shape.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
export const NODE_KINDS = ['goal', 'host', 'loop'];
|
|
16
|
+
/** Default per-node wall-clock budget when a node omits `timeoutSec`.
|
|
17
|
+
* Generous on purpose: completion is detected by the manifest watcher
|
|
18
|
+
* (seconds after the agent finishes), so the timeout only fires for hung
|
|
19
|
+
* nodes — a long default costs nothing on the happy path. The architect is
|
|
20
|
+
* prompted to set per-node `timeoutSec` explicitly for long tasks. */
|
|
21
|
+
export const DEFAULT_NODE_TIMEOUT_SEC = 1800;
|
|
22
|
+
/** Hard ceiling for per-node `timeoutSec` (4h) — rejects runaway budgets the
|
|
23
|
+
* architect might hallucinate while still allowing genuinely long tasks. */
|
|
24
|
+
export const MAX_NODE_TIMEOUT_SEC = 14400;
|
|
25
|
+
export const DEFAULT_HUMAN_GATE_OPTIONS = ['approve', 'reject'];
|
|
26
|
+
export const MAX_HUMAN_GATE_OPTIONS = 8;
|
|
27
|
+
export const MAX_HUMAN_GATE_OPTION_LENGTH = 32;
|
|
28
|
+
export const MAX_OVERRIDE_MODEL_LENGTH = 64;
|
|
29
|
+
export const MAX_OVERRIDE_SYSTEM_PROMPT_APPEND = 8000;
|
|
30
|
+
const RESULT_FIELD_TYPES = ['string', 'number', 'boolean', 'array', 'object'];
|
|
31
|
+
/** Caps on the resultSchema subset (anti-runaway: a giant schema bloats the
|
|
32
|
+
* goal prompt and the validator). Checked at validateDag time. */
|
|
33
|
+
export const RESULT_SCHEMA_MAX_PROPERTIES = 32;
|
|
34
|
+
export const RESULT_SCHEMA_MAX_BYTES = 4096;
|
|
35
|
+
/** Caps on a string property's `enum` (anti prompt-bloat; counted inside the
|
|
36
|
+
* 4KB schema budget like everything else). */
|
|
37
|
+
export const RESULT_ENUM_MAX_VALUES = 16;
|
|
38
|
+
export const RESULT_ENUM_MAX_VALUE_LENGTH = 64;
|
|
39
|
+
/** Backstop ceiling for `maxIterations` — like the timeout cap, it rejects a
|
|
40
|
+
* runaway budget the architect might hallucinate; a human can still grant
|
|
41
|
+
* extra iterations one at a time once the loop blocks. */
|
|
42
|
+
export const MAX_LOOP_ITERATIONS = 20;
|
|
43
|
+
/** Cross-node revisit budgets (anti-infinite-loop). Two tiers:
|
|
44
|
+
* - PER-PAIR (source→target): how many times one node may revisit one ancestor
|
|
45
|
+
* before the run blocks — default 1 (a node sends each ancestor back once;
|
|
46
|
+
* expected multi-round rework belongs in a structured loop, not ad-hoc
|
|
47
|
+
* revisit). Pinpoints which edge is ping-ponging.
|
|
48
|
+
* - PER-RUN: total revisits across the whole run — a generous backstop so many
|
|
49
|
+
* distinct pairs (or many nodes revisiting) can't run away.
|
|
50
|
+
* Exhaustion blocks the run; a human grants +1 (revisitBudgetGranted). */
|
|
51
|
+
export const DEFAULT_REVISIT_BUDGET_PER_PAIR = 1;
|
|
52
|
+
export const DEFAULT_REVISIT_BUDGET_PER_RUN = 8;
|
|
53
|
+
const LOOP_WHEN_OPERATORS = ['equals', 'notEquals', 'gt', 'gte', 'lt', 'lte'];
|
|
54
|
+
/** Narrowing guard: a validated goal node always has a non-empty `goal`. */
|
|
55
|
+
export function isGoalNode(node) {
|
|
56
|
+
return node.type === 'goal' && typeof node.goal === 'string' && node.goal.length > 0;
|
|
57
|
+
}
|
|
58
|
+
/** Narrowing guard for validated loop nodes. */
|
|
59
|
+
export function isLoopNode(node) {
|
|
60
|
+
return node.type === 'loop';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The expanded id a body node instance runs under in iteration N:
|
|
64
|
+
* `repairLoop.i001.code`. Path-safe by construction (loopId/bodyId are
|
|
65
|
+
* SEGMENT_RE, `.` is in the charset) and free of the `:` the blocked-card
|
|
66
|
+
* nonce uses as a separator. OPAQUE — never parse this string back; journal
|
|
67
|
+
* events carry a structured `loop: {loopId, iteration, bodyNodeId}` instead.
|
|
68
|
+
*/
|
|
69
|
+
export function loopInstanceId(loopId, iteration, bodyNodeId) {
|
|
70
|
+
return `${loopId}.i${String(iteration).padStart(3, '0')}.${bodyNodeId}`;
|
|
71
|
+
}
|
|
72
|
+
// ─── Validation ─────────────────────────────────────────────────────────
|
|
73
|
+
/** Thrown by `validateDag` / `loadDag` with every problem found, not just the
|
|
74
|
+
* first — authoring a DAG by hand is iterative, so surface the full list. */
|
|
75
|
+
export class DagValidationError extends Error {
|
|
76
|
+
problems;
|
|
77
|
+
constructor(problems) {
|
|
78
|
+
super(`Invalid v3 dag.json:\n - ${problems.join('\n - ')}`);
|
|
79
|
+
this.problems = problems;
|
|
80
|
+
this.name = 'DagValidationError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Node ids and runId double as filesystem path segments under the runDir. */
|
|
84
|
+
const SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
85
|
+
function isObject(v) {
|
|
86
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Validate an untrusted parsed value into a `V3Dag`. Pure — throws
|
|
90
|
+
* `DagValidationError` with the full problem list on any violation, otherwise
|
|
91
|
+
* returns a normalized dag (defaults filled, `humanGate: undefined` → `null`).
|
|
92
|
+
*
|
|
93
|
+
* Checks: runId shape; non-empty unique path-safe node ids; known `type`;
|
|
94
|
+
* `goal` non-empty for goal nodes; `host` rejected (executor not yet built);
|
|
95
|
+
* `depends` reference existing nodes, no self-dep, no dup `from` (P0: one
|
|
96
|
+
* edge per (from,to)); edge predicates validated against the SOURCE's
|
|
97
|
+
* resultSchema (goal-with-schema sources only); `triggerRule` shape/bounds;
|
|
98
|
+
* `inputs.from` reference existing nodes AND appear in `depends`; acyclic
|
|
99
|
+
* (delegated to `topologicalOrder`, conditional edges included).
|
|
100
|
+
*/
|
|
101
|
+
export function validateDag(raw) {
|
|
102
|
+
const problems = [];
|
|
103
|
+
if (!isObject(raw)) {
|
|
104
|
+
throw new DagValidationError(['root must be a JSON object']);
|
|
105
|
+
}
|
|
106
|
+
if (typeof raw.runId !== 'string' || !SEGMENT_RE.test(raw.runId)) {
|
|
107
|
+
problems.push(`runId must be a path-safe string matching ${SEGMENT_RE} (got ${JSON.stringify(raw.runId)})`);
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {
|
|
110
|
+
throw new DagValidationError([...problems, 'nodes must be a non-empty array']);
|
|
111
|
+
}
|
|
112
|
+
const ids = new Set();
|
|
113
|
+
const nodes = [];
|
|
114
|
+
// Edge predicates parked until every node (and thus every source's
|
|
115
|
+
// resultSchema) is collected — validated in the cross-node pass below.
|
|
116
|
+
const pendingWhens = [];
|
|
117
|
+
for (let i = 0; i < raw.nodes.length; i++) {
|
|
118
|
+
const n = raw.nodes[i];
|
|
119
|
+
const where = `nodes[${i}]`;
|
|
120
|
+
if (!isObject(n)) {
|
|
121
|
+
problems.push(`${where} must be an object`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const id = n.id;
|
|
125
|
+
if (typeof id !== 'string' || !SEGMENT_RE.test(id)) {
|
|
126
|
+
problems.push(`${where}.id must be a path-safe string matching ${SEGMENT_RE} (got ${JSON.stringify(id)})`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (ids.has(id)) {
|
|
130
|
+
problems.push(`duplicate node id "${id}"`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
ids.add(id);
|
|
134
|
+
const type = n.type;
|
|
135
|
+
if (type !== 'goal' && type !== 'host' && type !== 'loop') {
|
|
136
|
+
problems.push(`node "${id}".type must be one of ${NODE_KINDS.join(' | ')} (got ${JSON.stringify(type)})`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (type === 'host') {
|
|
140
|
+
problems.push(`node "${id}": type "host" is reserved but not yet executable in MVP — use "goal"`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const depends = normDepends(n.depends, `node "${id}"`, problems, { ownerId: id, list: pendingWhens });
|
|
144
|
+
const fromList = depends.map((d) => d.from);
|
|
145
|
+
if (fromList.includes(id))
|
|
146
|
+
problems.push(`node "${id}" depends on itself`);
|
|
147
|
+
if (new Set(fromList).size !== fromList.length)
|
|
148
|
+
problems.push(`node "${id}".depends has duplicates`);
|
|
149
|
+
const triggerRule = normTriggerRule(n.triggerRule, depends.length, `node "${id}"`, problems);
|
|
150
|
+
const inputs = normInputs(n.inputs, id, problems);
|
|
151
|
+
if (type === 'loop') {
|
|
152
|
+
const loopFields = normLoopFields(n, id, problems);
|
|
153
|
+
if (loopFields) {
|
|
154
|
+
nodes.push({
|
|
155
|
+
id,
|
|
156
|
+
type,
|
|
157
|
+
goal: typeof n.goal === 'string' ? n.goal : undefined,
|
|
158
|
+
bot: typeof n.bot === 'string' ? n.bot : undefined,
|
|
159
|
+
depends,
|
|
160
|
+
triggerRule,
|
|
161
|
+
inputs,
|
|
162
|
+
humanGate: null,
|
|
163
|
+
...loopFields,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (typeof n.goal !== 'string' || n.goal.trim() === '') {
|
|
169
|
+
problems.push(`goal node "${id}".goal must be a non-empty string`);
|
|
170
|
+
}
|
|
171
|
+
const timeoutSec = normTimeoutSec(n.timeoutSec, `node "${id}"`, problems);
|
|
172
|
+
const resultSchema = normResultSchema(n.resultSchema, id, problems);
|
|
173
|
+
const humanGate = normHumanGate(n.humanGate, `node "${id}"`, problems);
|
|
174
|
+
const override = normOverride(n.override, `node "${id}"`, problems);
|
|
175
|
+
const revisitTo = normRevisitTo(n.revisitTo, id, problems);
|
|
176
|
+
nodes.push({
|
|
177
|
+
id,
|
|
178
|
+
type,
|
|
179
|
+
goal: typeof n.goal === 'string' ? n.goal : undefined,
|
|
180
|
+
bot: typeof n.bot === 'string' ? n.bot : undefined,
|
|
181
|
+
depends,
|
|
182
|
+
triggerRule,
|
|
183
|
+
override,
|
|
184
|
+
inputs,
|
|
185
|
+
timeoutSec,
|
|
186
|
+
humanGate,
|
|
187
|
+
resultSchema,
|
|
188
|
+
...(revisitTo ? { revisitTo } : {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// Cross-node reference checks — only meaningful once ids are collected.
|
|
192
|
+
for (const node of nodes) {
|
|
193
|
+
for (const dep of node.depends) {
|
|
194
|
+
if (!ids.has(dep.from))
|
|
195
|
+
problems.push(`node "${node.id}" depends on unknown node "${dep.from}"`);
|
|
196
|
+
}
|
|
197
|
+
for (const inp of node.inputs) {
|
|
198
|
+
if (!ids.has(inp.from)) {
|
|
199
|
+
problems.push(`node "${node.id}".inputs references unknown node "${inp.from}"`);
|
|
200
|
+
}
|
|
201
|
+
else if (!node.depends.some((d) => d.from === inp.from)) {
|
|
202
|
+
problems.push(`node "${node.id}".inputs.from "${inp.from}" must also be in depends`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// revisitTo: each target must exist AND be a (transitive) ANCESTOR of this
|
|
206
|
+
// node — a revisit only ever jumps BACKWARD, so the definition graph stays
|
|
207
|
+
// acyclic and the supersede cone is well-defined (cross-node回溯 design).
|
|
208
|
+
if (node.revisitTo && node.revisitTo.length > 0) {
|
|
209
|
+
const ancestors = ancestorsOf(node.id, nodes);
|
|
210
|
+
for (const target of node.revisitTo) {
|
|
211
|
+
if (!ids.has(target)) {
|
|
212
|
+
problems.push(`node "${node.id}".revisitTo references unknown node "${target}"`);
|
|
213
|
+
}
|
|
214
|
+
else if (target === node.id) {
|
|
215
|
+
problems.push(`node "${node.id}".revisitTo cannot point at itself`);
|
|
216
|
+
}
|
|
217
|
+
else if (!ancestors.has(target)) {
|
|
218
|
+
problems.push(`node "${node.id}".revisitTo "${target}" must be an ancestor (reachable via depends) — revisit only jumps backward`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Edge-predicate validation (design §2): the source must be a goal node
|
|
224
|
+
// declaring a resultSchema — loop sources are forbidden in P0 (a loop's
|
|
225
|
+
// outward manifest belongs to its output-projection body node; put an
|
|
226
|
+
// explicit verifier goal after the loop instead), and host sources are
|
|
227
|
+
// unreachable (host itself is rejected above). The predicate reuses the
|
|
228
|
+
// loop-exit validator: declared + required key, exactly one operator,
|
|
229
|
+
// type-compatible, enum-reconciled.
|
|
230
|
+
const nodeById = new Map(nodes.map((nn) => [nn.id, nn]));
|
|
231
|
+
for (const pw of pendingWhens) {
|
|
232
|
+
const source = nodeById.get(pw.ref.from);
|
|
233
|
+
if (!source)
|
|
234
|
+
continue; // unknown `from` already reported above
|
|
235
|
+
if (source.type !== 'goal') {
|
|
236
|
+
problems.push(`${pw.where}: conditional edge source "${pw.ref.from}" must be a goal node ` +
|
|
237
|
+
`(P0 forbids loop sources — add a verifier goal after the loop and branch on ITS result)`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!source.resultSchema) {
|
|
241
|
+
problems.push(`${pw.where}: conditional edge source "${pw.ref.from}" must declare a resultSchema — the predicate reads its structured result`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const when = normLoopExitWhen(pw.raw, source.resultSchema, pw.where, problems);
|
|
245
|
+
if (when)
|
|
246
|
+
pw.ref.when = when;
|
|
247
|
+
}
|
|
248
|
+
// Loop expansion namespace guard: iteration instances run under
|
|
249
|
+
// `<loopId>.iNNN.<bodyId>` (see loopInstanceId), so no OTHER top-level id may
|
|
250
|
+
// sit inside a loop's dot-prefix — an authored `repairLoop.i001.code` node
|
|
251
|
+
// would collide with the expansion. Plain ids may still contain dots.
|
|
252
|
+
for (const node of nodes) {
|
|
253
|
+
if (node.type !== 'loop')
|
|
254
|
+
continue;
|
|
255
|
+
for (const other of ids) {
|
|
256
|
+
if (other !== node.id && other.startsWith(`${node.id}.`)) {
|
|
257
|
+
problems.push(`node id "${other}" collides with loop "${node.id}" expansion namespace ("${node.id}.*")`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (problems.length > 0)
|
|
262
|
+
throw new DagValidationError(problems);
|
|
263
|
+
const dag = { runId: raw.runId, nodes };
|
|
264
|
+
// Cycle detection: topologicalOrder throws on a cycle. Run it here so
|
|
265
|
+
// loadDag rejects a cyclic DAG up front rather than mid-run.
|
|
266
|
+
topologicalOrder(dag);
|
|
267
|
+
return dag;
|
|
268
|
+
}
|
|
269
|
+
function normHumanGate(raw, where, problems) {
|
|
270
|
+
if (raw == null)
|
|
271
|
+
return null;
|
|
272
|
+
if (!isObject(raw) || typeof raw.prompt !== 'string' || raw.prompt.trim() === '') {
|
|
273
|
+
problems.push(`${where}.humanGate must be { prompt: <non-empty string>, options?, approveOptions?, approvers? } or null`);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
let options = [...DEFAULT_HUMAN_GATE_OPTIONS];
|
|
277
|
+
if (raw.options !== undefined) {
|
|
278
|
+
if (!Array.isArray(raw.options)) {
|
|
279
|
+
problems.push(`${where}.humanGate.options must be a non-empty string array`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const parsed = parseUniqueStringList(raw.options, `${where}.humanGate.options`, problems, { allowEmptyList: false, maxItems: MAX_HUMAN_GATE_OPTIONS, maxLength: MAX_HUMAN_GATE_OPTION_LENGTH });
|
|
283
|
+
if (parsed)
|
|
284
|
+
options = parsed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let approveOptions;
|
|
288
|
+
if (raw.approveOptions !== undefined) {
|
|
289
|
+
if (!Array.isArray(raw.approveOptions)) {
|
|
290
|
+
problems.push(`${where}.humanGate.approveOptions must be a non-empty string array`);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
approveOptions = parseUniqueStringList(raw.approveOptions, `${where}.humanGate.approveOptions`, problems, { allowEmptyList: false, maxLength: MAX_HUMAN_GATE_OPTION_LENGTH });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
approveOptions ??= options.includes('approve') ? ['approve'] : [options[0]];
|
|
297
|
+
for (const opt of approveOptions) {
|
|
298
|
+
if (!options.includes(opt)) {
|
|
299
|
+
problems.push(`${where}.humanGate.approveOptions value ${JSON.stringify(opt)} must also appear in options`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
let approvers = [];
|
|
303
|
+
if (raw.approvers !== undefined) {
|
|
304
|
+
if (!Array.isArray(raw.approvers)) {
|
|
305
|
+
problems.push(`${where}.humanGate.approvers must be a string array`);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
approvers = parseUniqueStringList(raw.approvers, `${where}.humanGate.approvers`, problems, { allowEmptyList: true }) ?? [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { prompt: raw.prompt, options, approveOptions, approvers };
|
|
312
|
+
}
|
|
313
|
+
function parseUniqueStringList(raw, where, problems, opts) {
|
|
314
|
+
const values = [];
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
if (!opts.allowEmptyList && raw.length === 0) {
|
|
317
|
+
problems.push(`${where} must not be empty`);
|
|
318
|
+
}
|
|
319
|
+
if (opts.maxItems !== undefined && raw.length > opts.maxItems) {
|
|
320
|
+
problems.push(`${where} supports at most ${opts.maxItems} entries`);
|
|
321
|
+
}
|
|
322
|
+
for (const item of raw) {
|
|
323
|
+
if (typeof item !== 'string' || item.trim() === '') {
|
|
324
|
+
problems.push(`${where} entries must be non-empty strings`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (opts.maxLength !== undefined && item.length > opts.maxLength) {
|
|
328
|
+
problems.push(`${where} entry ${JSON.stringify(item)} exceeds ${opts.maxLength} characters`);
|
|
329
|
+
}
|
|
330
|
+
if (seen.has(item)) {
|
|
331
|
+
problems.push(`${where} has duplicate value ${JSON.stringify(item)}`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
seen.add(item);
|
|
335
|
+
values.push(item);
|
|
336
|
+
}
|
|
337
|
+
return problems.some((p) => p.startsWith(where)) ? undefined : values;
|
|
338
|
+
}
|
|
339
|
+
function normStringArray(v, where, problems) {
|
|
340
|
+
if (v === undefined)
|
|
341
|
+
return [];
|
|
342
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) {
|
|
343
|
+
problems.push(`${where} must be an array of strings`);
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
return v;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Normalize a `depends` array of `string | { from, when? }` entries into
|
|
350
|
+
* `V3DependRef[]`. `when` objects are NOT validated here (the source's
|
|
351
|
+
* resultSchema may not be collected yet) — they are parked in `whenSink` for
|
|
352
|
+
* the cross-node pass. `whenSink === undefined` means conditional edges are
|
|
353
|
+
* not allowed in this position (loop bodies, first cut).
|
|
354
|
+
*/
|
|
355
|
+
function normDepends(v, where, problems, whenSink) {
|
|
356
|
+
if (v === undefined)
|
|
357
|
+
return [];
|
|
358
|
+
if (!Array.isArray(v)) {
|
|
359
|
+
problems.push(`${where}.depends must be an array of nodeId strings or { from, when? } objects`);
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
const out = [];
|
|
363
|
+
for (let j = 0; j < v.length; j++) {
|
|
364
|
+
const entry = v[j];
|
|
365
|
+
if (typeof entry === 'string') {
|
|
366
|
+
out.push({ from: entry });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (isObject(entry) && typeof entry.from === 'string') {
|
|
370
|
+
const extra = Object.keys(entry).filter((k) => k !== 'from' && k !== 'when');
|
|
371
|
+
if (extra.length > 0) {
|
|
372
|
+
problems.push(`${where}.depends[${j}] has unsupported key(s): ${extra.join(', ')} (allowed: from, when)`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const ref = { from: entry.from };
|
|
376
|
+
if (entry.when !== undefined) {
|
|
377
|
+
if (!whenSink) {
|
|
378
|
+
problems.push(`${where}.depends[${j}].when: conditional edges are not supported inside a loop body (first cut)`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (!isObject(entry.when)) {
|
|
382
|
+
problems.push(`${where}.depends[${j}].when must be an object`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
whenSink.list.push({
|
|
386
|
+
where: `${where}.depends[${j}].when (edge "${entry.from}" -> "${whenSink.ownerId}")`,
|
|
387
|
+
ref,
|
|
388
|
+
raw: entry.when,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
out.push(ref);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
problems.push(`${where}.depends[${j}] must be a nodeId string or { from, when? }`);
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Validate `triggerRule` (design §1.2). Bounds depend on the node's indegree:
|
|
400
|
+
* a join rule on a node with no incoming edges is an authoring error, and a
|
|
401
|
+
* quorum must be satisfiable (1..indegree).
|
|
402
|
+
*/
|
|
403
|
+
function normTriggerRule(v, indegree, where, problems) {
|
|
404
|
+
if (v === undefined)
|
|
405
|
+
return undefined;
|
|
406
|
+
if (v === 'all_success' || v === 'one_success') {
|
|
407
|
+
if (indegree === 0) {
|
|
408
|
+
problems.push(`${where}.triggerRule requires at least one incoming edge (depends is empty)`);
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
return v;
|
|
412
|
+
}
|
|
413
|
+
if (isObject(v)) {
|
|
414
|
+
const extra = Object.keys(v).filter((k) => k !== 'quorum');
|
|
415
|
+
if (extra.length > 0) {
|
|
416
|
+
problems.push(`${where}.triggerRule object only supports { quorum: N } (got extra: ${extra.join(', ')})`);
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
if (indegree === 0) {
|
|
420
|
+
problems.push(`${where}.triggerRule requires at least one incoming edge (depends is empty)`);
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
const q = v.quorum;
|
|
424
|
+
if (typeof q !== 'number' || !Number.isInteger(q) || q < 1 || q > indegree) {
|
|
425
|
+
problems.push(`${where}.triggerRule.quorum must be an integer in [1, ${indegree}] (got ${JSON.stringify(q)})`);
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
return { quorum: q };
|
|
429
|
+
}
|
|
430
|
+
problems.push(`${where}.triggerRule must be 'all_success' | 'one_success' | { quorum: N }`);
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Validate the per-node capability override (P2). Fail-loud on unknown keys
|
|
435
|
+
* (incl. the deferred `toolsSubset` — better an explicit "not yet" than a
|
|
436
|
+
* field the runtime silently ignores). `permissionMode` has no 'bypass'
|
|
437
|
+
* value BY CONSTRUCTION — the no-escalation red line lives in the type, not
|
|
438
|
+
* in a runtime check.
|
|
439
|
+
*/
|
|
440
|
+
function normOverride(v, where, problems) {
|
|
441
|
+
if (v === undefined || v === null)
|
|
442
|
+
return undefined;
|
|
443
|
+
if (!isObject(v)) {
|
|
444
|
+
problems.push(`${where}.override must be an object`);
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
const known = new Set(['model', 'permissionMode', 'systemPromptAppend']);
|
|
448
|
+
const extra = Object.keys(v).filter((k) => !known.has(k));
|
|
449
|
+
if (extra.length > 0) {
|
|
450
|
+
const hint = extra.includes('toolsSubset') ? ' (toolsSubset is deferred — P2b)' : '';
|
|
451
|
+
problems.push(`${where}.override has unsupported key(s): ${extra.join(', ')}${hint} (allowed: model, permissionMode, systemPromptAppend)`);
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
const out = {};
|
|
455
|
+
if (v.model !== undefined) {
|
|
456
|
+
if (typeof v.model !== 'string' || v.model.trim() === '' || v.model.length > MAX_OVERRIDE_MODEL_LENGTH) {
|
|
457
|
+
problems.push(`${where}.override.model must be a non-empty string ≤${MAX_OVERRIDE_MODEL_LENGTH} chars`);
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
out.model = v.model.trim();
|
|
461
|
+
}
|
|
462
|
+
if (v.permissionMode !== undefined) {
|
|
463
|
+
if (v.permissionMode !== 'inherit' && v.permissionMode !== 'restricted') {
|
|
464
|
+
problems.push(`${where}.override.permissionMode must be 'inherit' | 'restricted' (there is no 'bypass' — a node can only reduce privilege)`);
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
out.permissionMode = v.permissionMode;
|
|
468
|
+
}
|
|
469
|
+
if (v.systemPromptAppend !== undefined) {
|
|
470
|
+
if (typeof v.systemPromptAppend !== 'string' ||
|
|
471
|
+
v.systemPromptAppend.trim() === '' ||
|
|
472
|
+
Buffer.byteLength(v.systemPromptAppend, 'utf-8') > MAX_OVERRIDE_SYSTEM_PROMPT_APPEND) {
|
|
473
|
+
problems.push(`${where}.override.systemPromptAppend must be a non-empty string ≤${MAX_OVERRIDE_SYSTEM_PROMPT_APPEND} bytes`);
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
out.systemPromptAppend = v.systemPromptAppend;
|
|
477
|
+
}
|
|
478
|
+
if (Object.keys(out).length === 0) {
|
|
479
|
+
problems.push(`${where}.override must set at least one of model / permissionMode / systemPromptAppend`);
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
function normTimeoutSec(v, where, problems) {
|
|
485
|
+
if (v === undefined)
|
|
486
|
+
return undefined;
|
|
487
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
|
|
488
|
+
problems.push(`${where}.timeoutSec must be a positive number`);
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
if (v > MAX_NODE_TIMEOUT_SEC) {
|
|
492
|
+
problems.push(`${where}.timeoutSec ${v} exceeds the ${MAX_NODE_TIMEOUT_SEC}s (4h) ceiling`);
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
return v;
|
|
496
|
+
}
|
|
497
|
+
/** Normalize `revisitTo`: an optional array of non-empty path-safe node ids.
|
|
498
|
+
* Shape only here; the ancestor / existence cross-checks run once all ids are
|
|
499
|
+
* collected (validateDag's cross-node pass). */
|
|
500
|
+
function normRevisitTo(v, id, problems) {
|
|
501
|
+
if (v === undefined)
|
|
502
|
+
return undefined;
|
|
503
|
+
if (!Array.isArray(v)) {
|
|
504
|
+
problems.push(`node "${id}".revisitTo must be an array of node ids`);
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
const out = [];
|
|
508
|
+
for (const entry of v) {
|
|
509
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
510
|
+
problems.push(`node "${id}".revisitTo entries must be non-empty node-id strings`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
out.push(entry);
|
|
514
|
+
}
|
|
515
|
+
if (new Set(out).size !== out.length)
|
|
516
|
+
problems.push(`node "${id}".revisitTo has duplicates`);
|
|
517
|
+
return out.length > 0 ? out : undefined;
|
|
518
|
+
}
|
|
519
|
+
/** Transitive ancestors of `nodeId` over `depends` edges (the set of nodes
|
|
520
|
+
* from which `nodeId` is reachable downstream). Used to constrain
|
|
521
|
+
* `revisitTo` to backward-only jumps. Pure BFS over the (acyclic-by-design)
|
|
522
|
+
* definition graph. */
|
|
523
|
+
function ancestorsOf(nodeId, nodes) {
|
|
524
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
525
|
+
const seen = new Set();
|
|
526
|
+
const queue = [...(byId.get(nodeId)?.depends.map((d) => d.from) ?? [])];
|
|
527
|
+
while (queue.length > 0) {
|
|
528
|
+
const cur = queue.shift();
|
|
529
|
+
if (seen.has(cur))
|
|
530
|
+
continue;
|
|
531
|
+
seen.add(cur);
|
|
532
|
+
for (const dep of byId.get(cur)?.depends ?? [])
|
|
533
|
+
queue.push(dep.from);
|
|
534
|
+
}
|
|
535
|
+
return seen;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Validate + normalize a loop node's composite fields. Self-contained: the
|
|
539
|
+
* body is its own little DAG (goal nodes only, internal refs, acyclic), and
|
|
540
|
+
* exit/feedback/output all reference INTO the body, so every cross-check lives
|
|
541
|
+
* here rather than in the top-level pass. Returns `undefined` (with problems
|
|
542
|
+
* pushed) on any violation.
|
|
543
|
+
*/
|
|
544
|
+
function normLoopFields(n, id, problems) {
|
|
545
|
+
const where = `loop node "${id}"`;
|
|
546
|
+
const before = problems.length;
|
|
547
|
+
// Fields that make no sense on a composite node — reject loudly rather than
|
|
548
|
+
// silently ignore (same fail-loud stance as the resultSchema subset).
|
|
549
|
+
if (n.timeoutSec !== undefined) {
|
|
550
|
+
problems.push(`${where}.timeoutSec is not supported — set timeoutSec on body nodes instead`);
|
|
551
|
+
}
|
|
552
|
+
if (n.resultSchema !== undefined) {
|
|
553
|
+
problems.push(`${where}.resultSchema is not supported — declare it on the exit body node`);
|
|
554
|
+
}
|
|
555
|
+
if (n.humanGate != null) {
|
|
556
|
+
problems.push(`${where}.humanGate is not supported (first cut) — gate an upstream node instead`);
|
|
557
|
+
}
|
|
558
|
+
if (n.onExhausted !== undefined && n.onExhausted !== 'blocked') {
|
|
559
|
+
problems.push(`${where}.onExhausted only supports "blocked"`);
|
|
560
|
+
}
|
|
561
|
+
if (n.sessionPolicy !== undefined && n.sessionPolicy !== 'fresh') {
|
|
562
|
+
problems.push(`${where}.sessionPolicy only supports "fresh" (resumeWithinLoop is deferred)`);
|
|
563
|
+
}
|
|
564
|
+
if (n.override !== undefined) {
|
|
565
|
+
problems.push(`${where}.override is not supported — a loop composite never spawns a worker; set override on body nodes instead`);
|
|
566
|
+
}
|
|
567
|
+
let maxIterations;
|
|
568
|
+
if (typeof n.maxIterations !== 'number' || !Number.isInteger(n.maxIterations) || n.maxIterations < 1) {
|
|
569
|
+
problems.push(`${where}.maxIterations must be a positive integer`);
|
|
570
|
+
}
|
|
571
|
+
else if (n.maxIterations > MAX_LOOP_ITERATIONS) {
|
|
572
|
+
problems.push(`${where}.maxIterations ${n.maxIterations} exceeds the ${MAX_LOOP_ITERATIONS} ceiling`);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
maxIterations = n.maxIterations;
|
|
576
|
+
}
|
|
577
|
+
// ── body: a small inline DAG of goal nodes ──
|
|
578
|
+
const bodyRaw = n.body;
|
|
579
|
+
if (!isObject(bodyRaw) || !Array.isArray(bodyRaw.nodes) || bodyRaw.nodes.length === 0) {
|
|
580
|
+
problems.push(`${where}.body.nodes must be a non-empty array`);
|
|
581
|
+
return undefined; // exit/feedback/output are unverifiable without a body
|
|
582
|
+
}
|
|
583
|
+
const bodyBefore = problems.length;
|
|
584
|
+
const bodyIds = new Set();
|
|
585
|
+
const bodyNodes = [];
|
|
586
|
+
for (let j = 0; j < bodyRaw.nodes.length; j++) {
|
|
587
|
+
const b = bodyRaw.nodes[j];
|
|
588
|
+
const bwhere = `${where}.body.nodes[${j}]`;
|
|
589
|
+
if (!isObject(b)) {
|
|
590
|
+
problems.push(`${bwhere} must be an object`);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const bid = b.id;
|
|
594
|
+
if (typeof bid !== 'string' || !SEGMENT_RE.test(bid)) {
|
|
595
|
+
problems.push(`${bwhere}.id must be a path-safe string matching ${SEGMENT_RE} (got ${JSON.stringify(bid)})`);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (bodyIds.has(bid)) {
|
|
599
|
+
problems.push(`${where}.body has duplicate node id "${bid}"`);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
bodyIds.add(bid);
|
|
603
|
+
if (b.type !== 'goal') {
|
|
604
|
+
problems.push(`${where}.body node "${bid}": only "goal" nodes are allowed in a loop body (no nested loops, no host)`);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (typeof b.goal !== 'string' || b.goal.trim() === '') {
|
|
608
|
+
problems.push(`${where}.body node "${bid}".goal must be a non-empty string`);
|
|
609
|
+
}
|
|
610
|
+
if (b.humanGate != null) {
|
|
611
|
+
problems.push(`${where}.body node "${bid}".humanGate is not supported inside a loop body (first cut)`);
|
|
612
|
+
}
|
|
613
|
+
if (b.triggerRule !== undefined) {
|
|
614
|
+
problems.push(`${where}.body node "${bid}".triggerRule is not supported inside a loop body (first cut)`);
|
|
615
|
+
}
|
|
616
|
+
// No whenSink: conditional edges are rejected inside a body (first cut).
|
|
617
|
+
const bdepends = normDepends(b.depends, `${where}.body node "${bid}"`, problems);
|
|
618
|
+
const bFromList = bdepends.map((d) => d.from);
|
|
619
|
+
if (bFromList.includes(bid))
|
|
620
|
+
problems.push(`${where}.body node "${bid}" depends on itself`);
|
|
621
|
+
if (new Set(bFromList).size !== bFromList.length)
|
|
622
|
+
problems.push(`${where}.body node "${bid}".depends has duplicates`);
|
|
623
|
+
const binputs = normInputs(b.inputs, `${id}.body.${bid}`, problems);
|
|
624
|
+
const btimeout = normTimeoutSec(b.timeoutSec, `${where}.body node "${bid}"`, problems);
|
|
625
|
+
const bschema = normResultSchema(b.resultSchema, `${id}.body.${bid}`, problems);
|
|
626
|
+
const boverride = normOverride(b.override, `${where}.body node "${bid}"`, problems);
|
|
627
|
+
bodyNodes.push({
|
|
628
|
+
id: bid,
|
|
629
|
+
type: 'goal',
|
|
630
|
+
goal: typeof b.goal === 'string' ? b.goal : undefined,
|
|
631
|
+
bot: typeof b.bot === 'string' ? b.bot : undefined,
|
|
632
|
+
depends: bdepends,
|
|
633
|
+
override: boverride,
|
|
634
|
+
inputs: binputs,
|
|
635
|
+
timeoutSec: btimeout,
|
|
636
|
+
humanGate: null,
|
|
637
|
+
resultSchema: bschema,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
// Body-internal references.
|
|
641
|
+
for (const bn of bodyNodes) {
|
|
642
|
+
for (const dep of bn.depends) {
|
|
643
|
+
if (!bodyIds.has(dep.from))
|
|
644
|
+
problems.push(`${where}.body node "${bn.id}" depends on unknown body node "${dep.from}"`);
|
|
645
|
+
}
|
|
646
|
+
for (const inp of bn.inputs) {
|
|
647
|
+
if (!bodyIds.has(inp.from)) {
|
|
648
|
+
problems.push(`${where}.body node "${bn.id}".inputs references unknown body node "${inp.from}"`);
|
|
649
|
+
}
|
|
650
|
+
else if (!bn.depends.some((d) => d.from === inp.from)) {
|
|
651
|
+
problems.push(`${where}.body node "${bn.id}".inputs.from "${inp.from}" must also be in depends`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Body acyclic — only checkable once its refs are sane (topologicalOrder
|
|
656
|
+
// assumes valid deps).
|
|
657
|
+
if (problems.length === bodyBefore && bodyNodes.length === bodyRaw.nodes.length) {
|
|
658
|
+
try {
|
|
659
|
+
topologicalOrder({ runId: 'body', nodes: bodyNodes });
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
if (err instanceof DagValidationError) {
|
|
663
|
+
for (const p of err.problems)
|
|
664
|
+
problems.push(`${where}.body: ${p}`);
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// ── exit ──
|
|
672
|
+
let exit;
|
|
673
|
+
const exitRaw = n.exit;
|
|
674
|
+
if (!isObject(exitRaw) || typeof exitRaw.node !== 'string' || !isObject(exitRaw.when)) {
|
|
675
|
+
problems.push(`${where}.exit must be { node: <bodyId>, when: { path, <operator> } }`);
|
|
676
|
+
}
|
|
677
|
+
else if (!bodyIds.has(exitRaw.node)) {
|
|
678
|
+
problems.push(`${where}.exit.node "${exitRaw.node}" is not a body node`);
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
const exitNode = bodyNodes.find((b) => b.id === exitRaw.node);
|
|
682
|
+
if (!exitNode?.resultSchema) {
|
|
683
|
+
problems.push(`${where}.exit.node "${exitRaw.node}" must declare a resultSchema — the exit decision reads its structured result`);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
const when = normLoopExitWhen(exitRaw.when, exitNode.resultSchema, `${where}.exit.when`, problems);
|
|
687
|
+
if (when)
|
|
688
|
+
exit = { node: exitRaw.node, when };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// ── feedback: previous-iteration product references ──
|
|
692
|
+
const feedback = [];
|
|
693
|
+
if (n.feedback !== undefined) {
|
|
694
|
+
if (!Array.isArray(n.feedback) || n.feedback.some((x) => typeof x !== 'string')) {
|
|
695
|
+
problems.push(`${where}.feedback must be an array of strings`);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
for (const ref of n.feedback) {
|
|
699
|
+
const dot = ref.lastIndexOf('.');
|
|
700
|
+
const bodyId = dot > 0 ? ref.slice(0, dot) : '';
|
|
701
|
+
const kind = dot > 0 ? ref.slice(dot + 1) : '';
|
|
702
|
+
if (!bodyIds.has(bodyId) || !['result', 'files', 'manifest'].includes(kind)) {
|
|
703
|
+
problems.push(`${where}.feedback "${ref}" must be <bodyId>.result | <bodyId>.files | <bodyId>.manifest`);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (kind === 'result' && !bodyNodes.find((b) => b.id === bodyId)?.resultSchema) {
|
|
707
|
+
problems.push(`${where}.feedback "${ref}" requires body node "${bodyId}" to declare a resultSchema`);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (feedback.includes(ref)) {
|
|
711
|
+
problems.push(`${where}.feedback has duplicate "${ref}"`);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
feedback.push(ref);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// ── output projection (defaults to the exit node) ──
|
|
719
|
+
let output;
|
|
720
|
+
if (n.output !== undefined) {
|
|
721
|
+
if (!isObject(n.output) || typeof n.output.from !== 'string' || !bodyIds.has(n.output.from)) {
|
|
722
|
+
problems.push(`${where}.output must be { from: <bodyId> }`);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
output = { from: n.output.from };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
else if (exit) {
|
|
729
|
+
output = { from: exit.node };
|
|
730
|
+
}
|
|
731
|
+
if (problems.length > before || maxIterations === undefined || !exit || !output)
|
|
732
|
+
return undefined;
|
|
733
|
+
return {
|
|
734
|
+
maxIterations,
|
|
735
|
+
body: { nodes: bodyNodes },
|
|
736
|
+
exit,
|
|
737
|
+
feedback,
|
|
738
|
+
output,
|
|
739
|
+
onExhausted: 'blocked',
|
|
740
|
+
sessionPolicy: 'fresh',
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Validate the exit predicate against the exit node's resultSchema:
|
|
745
|
+
* `path` must be `result.<key>` for a DECLARED + REQUIRED key, and the single
|
|
746
|
+
* comparison operator must be type-compatible with the key (boolean/string →
|
|
747
|
+
* equals/notEquals; number → also gt/gte/lt/lte; array/object → unusable).
|
|
748
|
+
*/
|
|
749
|
+
function normLoopExitWhen(v, schema, where, problems) {
|
|
750
|
+
const unknown = Object.keys(v).filter((k) => k !== 'path' && !LOOP_WHEN_OPERATORS.includes(k));
|
|
751
|
+
if (unknown.length > 0) {
|
|
752
|
+
problems.push(`${where} has unsupported keyword(s): ${unknown.join(', ')} (allowed: path + one of ${LOOP_WHEN_OPERATORS.join('/')})`);
|
|
753
|
+
return undefined;
|
|
754
|
+
}
|
|
755
|
+
const m = typeof v.path === 'string' ? /^result\.([A-Za-z0-9_-]+)$/.exec(v.path) : null;
|
|
756
|
+
if (!m) {
|
|
757
|
+
problems.push(`${where}.path must be "result.<key>" (the resultSchema subset is flat — no deeper paths)`);
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
const key = m[1];
|
|
761
|
+
const prop = schema.properties[key];
|
|
762
|
+
if (!prop) {
|
|
763
|
+
problems.push(`${where}.path references "${key}", which is not declared in the exit node's resultSchema`);
|
|
764
|
+
return undefined;
|
|
765
|
+
}
|
|
766
|
+
if (!(schema.required ?? []).includes(key)) {
|
|
767
|
+
problems.push(`${where}.path references "${key}", which must be in the exit node's resultSchema.required (otherwise the field may be absent at runtime)`);
|
|
768
|
+
return undefined;
|
|
769
|
+
}
|
|
770
|
+
const ops = LOOP_WHEN_OPERATORS.filter((op) => v[op] !== undefined);
|
|
771
|
+
if (ops.length !== 1) {
|
|
772
|
+
problems.push(`${where} must set exactly ONE operator (${LOOP_WHEN_OPERATORS.join('/')})`);
|
|
773
|
+
return undefined;
|
|
774
|
+
}
|
|
775
|
+
const op = ops[0];
|
|
776
|
+
const operand = v[op];
|
|
777
|
+
if (prop.type === 'array' || prop.type === 'object') {
|
|
778
|
+
problems.push(`${where}: cannot compare "${key}" — exit predicates only support string/number/boolean fields`);
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') {
|
|
782
|
+
if (prop.type !== 'number') {
|
|
783
|
+
problems.push(`${where}.${op} requires "${key}" to be a number field (it is ${prop.type})`);
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
786
|
+
if (typeof operand !== 'number' || !Number.isFinite(operand)) {
|
|
787
|
+
problems.push(`${where}.${op} must be a finite number`);
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
// equals / notEquals — operand must match the field's primitive type.
|
|
793
|
+
if (typeof operand !== prop.type) {
|
|
794
|
+
problems.push(`${where}.${op} must be a ${prop.type} to match "${key}"`);
|
|
795
|
+
return undefined;
|
|
796
|
+
}
|
|
797
|
+
// Enum reconciliation (edge-activation design §2.3): when the field
|
|
798
|
+
// declares a vocabulary, an operand outside it is a validate-time typo,
|
|
799
|
+
// not a runtime surprise — the seedclaw `decision_values` equivalent.
|
|
800
|
+
if (prop.type === 'string' && prop.enum && !prop.enum.includes(operand)) {
|
|
801
|
+
problems.push(`${where}.${op} value ${JSON.stringify(operand)} is not in "${key}"'s enum [${prop.enum.join(', ')}]`);
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return { path: v.path, [op]: operand };
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Validate the opt-in `resultSchema` against the supported subset. Strict on
|
|
809
|
+
* purpose: unknown keywords are REJECTED (not ignored) so a schema the
|
|
810
|
+
* validator silently wouldn't enforce can never enter a dag (codex v2 of the
|
|
811
|
+
* blocked design). Caps: ≤32 properties, ≤4KB serialized, flat (depth 1).
|
|
812
|
+
*/
|
|
813
|
+
function normResultSchema(v, id, problems) {
|
|
814
|
+
if (v === undefined || v === null)
|
|
815
|
+
return undefined;
|
|
816
|
+
const where = `node "${id}".resultSchema`;
|
|
817
|
+
if (!isObject(v)) {
|
|
818
|
+
problems.push(`${where} must be an object`);
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
const knownTop = new Set(['type', 'properties', 'required']);
|
|
822
|
+
for (const key of Object.keys(v)) {
|
|
823
|
+
if (!knownTop.has(key)) {
|
|
824
|
+
problems.push(`${where} has unsupported keyword "${key}" (subset allows: type/properties/required)`);
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (v.type !== 'object') {
|
|
829
|
+
problems.push(`${where}.type must be "object"`);
|
|
830
|
+
return undefined;
|
|
831
|
+
}
|
|
832
|
+
if (!isObject(v.properties) || Object.keys(v.properties).length === 0) {
|
|
833
|
+
problems.push(`${where}.properties must be a non-empty object`);
|
|
834
|
+
return undefined;
|
|
835
|
+
}
|
|
836
|
+
const props = Object.entries(v.properties);
|
|
837
|
+
if (props.length > RESULT_SCHEMA_MAX_PROPERTIES) {
|
|
838
|
+
problems.push(`${where} has ${props.length} properties (max ${RESULT_SCHEMA_MAX_PROPERTIES})`);
|
|
839
|
+
return undefined;
|
|
840
|
+
}
|
|
841
|
+
const properties = {};
|
|
842
|
+
for (const [name, spec] of props) {
|
|
843
|
+
if (!isObject(spec)) {
|
|
844
|
+
problems.push(`${where}.properties.${name} must be an object`);
|
|
845
|
+
return undefined;
|
|
846
|
+
}
|
|
847
|
+
for (const key of Object.keys(spec)) {
|
|
848
|
+
if (key !== 'type' && key !== 'enum') {
|
|
849
|
+
problems.push(`${where}.properties.${name} has unsupported keyword "${key}" (subset allows: type, enum)`);
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (!RESULT_FIELD_TYPES.includes(spec.type)) {
|
|
854
|
+
problems.push(`${where}.properties.${name}.type must be one of ${RESULT_FIELD_TYPES.join(' | ')}`);
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
let enumValues;
|
|
858
|
+
if (spec.enum !== undefined) {
|
|
859
|
+
// enum on STRING fields only (edge-activation design §1.3) — it anchors
|
|
860
|
+
// edge-predicate vocabulary; other types have nothing to enumerate.
|
|
861
|
+
if (spec.type !== 'string') {
|
|
862
|
+
problems.push(`${where}.properties.${name}.enum is only supported on string fields (it is ${String(spec.type)})`);
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
if (!Array.isArray(spec.enum) || spec.enum.length === 0 || spec.enum.some((x) => typeof x !== 'string' || x.length === 0)) {
|
|
866
|
+
problems.push(`${where}.properties.${name}.enum must be a non-empty array of non-empty strings`);
|
|
867
|
+
return undefined;
|
|
868
|
+
}
|
|
869
|
+
if (spec.enum.length > RESULT_ENUM_MAX_VALUES) {
|
|
870
|
+
problems.push(`${where}.properties.${name}.enum has ${spec.enum.length} values (max ${RESULT_ENUM_MAX_VALUES})`);
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
if (new Set(spec.enum).size !== spec.enum.length) {
|
|
874
|
+
problems.push(`${where}.properties.${name}.enum has duplicates`);
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
877
|
+
const tooLong = spec.enum.filter((x) => x.length > RESULT_ENUM_MAX_VALUE_LENGTH);
|
|
878
|
+
if (tooLong.length > 0) {
|
|
879
|
+
problems.push(`${where}.properties.${name}.enum value(s) exceed ${RESULT_ENUM_MAX_VALUE_LENGTH} chars: ${tooLong.join(', ')}`);
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
enumValues = spec.enum;
|
|
883
|
+
}
|
|
884
|
+
properties[name] = enumValues ? { type: spec.type, enum: enumValues } : { type: spec.type };
|
|
885
|
+
}
|
|
886
|
+
let required;
|
|
887
|
+
if (v.required !== undefined) {
|
|
888
|
+
if (!Array.isArray(v.required) || v.required.some((x) => typeof x !== 'string')) {
|
|
889
|
+
problems.push(`${where}.required must be an array of strings`);
|
|
890
|
+
return undefined;
|
|
891
|
+
}
|
|
892
|
+
const unknown = v.required.filter((r) => !(r in properties));
|
|
893
|
+
if (unknown.length > 0) {
|
|
894
|
+
problems.push(`${where}.required references undeclared properties: ${unknown.join(', ')}`);
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
if (new Set(v.required).size !== v.required.length) {
|
|
898
|
+
problems.push(`${where}.required has duplicates`);
|
|
899
|
+
return undefined;
|
|
900
|
+
}
|
|
901
|
+
required = v.required;
|
|
902
|
+
}
|
|
903
|
+
const schema = required ? { type: 'object', properties, required } : { type: 'object', properties };
|
|
904
|
+
const bytes = Buffer.byteLength(JSON.stringify(schema), 'utf-8');
|
|
905
|
+
if (bytes > RESULT_SCHEMA_MAX_BYTES) {
|
|
906
|
+
problems.push(`${where} serializes to ${bytes} bytes (max ${RESULT_SCHEMA_MAX_BYTES})`);
|
|
907
|
+
return undefined;
|
|
908
|
+
}
|
|
909
|
+
return schema;
|
|
910
|
+
}
|
|
911
|
+
function normInputs(v, id, problems) {
|
|
912
|
+
if (v === undefined)
|
|
913
|
+
return [];
|
|
914
|
+
if (!Array.isArray(v)) {
|
|
915
|
+
problems.push(`node "${id}".inputs must be an array`);
|
|
916
|
+
return [];
|
|
917
|
+
}
|
|
918
|
+
const out = [];
|
|
919
|
+
for (let j = 0; j < v.length; j++) {
|
|
920
|
+
const inp = v[j];
|
|
921
|
+
if (!isObject(inp) || typeof inp.from !== 'string') {
|
|
922
|
+
problems.push(`node "${id}".inputs[${j}] must be { from: <nodeId>, select? }`);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
const extra = Object.keys(inp).filter((k) => k !== 'from' && k !== 'select');
|
|
926
|
+
if (extra.length > 0) {
|
|
927
|
+
problems.push(`node "${id}".inputs[${j}] has unsupported key(s): ${extra.join(', ')} (allowed: from, select)`);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (inp.select === undefined) {
|
|
931
|
+
out.push({ from: inp.from });
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
// P3 selector: exactly one of name/path, both non-empty strings.
|
|
935
|
+
if (!isObject(inp.select)) {
|
|
936
|
+
problems.push(`node "${id}".inputs[${j}].select must be { name: <string> } or { path: <string> }`);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const selKeys = Object.keys(inp.select);
|
|
940
|
+
const badKeys = selKeys.filter((k) => k !== 'name' && k !== 'path');
|
|
941
|
+
if (badKeys.length > 0 || selKeys.length !== 1) {
|
|
942
|
+
problems.push(`node "${id}".inputs[${j}].select must set exactly ONE of name / path`);
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
const selVal = inp.select.name ?? inp.select.path;
|
|
946
|
+
if (typeof selVal !== 'string' || selVal.trim() === '') {
|
|
947
|
+
problems.push(`node "${id}".inputs[${j}].select.${selKeys[0]} must be a non-empty string`);
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
out.push({
|
|
951
|
+
from: inp.from,
|
|
952
|
+
select: inp.select.name !== undefined ? { name: selVal } : { path: selVal },
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
return out;
|
|
956
|
+
}
|
|
957
|
+
// ─── Loader ─────────────────────────────────────────────────────────────
|
|
958
|
+
/** Read + JSON.parse + validate a `dag.json` at `path`. Throws
|
|
959
|
+
* `DagValidationError` on a malformed graph and a plain `Error` on read /
|
|
960
|
+
* parse failure (so callers can distinguish "bad file" from "bad graph"). */
|
|
961
|
+
export function loadDag(path) {
|
|
962
|
+
let raw;
|
|
963
|
+
try {
|
|
964
|
+
raw = readFileSync(path, 'utf-8');
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
throw new Error(`v3: cannot read dag.json at ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
968
|
+
}
|
|
969
|
+
let parsed;
|
|
970
|
+
try {
|
|
971
|
+
parsed = JSON.parse(raw);
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
throw new Error(`v3: dag.json at ${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
975
|
+
}
|
|
976
|
+
return validateDag(parsed);
|
|
977
|
+
}
|
|
978
|
+
// ─── Topological order ─────────────────────────────────────────────────
|
|
979
|
+
/**
|
|
980
|
+
* Deterministic topological order via Kahn's algorithm. Ties (nodes with the
|
|
981
|
+
* same remaining in-degree available at once) are broken by ascending id so
|
|
982
|
+
* the schedule is stable across runs — important for reproducible journals.
|
|
983
|
+
* Throws if the graph contains a cycle (lists the offending nodes).
|
|
984
|
+
*
|
|
985
|
+
* Assumes `depends` already reference existing nodes; `validateDag` enforces
|
|
986
|
+
* that before calling here.
|
|
987
|
+
*/
|
|
988
|
+
export function topologicalOrder(dag) {
|
|
989
|
+
const indeg = new Map();
|
|
990
|
+
const adj = new Map(); // dep → dependents
|
|
991
|
+
for (const node of dag.nodes) {
|
|
992
|
+
indeg.set(node.id, indeg.get(node.id) ?? 0);
|
|
993
|
+
if (!adj.has(node.id))
|
|
994
|
+
adj.set(node.id, []);
|
|
995
|
+
}
|
|
996
|
+
for (const node of dag.nodes) {
|
|
997
|
+
// Conditional and unconditional edges alike count for ordering/acyclicity
|
|
998
|
+
// (edge-activation design H2): an edge that may never activate is still a
|
|
999
|
+
// structural edge — the graph must be acyclic regardless of run outcomes.
|
|
1000
|
+
for (const dep of node.depends) {
|
|
1001
|
+
indeg.set(node.id, (indeg.get(node.id) ?? 0) + 1);
|
|
1002
|
+
adj.get(dep.from).push(node.id);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// Ready set kept sorted for deterministic tie-breaking.
|
|
1006
|
+
const ready = [...indeg.entries()].filter(([, d]) => d === 0).map(([id]) => id).sort();
|
|
1007
|
+
const order = [];
|
|
1008
|
+
while (ready.length > 0) {
|
|
1009
|
+
const id = ready.shift();
|
|
1010
|
+
order.push(id);
|
|
1011
|
+
for (const next of adj.get(id) ?? []) {
|
|
1012
|
+
const d = indeg.get(next) - 1;
|
|
1013
|
+
indeg.set(next, d);
|
|
1014
|
+
if (d === 0) {
|
|
1015
|
+
// Insert keeping `ready` sorted.
|
|
1016
|
+
const pos = lowerBound(ready, next);
|
|
1017
|
+
ready.splice(pos, 0, next);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (order.length !== dag.nodes.length) {
|
|
1022
|
+
const stuck = dag.nodes.map((n) => n.id).filter((id) => !order.includes(id));
|
|
1023
|
+
throw new DagValidationError([`dag has a cycle among nodes: ${stuck.join(', ')}`]);
|
|
1024
|
+
}
|
|
1025
|
+
return order;
|
|
1026
|
+
}
|
|
1027
|
+
/** Index of the first element in sorted `arr` not less than `x`. */
|
|
1028
|
+
function lowerBound(arr, x) {
|
|
1029
|
+
let lo = 0;
|
|
1030
|
+
let hi = arr.length;
|
|
1031
|
+
while (lo < hi) {
|
|
1032
|
+
const mid = (lo + hi) >> 1;
|
|
1033
|
+
if (arr[mid] < x)
|
|
1034
|
+
lo = mid + 1;
|
|
1035
|
+
else
|
|
1036
|
+
hi = mid;
|
|
1037
|
+
}
|
|
1038
|
+
return lo;
|
|
1039
|
+
}
|
|
1040
|
+
//# sourceMappingURL=dag.js.map
|