@worca/app 1.0.0 → 1.2.0-rc.1
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/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
// src/core/graph/scheduler.mjs
|
|
2
|
+
// The single-owner dataflow loop of the v2 graph engine: the token store, the
|
|
3
|
+
// drain/launch walk, per-wire loop budgets and their human gates, End completion
|
|
4
|
+
// and the resume-v2 snapshot.
|
|
5
|
+
//
|
|
6
|
+
// Shape of a pass: publishing routes tokens IMMEDIATELY (per-wire delivery counting
|
|
7
|
+
// and gate checks happen AT DELIVERY), but FIRING happens only in the drain loop. A
|
|
8
|
+
// pass walks `loops.launchOrder` ONCE; a node ready at its slot fires AT that slot —
|
|
9
|
+
// agent nodes as an async `execute` under the semaphore, flow nodes inline,
|
|
10
|
+
// whose publishes route immediately and may make LATER slots ready within the same
|
|
11
|
+
// pass. Earlier slots are never revisited mid-pass; passes repeat until a pass fires
|
|
12
|
+
// nothing. The execution sequence is therefore the order of `execute` CALLS, and it
|
|
13
|
+
// is deterministic.
|
|
14
|
+
//
|
|
15
|
+
// EVERY node's execution is routed through the injected `execute` — flow kinds
|
|
16
|
+
// inline and outside the semaphore, agent kinds under it. The scheduler never
|
|
17
|
+
// reads an agent key and does no IO.
|
|
18
|
+
//
|
|
19
|
+
// `rerunPending` coalescing is structural: a node that is already running is skipped
|
|
20
|
+
// in the walk, and readiness is re-evaluated after every completion, so readiness
|
|
21
|
+
// reached while running re-fires exactly once and never queues.
|
|
22
|
+
import { FLOW_KINDS, DEFAULT_MAX_CYCLES, AWAIT_PORT } from '../../shared/graph/constants.mjs';
|
|
23
|
+
import { classifyLoops } from '../../shared/graph/loops.mjs';
|
|
24
|
+
import { firedOutputs, resolveOrOutType } from '../../shared/graph/ports.mjs';
|
|
25
|
+
import { blockingIssues, hasBlocking } from '../../shared/graph/verdict.mjs';
|
|
26
|
+
|
|
27
|
+
/** Execution statuses that need no re-invocation on restore. */
|
|
28
|
+
const TERMINAL = new Set(['done', 'error', 'skipped']);
|
|
29
|
+
/** The reserved synthesized gate input: bound for readiness, never a payload, never a mode. */
|
|
30
|
+
const AWAIT_ID = AWAIT_PORT.id;
|
|
31
|
+
/** The §3 completion warning for a run that quiesced without reaching End (A8: ONE literal). */
|
|
32
|
+
export const QUIESCENCE_WARNING = 'finished at quiescence — End not reached';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The SECOND warning line a quiesced run gets when an output fired into nothing —
|
|
36
|
+
* "finished at quiescence" alone never says WHY. A separate entry, never a longer
|
|
37
|
+
* QUIESCENCE_WARNING: `ui/public/graph/run-decor.mjs` re-derives the base string
|
|
38
|
+
* client-side (`warnings.includes(QUIESCENCE_WARNING)`), so widening it would paint
|
|
39
|
+
* the banner twice.
|
|
40
|
+
* @param {string[]} ids sorted '<nodeId>.<portId>' list
|
|
41
|
+
*/
|
|
42
|
+
export const quiescenceDeadEnd = (ids) =>
|
|
43
|
+
`dead-ended output${ids.length === 1 ? '' : 's'}: ${ids.join(', ')} — fired with no wire`;
|
|
44
|
+
|
|
45
|
+
function defaultMaxParallel() {
|
|
46
|
+
const n = Number(process.env.WORCA_MAX_PARALLEL);
|
|
47
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 4;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Non-interactive default: gates continue, clarify asks answer empty (v1 `_ask` auto). */
|
|
51
|
+
const defaultAsk = async (ask) => (ask?.kind === 'gate' ? 'continue' : []);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The execution id of one composite sub-execution: the parent's id plus the manifest
|
|
55
|
+
* task id (`x:n_impl:1:p1t1`). Deterministic on purpose — a resumed composite
|
|
56
|
+
* re-mints the SAME ids, so its ledger entries overwrite rather than accumulate.
|
|
57
|
+
* @param {string} parentExecutionId
|
|
58
|
+
* @param {string} taskId
|
|
59
|
+
*/
|
|
60
|
+
export function sliceExecutionId(parentExecutionId, taskId) {
|
|
61
|
+
return `${parentExecutionId}:${taskId}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An abort rejection — a sibling cancelled by a phase-mate's failure, or the whole
|
|
65
|
+
* run going down. Never counted as the FIRST genuine failure. */
|
|
66
|
+
const isAbortError = (err) => err?.name === 'AbortError';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Settle with the promise, or reject with the signal's reason the moment it aborts.
|
|
70
|
+
* The scheduler never WAITS on an executor that ignores its signal: fail-fast and
|
|
71
|
+
* abort resolve the run while stalled work is still pending (the top-level path has
|
|
72
|
+
* the same property through `finish`). `AbortSignal.any`'s reason is an AbortError.
|
|
73
|
+
*/
|
|
74
|
+
function raceAbort(promise, signal) {
|
|
75
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const onAbort = () => reject(signal.reason);
|
|
78
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
79
|
+
Promise.resolve(promise).then(
|
|
80
|
+
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
|
|
81
|
+
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a scheduler over a resolved v2 template.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} opts
|
|
90
|
+
* @param {object} opts.template v2 template `{ nodes, wires }`
|
|
91
|
+
* @param {(node:object) => ({inputs?:Array, outputs?:Array, verdict?:object}|undefined)} opts.portsFn
|
|
92
|
+
* @param {{loopWireIds:Set<string>, loopInputs:Set<string>, launchOrder:string[]}} [opts.loops]
|
|
93
|
+
* @param {(args:object) => Promise<object>} opts.execute
|
|
94
|
+
* @param {(name:'exec'|'token'|'gate', payload:object) => void} [opts.onEvent]
|
|
95
|
+
* @param {(snapshot:object) => void} [opts.onSnapshot]
|
|
96
|
+
* @param {(gate:{wireId,fromNode,toNode,askId}|null) => void} [opts.onGate]
|
|
97
|
+
* @param {(ask:object) => Promise<any>} [opts.onAsk]
|
|
98
|
+
* @param {number} [opts.maxParallel]
|
|
99
|
+
* @param {(line:string, attrs?:object) => void} [opts.log]
|
|
100
|
+
*/
|
|
101
|
+
export function createScheduler(opts) {
|
|
102
|
+
const {
|
|
103
|
+
template,
|
|
104
|
+
portsFn,
|
|
105
|
+
execute,
|
|
106
|
+
loops = classifyLoops(template, portsFn),
|
|
107
|
+
onEvent = () => {},
|
|
108
|
+
onSnapshot = () => {},
|
|
109
|
+
onGate = () => {},
|
|
110
|
+
onAsk = defaultAsk,
|
|
111
|
+
maxParallel = defaultMaxParallel(),
|
|
112
|
+
log = () => {},
|
|
113
|
+
} = opts || {};
|
|
114
|
+
|
|
115
|
+
const nodes = Array.isArray(template?.nodes) ? template.nodes : [];
|
|
116
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
117
|
+
const wires = (Array.isArray(template?.wires) ? template.wires : [])
|
|
118
|
+
.filter((w) => nodeById.has(w?.from?.node) && nodeById.has(w?.to?.node));
|
|
119
|
+
const wireById = new Map(wires.map((w) => [w.id, w]));
|
|
120
|
+
const loopWireIds = new Set(loops?.loopWireIds || []);
|
|
121
|
+
const loopInputs = new Set(loops?.loopInputs || []);
|
|
122
|
+
const order = Array.isArray(loops?.launchOrder) && loops.launchOrder.length
|
|
123
|
+
? loops.launchOrder.filter((id) => nodeById.has(id))
|
|
124
|
+
: nodes.map((n) => n.id);
|
|
125
|
+
|
|
126
|
+
// Static wiring indexes. `wiredIn` is keyed by BARE port id per node; `outWires`
|
|
127
|
+
// fans a fired output out to its wires.
|
|
128
|
+
const wiredIn = new Map(nodes.map((n) => [n.id, new Map()]));
|
|
129
|
+
const outWires = new Map();
|
|
130
|
+
for (const w of wires) {
|
|
131
|
+
wiredIn.get(w.to.node).set(w.to.port, w.id);
|
|
132
|
+
const key = `${w.from.node}.${w.from.port}`;
|
|
133
|
+
if (!outWires.has(key)) outWires.set(key, []);
|
|
134
|
+
outWires.get(key).push(w);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- run state -----------------------------------------------------------
|
|
138
|
+
let seq = 0;
|
|
139
|
+
const tokens = new Map(); // '<node>.<inputPort>' -> delivered token
|
|
140
|
+
const outputs = new Map(); // '<node>.<outputPort>' -> latched token
|
|
141
|
+
const consumed = new Map(); // nodeId -> Map(port -> seq), recorded at bind
|
|
142
|
+
const ordinals = new Map(); // nodeId -> executions started
|
|
143
|
+
const wireState = new Map(); // loop wireId -> { deliveries, allowance }
|
|
144
|
+
const deadEnds = new Set(); // '<nodeId>.<portId>' outputs that fired with no wire
|
|
145
|
+
const execs = new Map(); // executionId -> ledger entry
|
|
146
|
+
const held = new Map(); // wireId -> { wireId, nodeId, executionId, token, issues, askId }
|
|
147
|
+
const outstanding = new Set(); // wireIds with an un-withdrawn ask in flight
|
|
148
|
+
const running = new Map(); // nodeId -> executionId
|
|
149
|
+
const completions = [];
|
|
150
|
+
const warnings = [];
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
let activeAgents = 0;
|
|
153
|
+
let ended = null;
|
|
154
|
+
let gate = null; // the CURRENT gate for state.gate (P4 stamps it)
|
|
155
|
+
let failure = null;
|
|
156
|
+
let pauseRequested = false;
|
|
157
|
+
let abortRequested = false;
|
|
158
|
+
let settled = false;
|
|
159
|
+
|
|
160
|
+
for (const id of loopWireIds) {
|
|
161
|
+
const raw = Number(wireById.get(id)?.config?.maxCycles ?? DEFAULT_MAX_CYCLES);
|
|
162
|
+
const maxCycles = Number.isFinite(raw) && raw >= 1 ? raw : DEFAULT_MAX_CYCLES;
|
|
163
|
+
wireState.set(id, { deliveries: 0, allowance: maxCycles - 1 }); // A1: allowance = maxCycles − 1
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- wake plumbing -------------------------------------------------------
|
|
167
|
+
let signalled = false;
|
|
168
|
+
let waiter = null;
|
|
169
|
+
function wake() {
|
|
170
|
+
signalled = true;
|
|
171
|
+
if (waiter) { const w = waiter; waiter = null; w(); }
|
|
172
|
+
}
|
|
173
|
+
async function waitForChange() {
|
|
174
|
+
if (signalled) { signalled = false; return; }
|
|
175
|
+
await new Promise((res) => { waiter = res; });
|
|
176
|
+
signalled = false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- the agent semaphore -------------------------------------------------
|
|
180
|
+
// A counting semaphore with a FIFO waiter queue. The drain walk POLLS it before
|
|
181
|
+
// launching a node; composite slices AWAIT it (they are launched from inside an
|
|
182
|
+
// already-running execution). The composite SHELL holds no slot, which is what
|
|
183
|
+
// keeps a fan-out from deadlocking behind itself at maxParallel 1.
|
|
184
|
+
const slotQueue = [];
|
|
185
|
+
function takeSlot() {
|
|
186
|
+
if (activeAgents < maxParallel) { activeAgents += 1; return Promise.resolve(); }
|
|
187
|
+
return new Promise((resolve) => { slotQueue.push(resolve); });
|
|
188
|
+
}
|
|
189
|
+
function freeSlot() {
|
|
190
|
+
const next = slotQueue.shift();
|
|
191
|
+
if (next) { next(); return; } // handed straight over: the count is unchanged
|
|
192
|
+
activeAgents -= 1;
|
|
193
|
+
wake(); // a freed slot may unblock a queued launch
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- small helpers -------------------------------------------------------
|
|
197
|
+
/** This scheduler's 1-arg ports lookup (the shared `portsOf(portsFn, node)` is 2-arg). */
|
|
198
|
+
const portsOfNode = (node) => (typeof portsFn === 'function' ? portsFn(node) : null) || {};
|
|
199
|
+
const isFlow = (node) => FLOW_KINDS.includes(node.kind); // FLOW_KINDS is a frozen ARRAY (P1)
|
|
200
|
+
const spentOf = (nodeId) => consumed.get(nodeId) || new Map();
|
|
201
|
+
|
|
202
|
+
/** A port is a loop input when the classification says so OR its meta declares it.
|
|
203
|
+
* Both halves are load-bearing: `or.out -> agent.fix` is an ALWAYS-sourced wire
|
|
204
|
+
* (never a classified loop wire) into a `loop:true` port, and only the meta half
|
|
205
|
+
* excuses it from the first-run barrier. */
|
|
206
|
+
const isLoopPort = (nodeId, port) => Boolean(port?.loop) || loopInputs.has(`${nodeId}.${port?.id}`);
|
|
207
|
+
|
|
208
|
+
function makeToken({ type, path = null, value = null, meta = null, sourceExecutionId = null, forced = false }) {
|
|
209
|
+
seq += 1;
|
|
210
|
+
return { seq, type, path, value, meta, firedAt: Date.now(), sourceExecutionId, forced };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The payload half of a token, materialized only where it exists. */
|
|
214
|
+
function payloadOf(token) {
|
|
215
|
+
const out = { seq: token.seq, type: token.type };
|
|
216
|
+
if (token.path != null) out.path = token.path;
|
|
217
|
+
if (token.value != null) out.value = token.value;
|
|
218
|
+
if (token.meta != null) out.meta = token.meta;
|
|
219
|
+
if (token.forced) out.forced = true;
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function emitExec(node, entry, status, extra) {
|
|
224
|
+
onEvent('exec', {
|
|
225
|
+
nodeId: node.id,
|
|
226
|
+
executionId: entry.executionId,
|
|
227
|
+
kind: entry.kind,
|
|
228
|
+
ordinal: entry.ordinal,
|
|
229
|
+
status,
|
|
230
|
+
agentKey: node.kind === 'agent' ? (node.key ?? null) : null, // flow rows carry none
|
|
231
|
+
trigger: entry.trigger,
|
|
232
|
+
// Composite sub-executions carry their slice identity; the UI collapses them
|
|
233
|
+
// under the node and labels them by title (A9: taskIndex/taskTotal ride along).
|
|
234
|
+
...(entry.kind === 'task'
|
|
235
|
+
? { phase: entry.phase, taskId: entry.taskId, title: entry.title, parentExecutionId: entry.parentExecutionId,
|
|
236
|
+
taskIndex: entry.taskIndex, taskTotal: entry.taskTotal }
|
|
237
|
+
: null),
|
|
238
|
+
...(extra || null),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function emitToken(node, port, token) {
|
|
243
|
+
onEvent('token', {
|
|
244
|
+
seq: token.seq,
|
|
245
|
+
from: { node: node.id, port: port.id },
|
|
246
|
+
to: (outWires.get(`${node.id}.${port.id}`) || [])
|
|
247
|
+
.map((w) => ({ node: w.to.node, port: w.to.port, wireId: w.id })),
|
|
248
|
+
type: token.type,
|
|
249
|
+
path: token.path,
|
|
250
|
+
forced: token.forced,
|
|
251
|
+
firedAt: token.firedAt,
|
|
252
|
+
sourceExecutionId: token.sourceExecutionId,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- binding -------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Latch this execution's inputs. Every input holding a token is bound (a
|
|
260
|
+
* re-execution binds latched values for its non-triggering inputs) and spent in
|
|
261
|
+
* `consumed`; the OR card binds ONLY the freshest fresh input, so the older fresh
|
|
262
|
+
* tokens are spent at that same bind without being bound.
|
|
263
|
+
*/
|
|
264
|
+
function bindFor(node) {
|
|
265
|
+
const inputs = portsOfNode(node).inputs || [];
|
|
266
|
+
const spent = spentOf(node.id);
|
|
267
|
+
const present = [];
|
|
268
|
+
for (const inp of inputs) {
|
|
269
|
+
const token = tokens.get(`${node.id}.${inp.id}`);
|
|
270
|
+
if (!token) continue;
|
|
271
|
+
const prior = spent.get(inp.id);
|
|
272
|
+
present.push({ port: inp.id, token, fresh: prior === undefined || token.seq > prior });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let bound = present;
|
|
276
|
+
if (node.kind === 'or') {
|
|
277
|
+
const freshest = present
|
|
278
|
+
.filter((p) => p.fresh)
|
|
279
|
+
.reduce((best, p) => (best && best.token.seq >= p.token.seq ? best : p), null);
|
|
280
|
+
bound = freshest ? [freshest] : [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const bindings = {};
|
|
284
|
+
for (const p of bound) {
|
|
285
|
+
if (p.port === AWAIT_ID) continue; // consumed for the barrier, payload discarded
|
|
286
|
+
bindings[p.port] = payloadOf(p.token);
|
|
287
|
+
}
|
|
288
|
+
const fresh = present.filter((p) => p.fresh);
|
|
289
|
+
return {
|
|
290
|
+
bindings,
|
|
291
|
+
present,
|
|
292
|
+
trigger: {
|
|
293
|
+
wireIds: fresh.map((p) => wiredIn.get(node.id)?.get(p.port)).filter(Boolean),
|
|
294
|
+
// A3: only a FRESH port selects the mode. `await` never does — it is a barrier,
|
|
295
|
+
// not a payload, so it is never listed (first executions and re-fires alike).
|
|
296
|
+
freshPorts: fresh.filter((p) => p.port !== AWAIT_ID).map((p) => p.port),
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function commitBind(node, present) {
|
|
302
|
+
let spent = consumed.get(node.id);
|
|
303
|
+
if (!spent) { spent = new Map(); consumed.set(node.id, spent); }
|
|
304
|
+
for (const p of present) spent.set(p.port, p.token.seq);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- launching -----------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
function startExecution(node) {
|
|
310
|
+
const ordinal = (ordinals.get(node.id) || 0) + 1;
|
|
311
|
+
const executionId = `x:${node.id}:${ordinal}`;
|
|
312
|
+
const b = bindFor(node); // reads `consumed` — bind BEFORE the bump
|
|
313
|
+
ordinals.set(node.id, ordinal);
|
|
314
|
+
commitBind(node, b.present);
|
|
315
|
+
const entry = {
|
|
316
|
+
executionId,
|
|
317
|
+
nodeId: node.id,
|
|
318
|
+
kind: 'cycle',
|
|
319
|
+
ordinal,
|
|
320
|
+
status: 'start',
|
|
321
|
+
sessionId: null,
|
|
322
|
+
bindings: b.bindings,
|
|
323
|
+
trigger: b.trigger,
|
|
324
|
+
};
|
|
325
|
+
const expandsPort = expandsTrigger(node, b);
|
|
326
|
+
if (expandsPort) entry.expandsPort = expandsPort;
|
|
327
|
+
execs.set(executionId, entry);
|
|
328
|
+
running.set(node.id, executionId);
|
|
329
|
+
emitExec(node, entry, 'start');
|
|
330
|
+
return { node, entry, args: argsFor(node, entry), composite: !!expandsPort };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function argsFor(node, entry) {
|
|
334
|
+
return {
|
|
335
|
+
node,
|
|
336
|
+
executionId: entry.executionId,
|
|
337
|
+
ordinal: entry.ordinal,
|
|
338
|
+
bindings: entry.bindings,
|
|
339
|
+
trigger: entry.trigger,
|
|
340
|
+
signal: controller.signal,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Flow cards run inline at their slot so their publishes reach later slots. */
|
|
345
|
+
async function fireFlow(node) {
|
|
346
|
+
const h = startExecution(node);
|
|
347
|
+
let res = null;
|
|
348
|
+
let err = null;
|
|
349
|
+
try { res = await execute(h.args); } catch (e) { err = e; }
|
|
350
|
+
settle(h, res, err);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Agent nodes take a semaphore slot; `execute` is called AT the slot. */
|
|
354
|
+
function fireAgent(node) {
|
|
355
|
+
const h = startExecution(node);
|
|
356
|
+
if (!h.composite) activeAgents += 1;
|
|
357
|
+
let p;
|
|
358
|
+
try { p = invoke(h); } catch (err) { p = Promise.reject(err); }
|
|
359
|
+
Promise.resolve(p).then(
|
|
360
|
+
(res) => { completions.push({ h, res, err: null }); wake(); },
|
|
361
|
+
(err) => { completions.push({ h, res: null, err }); wake(); },
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Run one started execution: the composite driver, or the plain injected call. */
|
|
366
|
+
function invoke(h) {
|
|
367
|
+
return h.composite ? runComposite(h) : execute(h.args);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The FRESH `expands` input that makes this firing COMPOSITE, or null.
|
|
372
|
+
*
|
|
373
|
+
* A3, parity-mandatory: a fresh LOOP input wins outright — a fix-cycle re-fire runs
|
|
374
|
+
* ONE ordinary execution on the combined diff, which is v1's `!bus.review` arm of
|
|
375
|
+
* the same guard. A latched expands token never fans out again either, because only
|
|
376
|
+
* FRESH ports are considered.
|
|
377
|
+
*/
|
|
378
|
+
function expandsTrigger(node, b) {
|
|
379
|
+
if (isFlow(node)) return null;
|
|
380
|
+
const inputs = portsOfNode(node).inputs || [];
|
|
381
|
+
const fresh = new Set(b.trigger.freshPorts || []);
|
|
382
|
+
if (inputs.some((inp) => isLoopPort(node.id, inp) && fresh.has(inp.id))) return null;
|
|
383
|
+
const port = inputs.find((inp) => inp?.expands && fresh.has(inp.id) && b.bindings[inp.id]);
|
|
384
|
+
return port ? port.id : null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Drive ONE composite execution. Phases run in order, each phase's tasks in
|
|
389
|
+
* parallel under the semaphore, and the single value returned here is what the node
|
|
390
|
+
* PUBLISHES — so its outputs fire exactly once, after the last phase, never once
|
|
391
|
+
* per task. Pause/abort is checked at every phase boundary.
|
|
392
|
+
*/
|
|
393
|
+
async function runComposite(h) {
|
|
394
|
+
const portId = h.entry.expandsPort;
|
|
395
|
+
const expanded = await execute({ ...h.args, composite: 'expand', expandsPort: portId });
|
|
396
|
+
// A pause raised INSIDE the expansion (the adapter converted a throw) settles the
|
|
397
|
+
// shell row here, with its expands binding intact. Falling through to
|
|
398
|
+
// runUnexpanded would strip `expandsPort`/the binding from the very entry the
|
|
399
|
+
// resume re-invokes, so the node would re-run once as a plain execution and the
|
|
400
|
+
// decomposition would never be re-read.
|
|
401
|
+
if (expanded?.paused === true) return { paused: true };
|
|
402
|
+
const phases = Array.isArray(expanded?.phases) ? expanded.phases : [];
|
|
403
|
+
if (!phases.length) return runUnexpanded(h, portId);
|
|
404
|
+
|
|
405
|
+
// A halted run returns WITHOUT `finish`: nothing is staged and no phase is falsely
|
|
406
|
+
// marked done. A PAUSE answers `{ paused: true }` so the shell row stays
|
|
407
|
+
// non-terminal and the resume re-runs the whole fan-out; any other halt (End,
|
|
408
|
+
// abort, failure) answers `{ skipped: true }` — the base spec's Completion
|
|
409
|
+
// paragraph puts "anything cut off by the End drain" in `skipped`. The old
|
|
410
|
+
// `{ outputs: {} }` was read as a SUCCESSFUL completion: the ledger row said
|
|
411
|
+
// `done` although finish() never ran, and an empty (path:null) token latched
|
|
412
|
+
// into the snapshot and animated in the monitor.
|
|
413
|
+
const bail = (paused) => (paused || pauseRequested ? { paused: true } : { skipped: true });
|
|
414
|
+
for (const ph of phases) {
|
|
415
|
+
if (halted()) return bail(false);
|
|
416
|
+
const { paused } = await runPhase(h, portId, ph);
|
|
417
|
+
if (paused || halted()) return bail(paused);
|
|
418
|
+
}
|
|
419
|
+
return await execute({ ...h.args, composite: 'finish', expandsPort: portId, phases });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Nothing to fan out. Strip the expands binding — so the consumer neither sees the
|
|
424
|
+
* manifest as an input nor renders its slice directive (A3) — and run the ONE
|
|
425
|
+
* ordinary execution the firing would have been. The entry is mutated in place
|
|
426
|
+
* because it is the ledger row a resume would re-invoke from.
|
|
427
|
+
*/
|
|
428
|
+
async function runUnexpanded(h, portId) {
|
|
429
|
+
const { node, entry } = h;
|
|
430
|
+
const wireId = wiredIn.get(node.id)?.get(portId);
|
|
431
|
+
delete entry.bindings[portId];
|
|
432
|
+
entry.trigger = {
|
|
433
|
+
wireIds: (entry.trigger.wireIds || []).filter((w) => w !== wireId),
|
|
434
|
+
freshPorts: (entry.trigger.freshPorts || []).filter((p) => p !== portId),
|
|
435
|
+
};
|
|
436
|
+
delete entry.expandsPort;
|
|
437
|
+
h.composite = false; // … so settle() frees the slot taken here
|
|
438
|
+
await takeSlot();
|
|
439
|
+
h.args = argsFor(node, entry);
|
|
440
|
+
return await execute(h.args);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* One phase: every task launched together, each awaiting its own semaphore slot.
|
|
445
|
+
* The FIRST genuine (non-abort) failure aborts its siblings immediately through the
|
|
446
|
+
* phase-local controller and fails the whole composite — v1's abort-on-first-
|
|
447
|
+
* failure, kept. Returns `{ paused }` — true when any slice answered `{ paused: true }`.
|
|
448
|
+
*/
|
|
449
|
+
async function runPhase(h, portId, ph) {
|
|
450
|
+
const tasks = Array.isArray(ph.tasks) ? ph.tasks : [];
|
|
451
|
+
const phaseAbort = new AbortController();
|
|
452
|
+
let firstError = null;
|
|
453
|
+
const opened = await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'running' });
|
|
454
|
+
if (opened?.paused === true) return { paused: true }; // the phase bookkeeping paused the run: no slice launches
|
|
455
|
+
|
|
456
|
+
const results = await Promise.allSettled(tasks.map((task, index) =>
|
|
457
|
+
runSlice(h, portId, ph, task, index, phaseAbort).catch((err) => {
|
|
458
|
+
// The slice already aborted its phase-mates (see runSlice); record the FIRST
|
|
459
|
+
// genuine failure as the composite's error.
|
|
460
|
+
if (!firstError && !isAbortError(err)) firstError = { task, err };
|
|
461
|
+
throw err;
|
|
462
|
+
})));
|
|
463
|
+
|
|
464
|
+
if (firstError) {
|
|
465
|
+
await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'error' });
|
|
466
|
+
const label = firstError.task.title || firstError.task.id;
|
|
467
|
+
throw new Error(
|
|
468
|
+
`composite execution failed in phase ${ph.ordinal}: task "${label}": ` +
|
|
469
|
+
`${firstError.err?.message || firstError.err}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const paused = results.some((r) => r.status === 'fulfilled' && r.value?.paused === true);
|
|
473
|
+
// A halted or paused run leaves the phase RUNNING: the resume re-runs the whole
|
|
474
|
+
// composite, and a phase that never finished must not read as done.
|
|
475
|
+
if (paused || halted()) return { paused };
|
|
476
|
+
await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'done' });
|
|
477
|
+
return { paused: false };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* One task sub-execution: the consumer node with its expands input rebound to this
|
|
482
|
+
* task's own markdown file, still FRESH so the slice directive renders, and its
|
|
483
|
+
* phase-mates listed in `slice.siblings` (the shared-working-tree block). Recorded
|
|
484
|
+
* `kind:'task'` under the SAME node — it publishes nothing; `finish` does that.
|
|
485
|
+
* The adapter's `{ paused: true }` / `{ error }` answers are honored exactly as
|
|
486
|
+
* `settle` honors them for a top-level execution.
|
|
487
|
+
*/
|
|
488
|
+
async function runSlice(h, portId, ph, task, index, phaseAbort) {
|
|
489
|
+
const { node, entry } = h;
|
|
490
|
+
const signal = AbortSignal.any([controller.signal, phaseAbort.signal]);
|
|
491
|
+
await takeSlot();
|
|
492
|
+
// A slot handed over AFTER the phase (or the run) aborted: never launch. The slice
|
|
493
|
+
// gets no ledger row (it never started) and rejects with the abort reason, so it
|
|
494
|
+
// is not counted as the phase's failure.
|
|
495
|
+
if (signal.aborted) { freeSlot(); throw signal.reason; }
|
|
496
|
+
const sub = {
|
|
497
|
+
executionId: sliceExecutionId(entry.executionId, task.id),
|
|
498
|
+
nodeId: node.id,
|
|
499
|
+
kind: 'task',
|
|
500
|
+
ordinal: entry.ordinal,
|
|
501
|
+
status: 'start',
|
|
502
|
+
sessionId: null,
|
|
503
|
+
phase: ph.ordinal,
|
|
504
|
+
taskId: task.id,
|
|
505
|
+
title: task.title || task.id,
|
|
506
|
+
parentExecutionId: entry.executionId,
|
|
507
|
+
taskIndex: index + 1, // 1-based within its phase (the CLI's "task 3/7")
|
|
508
|
+
taskTotal: (Array.isArray(ph.tasks) ? ph.tasks : []).length,
|
|
509
|
+
bindings: {
|
|
510
|
+
...entry.bindings,
|
|
511
|
+
[portId]: { seq: entry.bindings[portId]?.seq, type: 'md', path: task.path ?? null },
|
|
512
|
+
},
|
|
513
|
+
trigger: entry.trigger,
|
|
514
|
+
};
|
|
515
|
+
execs.set(sub.executionId, sub);
|
|
516
|
+
emitExec(node, sub, 'start');
|
|
517
|
+
const args = {
|
|
518
|
+
...argsFor(node, sub),
|
|
519
|
+
node: { ...node },
|
|
520
|
+
signal,
|
|
521
|
+
kind: 'task',
|
|
522
|
+
parentExecutionId: sub.parentExecutionId, // the adapter's ledger row + exec_meta read these three
|
|
523
|
+
taskIndex: sub.taskIndex,
|
|
524
|
+
taskTotal: sub.taskTotal,
|
|
525
|
+
slice: {
|
|
526
|
+
id: task.id,
|
|
527
|
+
title: task.title ?? null,
|
|
528
|
+
phase: ph.ordinal,
|
|
529
|
+
path: task.path ?? null,
|
|
530
|
+
index,
|
|
531
|
+
siblings: (Array.isArray(ph.tasks) ? ph.tasks : [])
|
|
532
|
+
.filter((t) => t.id !== task.id)
|
|
533
|
+
.map((t) => ({ id: t.id, title: t.title ?? null, file: t.file ?? null })),
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
try {
|
|
537
|
+
const res = await raceAbort(execute(args), signal);
|
|
538
|
+
if (res?.error) throw (res.error instanceof Error ? res.error : new Error(String(res.error)));
|
|
539
|
+
sub.status = res?.paused === true ? 'paused' : 'done';
|
|
540
|
+
if (res?.sessionId) sub.sessionId = res.sessionId;
|
|
541
|
+
emitExec(node, sub, sub.status);
|
|
542
|
+
return res;
|
|
543
|
+
} catch (err) {
|
|
544
|
+
sub.status = 'error';
|
|
545
|
+
sub.error = String(err?.message || err);
|
|
546
|
+
emitExec(node, sub, 'error', { error: sub.error });
|
|
547
|
+
// Fail-fast: the FIRST genuine failure aborts its phase-mates HERE — before the
|
|
548
|
+
// slot is handed on in `finally` — so a queued sibling wakes up already aborted
|
|
549
|
+
// and never launches.
|
|
550
|
+
if (!isAbortError(err)) phaseAbort.abort();
|
|
551
|
+
throw err;
|
|
552
|
+
} finally {
|
|
553
|
+
freeSlot();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function settle(h, res, err) {
|
|
558
|
+
running.delete(h.node.id);
|
|
559
|
+
if (!isFlow(h.node) && !h.composite) freeSlot();
|
|
560
|
+
if (err || res?.error) failExecution(h, err || res.error);
|
|
561
|
+
else if (res?.paused === true) pausedExecution(h);
|
|
562
|
+
else if (res?.skipped === true) skippedExecution(h);
|
|
563
|
+
else completeExecution(h, res || {});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The execution was CUT SHORT by a terminal run condition (the End drain, an abort,
|
|
568
|
+
* a sibling's failure) rather than completing. TERMINAL, so a resume never re-invokes
|
|
569
|
+
* it, but it publishes NOTHING — no token event, no latched payload, and no `done`
|
|
570
|
+
* in the ledger for work that did not happen.
|
|
571
|
+
*/
|
|
572
|
+
function skippedExecution(h) {
|
|
573
|
+
const { node, entry } = h;
|
|
574
|
+
entry.status = 'skipped';
|
|
575
|
+
emitExec(node, entry, 'skipped');
|
|
576
|
+
snap();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function completeExecution(h, res) {
|
|
580
|
+
const { node, entry } = h;
|
|
581
|
+
entry.status = 'done';
|
|
582
|
+
// An execution may report NON-FATAL problems (a verifier that never wrote its
|
|
583
|
+
// verdict, MAJ-10). The scheduler owns `warnings` and the injected `log`, so
|
|
584
|
+
// they ride the execution result rather than a second channel.
|
|
585
|
+
for (const w of Array.isArray(res?.warnings) ? res.warnings : []) {
|
|
586
|
+
const text = String(w || '');
|
|
587
|
+
if (!text) continue;
|
|
588
|
+
warnings.push(text);
|
|
589
|
+
log(text);
|
|
590
|
+
}
|
|
591
|
+
if (res?.sessionId) entry.sessionId = res.sessionId;
|
|
592
|
+
emitExec(node, entry, 'done', {
|
|
593
|
+
...(node.kind === 'end' ? { result: boundResult(entry) } : null),
|
|
594
|
+
...(res?.verdict ? { verdict: { hasBlocking: hasBlocking(res.verdict), ...(res.verdict.missing ? { missing: true } : {}) } } : null),
|
|
595
|
+
});
|
|
596
|
+
publish(node, entry, res);
|
|
597
|
+
snap();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function failExecution(h, err) {
|
|
601
|
+
const { node, entry } = h;
|
|
602
|
+
entry.status = 'error';
|
|
603
|
+
entry.error = String(err?.message || err);
|
|
604
|
+
emitExec(node, entry, 'error', { error: entry.error });
|
|
605
|
+
failure = err;
|
|
606
|
+
controller.abort(); // fail-fast aborts everything in flight
|
|
607
|
+
snap();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* A pause cancelled this execution (or, for a composite shell, one of its slices).
|
|
612
|
+
* The row stays NON-TERMINAL and nothing is published, so `reattach` re-invokes it
|
|
613
|
+
* with the recorded args on resume.
|
|
614
|
+
*/
|
|
615
|
+
function pausedExecution(h) {
|
|
616
|
+
const { node, entry } = h;
|
|
617
|
+
entry.status = 'paused';
|
|
618
|
+
emitExec(node, entry, 'paused');
|
|
619
|
+
// The EXECUTION paused the run (the adapter's ask-then-resume path, or its pause
|
|
620
|
+
// abort): treat it exactly like pause() — nothing else launches and run() resolves
|
|
621
|
+
// 'paused' once the in-flight work drains — instead of quiescing to a false 'done'.
|
|
622
|
+
pauseRequested = true;
|
|
623
|
+
snap();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// --- publishing / routing ------------------------------------------------
|
|
627
|
+
|
|
628
|
+
/** End's result is derived from the token the SCHEDULER bound, never from the
|
|
629
|
+
* execution's (informational) return value. Shape per A7: {type, path?, value?}. */
|
|
630
|
+
function boundResult(entry) {
|
|
631
|
+
const bound = Object.values(entry.bindings)[0] || {};
|
|
632
|
+
const result = { type: bound.type ?? 'void' };
|
|
633
|
+
if (bound.path != null) result.path = bound.path;
|
|
634
|
+
if (bound.value != null) result.value = bound.value;
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function publish(node, entry, res) {
|
|
639
|
+
if (node.kind === 'end') {
|
|
640
|
+
ended = {
|
|
641
|
+
nodeId: node.id,
|
|
642
|
+
executionId: entry.executionId,
|
|
643
|
+
seq: Object.values(entry.bindings)[0]?.seq ?? null,
|
|
644
|
+
result: boundResult(entry),
|
|
645
|
+
};
|
|
646
|
+
withdrawGates(); // no run() may block on a pending ask now
|
|
647
|
+
return; // zero outputs — the publish step fires no token
|
|
648
|
+
}
|
|
649
|
+
const verdict = res?.verdict ?? null;
|
|
650
|
+
for (const port of firedOutputs(portsOfNode(node).outputs || [], verdict)) {
|
|
651
|
+
const token = makeToken({
|
|
652
|
+
type: outTypeOf(node, port),
|
|
653
|
+
...payloadFor(node, port, entry, res),
|
|
654
|
+
sourceExecutionId: entry.executionId,
|
|
655
|
+
});
|
|
656
|
+
emitToken(node, port, token);
|
|
657
|
+
outputs.set(`${node.id}.${port.id}`, token);
|
|
658
|
+
route(node, port, token, verdict, entry.executionId);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const outTypeOf = (node, port) => (node.kind === 'or'
|
|
663
|
+
? (resolveOrOutType(template, portsFn, node.id) ?? port.type)
|
|
664
|
+
: port.type);
|
|
665
|
+
|
|
666
|
+
function payloadFor(node, port, entry, res) {
|
|
667
|
+
// The OR valve re-emits the token IT bound — payload AND provenance (an A4
|
|
668
|
+
// forced token keeps its flag and its open issues through the valve); the AND
|
|
669
|
+
// card is a pure synchronizer and emits void.
|
|
670
|
+
if (node.kind === 'or') {
|
|
671
|
+
const bound = Object.values(entry.bindings)[0] || {};
|
|
672
|
+
return { path: bound.path ?? null, value: bound.value ?? null, meta: bound.meta ?? null, forced: !!bound.forced };
|
|
673
|
+
}
|
|
674
|
+
if (node.kind === 'and') return { path: null, value: null };
|
|
675
|
+
const given = res?.outputs?.[port.id];
|
|
676
|
+
if (given) return { path: given.path ?? null, value: given.value ?? null };
|
|
677
|
+
return { path: null, value: null };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Deliver a token along every wire out of a fired port. Loop-wire deliveries are
|
|
682
|
+
* counted and gated HERE, per wire — before, and independently of, any downstream
|
|
683
|
+
* bind. During the End drain nothing is routed at all: the token is recorded
|
|
684
|
+
* (latched + evented) and accounting/gates are skipped.
|
|
685
|
+
*/
|
|
686
|
+
function route(node, port, token, verdict, executionId) {
|
|
687
|
+
if (ended) return;
|
|
688
|
+
const outs = outWires.get(`${node.id}.${port.id}`) || [];
|
|
689
|
+
// An output that fires with no wire is a DEAD END: its token has nowhere to go.
|
|
690
|
+
// Legal (a conditional branch may be deliberately unwired — wf_no-clarify's
|
|
691
|
+
// n_webui.review is v1 parity), but it is the one structural fact that explains
|
|
692
|
+
// a run finishing at quiescence, so the warning names it.
|
|
693
|
+
if (!outs.length) deadEnds.add(`${node.id}.${port.id}`);
|
|
694
|
+
for (const w of outs) {
|
|
695
|
+
const st = wireState.get(w.id);
|
|
696
|
+
if (st) {
|
|
697
|
+
if (st.deliveries >= st.allowance) { holdAt(w, token, verdict, node, executionId); continue; }
|
|
698
|
+
st.deliveries += 1;
|
|
699
|
+
}
|
|
700
|
+
tokens.set(`${w.to.node}.${w.to.port}`, token);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// --- gates ---------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
/** The current gate descriptor for `state.gate` — the FIRST hold, or null. */
|
|
707
|
+
function syncGate() {
|
|
708
|
+
const first = held.values().next().value || null;
|
|
709
|
+
const w = first ? wireById.get(first.wireId) : null;
|
|
710
|
+
const next = first && w
|
|
711
|
+
? { wireId: first.wireId, fromNode: w.from.node, toNode: w.to.node, askId: first.askId }
|
|
712
|
+
: null;
|
|
713
|
+
const changed = JSON.stringify(next ?? null) !== JSON.stringify(gate ?? null);
|
|
714
|
+
gate = next;
|
|
715
|
+
if (changed) onGate(gate ? { ...gate } : null);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function askGate(entry) {
|
|
719
|
+
Promise.resolve(onAsk({
|
|
720
|
+
id: entry.askId,
|
|
721
|
+
kind: 'gate',
|
|
722
|
+
wireId: entry.wireId,
|
|
723
|
+
nodeId: entry.nodeId,
|
|
724
|
+
executionId: entry.executionId,
|
|
725
|
+
issues: entry.issues,
|
|
726
|
+
// The CYCLE this hold stands in for, and WHICH hold it is. Both ride the
|
|
727
|
+
// payload because the id is opaque: a re-held wire suffixes `-h<holdNo>`,
|
|
728
|
+
// so anything parsing a trailing number off the id reads the hold ordinal
|
|
729
|
+
// as the cycle (src/cli/render.mjs formatGateHeader).
|
|
730
|
+
deliveryNo: entry.deliveryNo,
|
|
731
|
+
holdNo: entry.holdNo,
|
|
732
|
+
})).then(
|
|
733
|
+
(answer) => resolveGate(entry.wireId, answer),
|
|
734
|
+
() => resolveGate(entry.wireId, 'continue'),
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Past the allowance: HOLD the token and ask the human. "Open issues" = the
|
|
739
|
+
* critical/major findings that caused the block; they ride the ask and, on
|
|
740
|
+
* continue, the forced token's meta. A wire that is ALREADY held keeps its first
|
|
741
|
+
* hold — a later over-budget token on the same wire is dropped, so one ask never
|
|
742
|
+
* answers for a token it was not raised about.
|
|
743
|
+
*
|
|
744
|
+
* The ask id is UNIQUE PER HOLD. resolveGate('continue') advances neither
|
|
745
|
+
* counter, so the next blocking token on the same spent wire holds again with the
|
|
746
|
+
* same deliveryNo; minting the same id twice let a retried/duplicated answer
|
|
747
|
+
* resolve a hold the user never saw (run-harness.answer matches by id), and made
|
|
748
|
+
* the two holds indistinguishable in the audit trail. `st.holds` is monotonic per
|
|
749
|
+
* wire and rides the snapshot with the rest of the wire state, so a resume keeps
|
|
750
|
+
* counting. The first hold keeps the original `gate-<wireId>-<deliveryNo>`. */
|
|
751
|
+
function holdAt(wire, token, verdict, node, executionId) {
|
|
752
|
+
if (held.has(wire.id)) return;
|
|
753
|
+
const st = wireState.get(wire.id);
|
|
754
|
+
const deliveryNo = st.deliveries + 1; // the delivery this hold stands in for
|
|
755
|
+
const holdNo = (st.holds = (st.holds || 0) + 1); // which hold on THIS wire (1-based)
|
|
756
|
+
const issues = blockingIssues(verdict);
|
|
757
|
+
const askId = holdNo > 1
|
|
758
|
+
? `gate-${wire.id}-${deliveryNo}-h${holdNo}`
|
|
759
|
+
: `gate-${wire.id}-${deliveryNo}`;
|
|
760
|
+
const entry = { wireId: wire.id, nodeId: node.id, executionId, token, issues, askId, deliveryNo, holdNo };
|
|
761
|
+
held.set(wire.id, entry);
|
|
762
|
+
outstanding.add(wire.id);
|
|
763
|
+
onEvent('gate', {
|
|
764
|
+
wireId: wire.id, nodeId: node.id, executionId, issues, askId, deliveryNo, holdNo, status: 'held',
|
|
765
|
+
});
|
|
766
|
+
syncGate();
|
|
767
|
+
askGate(entry);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function resolveGate(wireId, answer) {
|
|
771
|
+
if (settled) return; // the run already resolved: the resume re-asks this hold (P3-36)
|
|
772
|
+
if (!outstanding.has(wireId)) return; // withdrawn by End (or already answered) — a no-op
|
|
773
|
+
outstanding.delete(wireId);
|
|
774
|
+
const entry = held.get(wireId);
|
|
775
|
+
held.delete(wireId);
|
|
776
|
+
syncGate();
|
|
777
|
+
if (!entry) return;
|
|
778
|
+
const decision = answer === 'another' ? 'another' : 'continue';
|
|
779
|
+
onEvent('gate', {
|
|
780
|
+
wireId, nodeId: entry.nodeId, executionId: entry.executionId,
|
|
781
|
+
issues: entry.issues, askId: entry.askId,
|
|
782
|
+
deliveryNo: entry.deliveryNo, holdNo: entry.holdNo, status: decision,
|
|
783
|
+
});
|
|
784
|
+
if (decision === 'another') {
|
|
785
|
+
const st = wireState.get(wireId);
|
|
786
|
+
st.allowance += 1;
|
|
787
|
+
st.deliveries += 1;
|
|
788
|
+
const w = wireById.get(wireId);
|
|
789
|
+
tokens.set(`${w.to.node}.${w.to.port}`, entry.token);
|
|
790
|
+
} else {
|
|
791
|
+
forceClean(entry);
|
|
792
|
+
}
|
|
793
|
+
snap();
|
|
794
|
+
wake();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* A4: on "continue" the held blocking token is discarded and each of the SOURCE
|
|
799
|
+
* node's clean outputs force-fires — payload = the held token's path/value when
|
|
800
|
+
* the port types match, else the clean port's latched payload, else null;
|
|
801
|
+
* `forced` + the open issues in meta either way.
|
|
802
|
+
*/
|
|
803
|
+
function forceClean(entry) {
|
|
804
|
+
const node = nodeById.get(entry.nodeId);
|
|
805
|
+
if (!node) return;
|
|
806
|
+
for (const port of (portsOfNode(node).outputs || []).filter((o) => o.when === 'clean')) {
|
|
807
|
+
const latched = outputs.get(`${node.id}.${port.id}`);
|
|
808
|
+
const payload = port.type === entry.token.type
|
|
809
|
+
? { path: entry.token.path ?? null, value: entry.token.value ?? null }
|
|
810
|
+
: latched
|
|
811
|
+
? { path: latched.path ?? null, value: latched.value ?? null }
|
|
812
|
+
: { path: null, value: null };
|
|
813
|
+
const token = makeToken({
|
|
814
|
+
type: port.type,
|
|
815
|
+
...payload,
|
|
816
|
+
meta: { issues: entry.issues },
|
|
817
|
+
sourceExecutionId: entry.executionId,
|
|
818
|
+
forced: true,
|
|
819
|
+
});
|
|
820
|
+
emitToken(node, port, token);
|
|
821
|
+
outputs.set(`${node.id}.${port.id}`, token);
|
|
822
|
+
route(node, port, token, null, entry.executionId);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** End reached: stop awaiting every outstanding ask and drop the held state. */
|
|
827
|
+
function withdrawGates() {
|
|
828
|
+
held.clear();
|
|
829
|
+
outstanding.clear();
|
|
830
|
+
syncGate();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// --- snapshot ------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
function snapshotObject() {
|
|
836
|
+
// `held` is a Map — N loop wires can block in ONE drain (two verifiers into an
|
|
837
|
+
// OR, both at allowance). Every hold is serialized; `gate`/`ask` keep their
|
|
838
|
+
// singular spec shape as the FIRST hold.
|
|
839
|
+
const gates = [...held.values()].map((g) => ({
|
|
840
|
+
wireId: g.wireId, nodeId: g.nodeId, executionId: g.executionId,
|
|
841
|
+
token: g.token, issues: g.issues, askId: g.askId,
|
|
842
|
+
deliveryNo: g.deliveryNo, holdNo: g.holdNo,
|
|
843
|
+
}));
|
|
844
|
+
const asks = gates.map((g) => ({
|
|
845
|
+
id: g.askId, kind: 'gate', wireId: g.wireId, nodeId: g.nodeId,
|
|
846
|
+
executionId: g.executionId, issues: g.issues,
|
|
847
|
+
deliveryNo: g.deliveryNo, holdNo: g.holdNo,
|
|
848
|
+
}));
|
|
849
|
+
return {
|
|
850
|
+
version: 2,
|
|
851
|
+
seq,
|
|
852
|
+
graph: template,
|
|
853
|
+
tokens: Object.fromEntries(tokens),
|
|
854
|
+
outputs: Object.fromEntries(outputs),
|
|
855
|
+
consumed: Object.fromEntries([...consumed].map(([id, m]) => [id, Object.fromEntries(m)])),
|
|
856
|
+
ordinals: Object.fromEntries(ordinals),
|
|
857
|
+
wires: Object.fromEntries([...wireState].map(([id, st]) => [id, { ...st }])),
|
|
858
|
+
// Scheduler-local like `wires`: a run that paused AFTER an output dead-ended
|
|
859
|
+
// and quiesces only after the resume must still be able to say WHY.
|
|
860
|
+
deadEnds: [...deadEnds].sort(),
|
|
861
|
+
ended: ended ? { ...ended, result: { ...ended.result } } : null,
|
|
862
|
+
// The FULL ledger entry is serialized (bindings + trigger included): reattach
|
|
863
|
+
// re-invokes `execute` with the RECORDED args, and recomputing them from
|
|
864
|
+
// `consumed` + `tokens` would silently change what a resumed execution works on.
|
|
865
|
+
execs: [...execs.values()].map((e) => ({ ...e })),
|
|
866
|
+
gates,
|
|
867
|
+
asks,
|
|
868
|
+
gate: gates[0] || null,
|
|
869
|
+
ask: asks[0] || null,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const snap = () => onSnapshot(snapshotObject());
|
|
874
|
+
|
|
875
|
+
function restore(s) {
|
|
876
|
+
if (!s) return;
|
|
877
|
+
seq = s.seq ?? 0;
|
|
878
|
+
for (const [k, v] of Object.entries(s.tokens || {})) tokens.set(k, v);
|
|
879
|
+
for (const [k, v] of Object.entries(s.outputs || {})) outputs.set(k, v);
|
|
880
|
+
for (const [id, m] of Object.entries(s.consumed || {})) consumed.set(id, new Map(Object.entries(m)));
|
|
881
|
+
for (const [id, n] of Object.entries(s.ordinals || {})) ordinals.set(id, n);
|
|
882
|
+
for (const [id, st] of Object.entries(s.wires || {})) wireState.set(id, { ...st });
|
|
883
|
+
for (const id of s.deadEnds || []) deadEnds.add(id);
|
|
884
|
+
for (const e of s.execs || []) execs.set(e.executionId, { ...e });
|
|
885
|
+
ended = s.ended ? { ...s.ended, result: { ...s.ended.result } } : null;
|
|
886
|
+
// Every hold comes back (reattach re-asks all of them). A pre-plural resume point
|
|
887
|
+
// carries only the singular `gate`; read it as a one-element list.
|
|
888
|
+
const gates = Array.isArray(s.gates) ? s.gates : (s.gate ? [s.gate] : []);
|
|
889
|
+
if (!ended) for (const g of gates) held.set(g.wireId, { ...g });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Restore a snapshot and re-invoke `execute` once per NON-TERMINAL execution with
|
|
894
|
+
* exactly the recorded arguments — the injected execute decides re-attach vs
|
|
895
|
+
* re-run. Call BEFORE `run()`.
|
|
896
|
+
*
|
|
897
|
+
* A composite's slices are re-invoked BY their shell, not from here: the shell
|
|
898
|
+
* re-runs the whole fan-out (v1 resumed the decomposed stage whole) and re-mints
|
|
899
|
+
* the same ids, so those stale rows are overwritten in place.
|
|
900
|
+
*/
|
|
901
|
+
function reattach(snapshot) {
|
|
902
|
+
restore(snapshot);
|
|
903
|
+
for (const entry of [...execs.values()]) {
|
|
904
|
+
if (TERMINAL.has(entry.status)) continue;
|
|
905
|
+
if (entry.kind === 'task') continue;
|
|
906
|
+
const node = nodeById.get(entry.nodeId);
|
|
907
|
+
if (!node) continue;
|
|
908
|
+
running.set(node.id, entry.executionId);
|
|
909
|
+
const h = { node, entry, args: argsFor(node, entry), composite: !!entry.expandsPort };
|
|
910
|
+
if (!isFlow(node) && !h.composite) activeAgents += 1;
|
|
911
|
+
let p;
|
|
912
|
+
try { p = invoke(h); } catch (err) { p = Promise.reject(err); }
|
|
913
|
+
Promise.resolve(p).then(
|
|
914
|
+
(res) => { completions.push({ h, res, err: null }); wake(); },
|
|
915
|
+
(err) => { completions.push({ h, res: null, err }); wake(); },
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
// A gate restored without an End re-raises its ask — otherwise the held token has
|
|
919
|
+
// nobody to answer for it and the run would deadlock.
|
|
920
|
+
for (const entry of [...held.values()]) {
|
|
921
|
+
if (outstanding.has(entry.wireId)) continue;
|
|
922
|
+
outstanding.add(entry.wireId);
|
|
923
|
+
askGate(entry);
|
|
924
|
+
}
|
|
925
|
+
syncGate();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// --- readiness -----------------------------------------------------------
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Amendment f, §3 "Firing rule". Flow kinds first (Task fires once; End/AND/
|
|
932
|
+
* Combine all-fresh every execution; OR any-fresh), then the agent rules: the
|
|
933
|
+
* first-run barrier over wired non-loop inputs (the synthesized `await` port
|
|
934
|
+
* included), then any-fresh (default) or awaitAll.
|
|
935
|
+
*/
|
|
936
|
+
function isReady(node) {
|
|
937
|
+
const inputs = portsOfNode(node).inputs || [];
|
|
938
|
+
const wired = wiredIn.get(node.id) || new Map();
|
|
939
|
+
const spent = spentOf(node.id);
|
|
940
|
+
const everRan = (ordinals.get(node.id) || 0) > 0;
|
|
941
|
+
const awaitAll = node.config?.awaitAll === true;
|
|
942
|
+
const isFresh = (port) => {
|
|
943
|
+
const token = tokens.get(`${node.id}.${port}`);
|
|
944
|
+
if (!token) return false;
|
|
945
|
+
const prior = spent.get(port);
|
|
946
|
+
return prior === undefined || token.seq > prior;
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
if (isFlow(node)) {
|
|
950
|
+
switch (node.kind) {
|
|
951
|
+
case 'task':
|
|
952
|
+
return !everRan; // zero inputs; fires once at t0
|
|
953
|
+
case 'end':
|
|
954
|
+
case 'and':
|
|
955
|
+
case 'combine':
|
|
956
|
+
return inputs.length > 0 && inputs.every((inp) => isFresh(inp.id));
|
|
957
|
+
case 'or':
|
|
958
|
+
return inputs.some((inp) => isFresh(inp.id));
|
|
959
|
+
default:
|
|
960
|
+
return false; // unknown kind (V3)
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (!everRan) {
|
|
965
|
+
for (const inp of inputs) {
|
|
966
|
+
if (!wired.has(inp.id)) {
|
|
967
|
+
// V9 blocks this at save; stay defensively un-ready rather than firing a
|
|
968
|
+
// node whose required payload can never arrive. Loop inputs are exempt.
|
|
969
|
+
if (inp.required && !isLoopPort(node.id, inp)) return false;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
if (isLoopPort(node.id, inp)) continue; // excused from the barrier
|
|
973
|
+
if (!tokens.get(`${node.id}.${inp.id}`)) return false;
|
|
974
|
+
}
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (!awaitAll) return inputs.some((inp) => isFresh(inp.id));
|
|
979
|
+
|
|
980
|
+
// awaitAll: a fresh loop token alone always re-fires (the loop path is the point).
|
|
981
|
+
if (inputs.some((inp) => isLoopPort(node.id, inp) && isFresh(inp.id))) return true;
|
|
982
|
+
let barrier = false;
|
|
983
|
+
for (const inp of inputs) {
|
|
984
|
+
if (!wired.has(inp.id) || isLoopPort(node.id, inp)) continue;
|
|
985
|
+
barrier = true;
|
|
986
|
+
if (!isFresh(inp.id)) return false;
|
|
987
|
+
}
|
|
988
|
+
return barrier;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const halted = () => Boolean(ended) || Boolean(failure) || pauseRequested || abortRequested;
|
|
992
|
+
|
|
993
|
+
async function drainPasses() {
|
|
994
|
+
for (;;) {
|
|
995
|
+
let fired = false;
|
|
996
|
+
for (const nodeId of order) {
|
|
997
|
+
if (halted()) return; // pause/abort/End checked at every launch decision
|
|
998
|
+
const node = nodeById.get(nodeId);
|
|
999
|
+
if (!node || running.has(nodeId) || !isReady(node)) continue;
|
|
1000
|
+
if (isFlow(node)) { await fireFlow(node); fired = true; continue; }
|
|
1001
|
+
if (activeAgents >= maxParallel) continue; // capped: retried once a slot frees
|
|
1002
|
+
fireAgent(node);
|
|
1003
|
+
fired = true;
|
|
1004
|
+
}
|
|
1005
|
+
if (!fired) return;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function finish(result) {
|
|
1010
|
+
settled = true;
|
|
1011
|
+
if (result === 'error') controller.abort();
|
|
1012
|
+
if (result === 'done' && !ended) {
|
|
1013
|
+
warnings.push(QUIESCENCE_WARNING);
|
|
1014
|
+
log(QUIESCENCE_WARNING);
|
|
1015
|
+
if (deadEnds.size) {
|
|
1016
|
+
const detail = quiescenceDeadEnd([...deadEnds].sort());
|
|
1017
|
+
warnings.push(detail);
|
|
1018
|
+
log(detail);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
snap(); // one final snapshot at run resolution
|
|
1022
|
+
return result;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async function run() {
|
|
1026
|
+
for (;;) {
|
|
1027
|
+
while (completions.length) {
|
|
1028
|
+
const c = completions.shift();
|
|
1029
|
+
settle(c.h, c.res, c.err);
|
|
1030
|
+
}
|
|
1031
|
+
if (failure) return finish('error');
|
|
1032
|
+
if (abortRequested) return finish('error');
|
|
1033
|
+
if (!halted()) await drainPasses();
|
|
1034
|
+
if (failure) return finish('error');
|
|
1035
|
+
if (abortRequested) return finish('error');
|
|
1036
|
+
// Anything that landed while the pass ran — a completion, or a gate answer
|
|
1037
|
+
// that delivered its held token — gets its own pass before quiescence.
|
|
1038
|
+
if (completions.length || signalled) { signalled = false; continue; }
|
|
1039
|
+
if (running.size === 0) {
|
|
1040
|
+
if (ended) return finish('done');
|
|
1041
|
+
if (pauseRequested) return finish('paused');
|
|
1042
|
+
if (held.size === 0 && outstanding.size === 0) return finish('done'); // quiescence
|
|
1043
|
+
}
|
|
1044
|
+
await waitForChange();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
run,
|
|
1050
|
+
reattach,
|
|
1051
|
+
pause() { pauseRequested = true; wake(); },
|
|
1052
|
+
abort() { abortRequested = true; controller.abort(); wake(); },
|
|
1053
|
+
getState() {
|
|
1054
|
+
return {
|
|
1055
|
+
active: [...running].map(([nodeId, executionId]) => ({ nodeId, executionId })),
|
|
1056
|
+
executions: [...execs.values()].map((e) => ({ ...e })),
|
|
1057
|
+
// Latched OUTPUT tokens (what a wire carries), keyed '<node>.<outputPort>'.
|
|
1058
|
+
tokens: Object.fromEntries([...outputs].map(([k, t]) => [
|
|
1059
|
+
k, { seq: t.seq, type: t.type, path: t.path ?? null, firedAt: t.firedAt },
|
|
1060
|
+
])),
|
|
1061
|
+
wireDeliveries: Object.fromEntries([...wireState].map(([id, st]) => [id, st.deliveries])),
|
|
1062
|
+
ended: ended ? { ...ended, result: { ...ended.result } } : null,
|
|
1063
|
+
endReached: Boolean(ended),
|
|
1064
|
+
result: ended ? { ...ended.result } : null,
|
|
1065
|
+
warnings: [...warnings],
|
|
1066
|
+
gate: gate ? { ...gate } : null,
|
|
1067
|
+
settled,
|
|
1068
|
+
};
|
|
1069
|
+
},
|
|
1070
|
+
get settled() { return settled; },
|
|
1071
|
+
};
|
|
1072
|
+
}
|