@qaick/executor 0.1.0-alpha.3 → 0.2.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/executor.d.ts +17 -4
- package/dist/executor.js +532 -244
- package/dist/migrated-artifact-hash.d.ts +1 -1
- package/dist/migrated-executor.d.ts +6 -4
- package/dist/migrated-executor.js +32 -10
- package/dist/types.d.ts +82 -6
- package/package.json +3 -3
package/dist/executor.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { resolveDeclaredInputs } from './declared-inputs.js';
|
|
2
|
+
import { executionOccurrences, executionOperandDependencyIssues, hierarchyAddressIssues } from '@qaick/contract';
|
|
3
|
+
const addressKey = (address) => JSON.stringify([address.scope, address.segments.map(s => s.kind === 'body' ? ['body'] : [s.kind, s.position])]);
|
|
4
|
+
const invocationKey = (address, iterations) => JSON.stringify([addressKey(address), iterations.map(i => [addressKey(i.loopAddress), i.iteration])]);
|
|
5
|
+
function freezePlan(value) {
|
|
6
|
+
if (value && typeof value === 'object') {
|
|
7
|
+
Object.values(value).forEach(freezePlan);
|
|
8
|
+
Object.freeze(value);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
2
11
|
const MAX_TIMEOUT_MS = 300_000;
|
|
3
12
|
export function createExecutor(configuration = {}) {
|
|
4
13
|
return new DefaultExecutor(configuration);
|
|
@@ -13,21 +22,37 @@ export class DefaultExecutor {
|
|
|
13
22
|
}
|
|
14
23
|
registerActionExecutor(executor) { this.actionExecutors.push(executor); }
|
|
15
24
|
async execute(request) {
|
|
25
|
+
return this.startRun(request);
|
|
26
|
+
}
|
|
27
|
+
async startRun(request, debugSeed, debugProvenance) {
|
|
28
|
+
if (this.runs.has(request.requestId))
|
|
29
|
+
throw failure('PREFLIGHT_ERROR', 'Execution request ID already belongs to a Run.');
|
|
30
|
+
const requestSnapshot = structuredClone(request);
|
|
31
|
+
const validation = [];
|
|
32
|
+
let plan = new Map();
|
|
33
|
+
try {
|
|
34
|
+
// Shared traversal validates the projection before constructing the immutable plan.
|
|
35
|
+
plan = new Map(executionOccurrences(requestSnapshot.executionPackage).map(o => [addressKey(o.address), o]));
|
|
36
|
+
freezePlan(requestSnapshot.executionPackage);
|
|
37
|
+
}
|
|
38
|
+
catch (cause) {
|
|
39
|
+
validation.push({ code: 'invalid-execution-package', message: cause instanceof Error ? cause.message : 'Invalid Execution v7 projection.' });
|
|
40
|
+
}
|
|
16
41
|
let resolve;
|
|
17
42
|
const result = new Promise(promiseResolve => { resolve = promiseResolve; });
|
|
18
43
|
const run = {
|
|
19
|
-
request, resolve, events: [], listeners: [], abort: new AbortController(), cancelled: false,
|
|
44
|
+
debugSeed, debugProvenance, request: requestSnapshot, plan, journal: new Map(), replayCaptures: new Map(), resolve, events: [], listeners: [], abort: new AbortController(), cancelled: false,
|
|
20
45
|
timedOut: false, controlState: 'idle', pauseRequested: false, stepRequested: false,
|
|
21
46
|
controlWaiters: [], hitBreakpoints: new Set(), stopAfter: request.debugSession?.stopAfterStepId,
|
|
22
47
|
startedMs: Date.now(), pausedDurationMs: 0,
|
|
23
48
|
};
|
|
24
49
|
this.runs.set(request.requestId, run);
|
|
25
|
-
void this.run(run);
|
|
26
|
-
return { requestId: request.requestId, result };
|
|
50
|
+
void this.run(run, validation);
|
|
51
|
+
return { requestId: request.requestId, result, ...(debugProvenance ? { debugProvenance } : {}) };
|
|
27
52
|
}
|
|
28
53
|
async cancel(requestId) {
|
|
29
54
|
const run = this.runs.get(requestId);
|
|
30
|
-
if (!run || run.result)
|
|
55
|
+
if (!run || run.result || run.abort.signal.aborted)
|
|
31
56
|
return;
|
|
32
57
|
run.cancelled = true;
|
|
33
58
|
run.controlState = 'stopped';
|
|
@@ -92,85 +117,287 @@ export class DefaultExecutor {
|
|
|
92
117
|
run.resolve = value => { previous(value); resolve(value); };
|
|
93
118
|
});
|
|
94
119
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
// Terminal Act replay targets only private original structural captures.
|
|
121
|
+
replayTarget(requestId, target, kind) {
|
|
122
|
+
const parent = this.runs.get(requestId);
|
|
123
|
+
const reject = (message) => { throw failure('unsupported-execution-context', message); };
|
|
124
|
+
if (!parent?.result)
|
|
125
|
+
reject('Act replay requires a terminal original Run.');
|
|
126
|
+
const run = parent;
|
|
127
|
+
if (run.cancelled || run.timedOut)
|
|
128
|
+
reject('Interrupted original Runs cannot be replayed.');
|
|
129
|
+
if (run.activeReplay)
|
|
130
|
+
reject('An interactive replay session is already active for this Run.');
|
|
131
|
+
if (!isRecord(target) || Object.keys(target).some(key => !['structuralAddress', 'iterations'].includes(key)) || hierarchyAddressIssues(target.structuralAddress).length || !Array.isArray(target.iterations))
|
|
132
|
+
reject('Malformed structural replay target.');
|
|
133
|
+
const occurrence = run.plan.get(addressKey(target.structuralAddress));
|
|
134
|
+
if (!occurrence)
|
|
135
|
+
reject('Replay structural target does not exist in the original plan.');
|
|
136
|
+
const selections = target.iterations;
|
|
137
|
+
if (selections.length !== occurrence.loops.length || selections.some(selection => !isRecord(selection) || Object.keys(selection).some(key => !['loopAddress', 'iteration'].includes(key))
|
|
138
|
+
|| hierarchyAddressIssues(selection.loopAddress).length || !Number.isSafeInteger(selection.iteration) || selection.iteration < 1)
|
|
139
|
+
|| new Set(selections.map(selection => addressKey(selection.loopAddress))).size !== selections.length
|
|
140
|
+
|| occurrence.loops.some(loop => !selections.some(selection => addressKey(selection.loopAddress) === addressKey(loop))))
|
|
141
|
+
reject('Replay requires the complete explicit actual loop vector.');
|
|
142
|
+
const iterations = occurrence.loops.map(loop => ({ loopAddress: loop, iteration: selections.find(selection => addressKey(selection.loopAddress) === addressKey(loop)).iteration }));
|
|
143
|
+
const capture = run.replayCaptures.get(invocationKey(target.structuralAddress, iterations));
|
|
144
|
+
if (!capture || capture.kind !== kind)
|
|
145
|
+
reject('No original matching input capture exists for that structural invocation.');
|
|
146
|
+
return { run, occurrence: occurrence, capture: capture };
|
|
147
|
+
}
|
|
148
|
+
async retryAction(requestId, target) {
|
|
149
|
+
const { run, occurrence, capture } = this.replayTarget(requestId, target, 'action');
|
|
150
|
+
const snapshot = structuredClone({ structuralAddress: capture.structuralAddress, iterations: capture.iterations });
|
|
151
|
+
freezePlan(snapshot);
|
|
152
|
+
const session = {
|
|
153
|
+
parentRequestId: requestId, sessionId: crypto.randomUUID(), target: snapshot,
|
|
154
|
+
request: structuredClone(run.request), abort: new AbortController(), cancelled: false, timedOut: false,
|
|
155
|
+
kind: 'action', events: [], listeners: [],
|
|
156
|
+
};
|
|
157
|
+
run.activeReplay = session;
|
|
158
|
+
const result = this.replayAct(run, session, occurrence.step, capture.inputs);
|
|
159
|
+
const handle = {
|
|
160
|
+
parentRequestId: requestId, sessionId: session.sessionId, target: snapshot, result,
|
|
161
|
+
events: () => this.replayEvents(session),
|
|
162
|
+
cancel: async () => {
|
|
163
|
+
if (session.result || session.abort.signal.aborted)
|
|
164
|
+
return;
|
|
165
|
+
session.cancelled = true;
|
|
166
|
+
session.abort.abort();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
return Object.freeze(handle);
|
|
170
|
+
}
|
|
171
|
+
async *replayEvents(session) {
|
|
172
|
+
let index = 0;
|
|
173
|
+
while (true) {
|
|
174
|
+
while (index < session.events.length)
|
|
175
|
+
yield session.events[index++];
|
|
176
|
+
if (session.result)
|
|
177
|
+
return;
|
|
178
|
+
await new Promise(resolve => session.listeners.push(resolve));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
emitReplay(session, type, state, attempt, terminalReason) {
|
|
182
|
+
const event = {
|
|
183
|
+
parentRequestId: session.parentRequestId, sessionId: session.sessionId,
|
|
184
|
+
sequence: session.events.length + 1, timestamp: new Date().toISOString(),
|
|
185
|
+
...structuredClone(session.target), kind: session.kind, type, state,
|
|
186
|
+
...(attempt === undefined ? {} : { attempt }), ...(terminalReason ? { terminalReason } : {}),
|
|
187
|
+
};
|
|
188
|
+
freezePlan(event);
|
|
189
|
+
session.events.push(event);
|
|
190
|
+
session.listeners.splice(0).forEach(resolve => resolve());
|
|
191
|
+
}
|
|
192
|
+
async replayAct(parent, session, step, inputs) {
|
|
193
|
+
const startedMs = Date.now(), startedAt = new Date().toISOString();
|
|
194
|
+
const attempts = [];
|
|
195
|
+
let timer;
|
|
196
|
+
let outputs = {}, evidence, error;
|
|
197
|
+
let state = 'completed';
|
|
104
198
|
try {
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
199
|
+
// The approved session deadline is fresh and covers all attempts and retry delays.
|
|
200
|
+
const timeoutMs = session.request.options?.timeoutMs;
|
|
201
|
+
if (timeoutMs !== undefined)
|
|
202
|
+
timer = setTimeout(() => {
|
|
203
|
+
if (session.abort.signal.aborted)
|
|
204
|
+
return;
|
|
205
|
+
session.timedOut = true;
|
|
206
|
+
session.abort.abort();
|
|
207
|
+
}, timeoutMs);
|
|
208
|
+
this.emitReplay(session, 'replay_started', 'running');
|
|
209
|
+
const result = await this.executeAction(session, step, inputs, attempts, session.abort.signal, (type, attempt, attemptState) => this.emitReplay(session, type, attemptState, attempt));
|
|
210
|
+
this.assertActive(session, session.abort.signal);
|
|
211
|
+
outputs = structuredClone(result.outputs);
|
|
212
|
+
evidence = structuredClone(result.evidence);
|
|
119
213
|
}
|
|
120
214
|
catch (cause) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
215
|
+
error = structuredClone(this.failure(cause, session));
|
|
216
|
+
state = session.timedOut ? 'failed' : session.cancelled ? 'cancelled' : 'failed';
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
if (timer !== undefined)
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
// One admitted session owns this lock and releases it on every terminal path.
|
|
222
|
+
parent.activeReplay = undefined;
|
|
125
223
|
}
|
|
224
|
+
const terminalReason = session.timedOut ? 'timeout' : state === 'cancelled' ? 'cancelled' : state === 'failed' ? 'failed' : 'completed';
|
|
225
|
+
const result = {
|
|
226
|
+
parentRequestId: session.parentRequestId, sessionId: session.sessionId, target: structuredClone(session.target), kind: 'action',
|
|
227
|
+
state, terminalReason, startedAt, finishedAt: new Date().toISOString(), durationMs: Date.now() - startedMs,
|
|
228
|
+
inputs: structuredClone(inputs), outputs, ...(evidence ? { evidence } : {}), attempts,
|
|
229
|
+
...(error ? { error } : {}),
|
|
230
|
+
};
|
|
231
|
+
freezePlan(result);
|
|
232
|
+
session.result = result;
|
|
233
|
+
this.emitReplay(session, 'replay_finished', state, undefined, terminalReason);
|
|
234
|
+
return result;
|
|
126
235
|
}
|
|
127
|
-
async retryOperation(requestId,
|
|
128
|
-
const run = this.
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
async runFromHere(requestId, stepId, invocationPath) {
|
|
150
|
-
const run = this.runs.get(requestId);
|
|
151
|
-
if (!run)
|
|
152
|
-
return undefined;
|
|
153
|
-
if (invocationPath?.length)
|
|
154
|
-
return undefined;
|
|
155
|
-
const index = run.request.executionPackage.steps.findIndex(step => step.id === stepId);
|
|
156
|
-
if (index < 0)
|
|
157
|
-
return undefined;
|
|
158
|
-
const selected = findStep(run.result?.steps ?? [], stepId, invocationPath);
|
|
159
|
-
if (!selected)
|
|
160
|
-
return undefined;
|
|
161
|
-
const requestIdForRun = `${requestId}:from:${stepId}`;
|
|
162
|
-
const remainingSteps = run.request.executionPackage.steps.slice(index).map((step, offset) => offset === 0
|
|
163
|
-
? { ...step, bindings: undefined, staticInputs: { ...(step.staticInputs ?? {}), ...selected.inputs } }
|
|
164
|
-
: step);
|
|
165
|
-
return this.execute({
|
|
166
|
-
...run.request,
|
|
167
|
-
requestId: requestIdForRun,
|
|
168
|
-
executionPackage: { ...run.request.executionPackage, steps: remainingSteps, target: { kind: 'flow', id: run.request.executionPackage.target.id, name: run.request.executionPackage.target.name } },
|
|
169
|
-
inputs: selected.inputs,
|
|
236
|
+
async retryOperation(requestId, target) {
|
|
237
|
+
const { run, occurrence, capture } = this.replayTarget(requestId, target, 'operation');
|
|
238
|
+
if (capture.kind !== 'operation')
|
|
239
|
+
throw failure('unsupported-execution-context', 'Operation capture required.');
|
|
240
|
+
const snapshot = structuredClone({ structuralAddress: capture.structuralAddress, iterations: capture.iterations });
|
|
241
|
+
freezePlan(snapshot);
|
|
242
|
+
const session = {
|
|
243
|
+
parentRequestId: requestId, sessionId: crypto.randomUUID(), target: snapshot, kind: 'operation',
|
|
244
|
+
request: structuredClone(run.request), abort: new AbortController(), cancelled: false, timedOut: false,
|
|
245
|
+
events: [], listeners: [],
|
|
246
|
+
};
|
|
247
|
+
run.activeReplay = session;
|
|
248
|
+
const result = this.replayOperation(run, session, occurrence.step, capture);
|
|
249
|
+
return Object.freeze({
|
|
250
|
+
parentRequestId: requestId, sessionId: session.sessionId, target: snapshot, result,
|
|
251
|
+
events: () => this.replayEvents(session),
|
|
252
|
+
cancel: async () => {
|
|
253
|
+
if (session.result || session.abort.signal.aborted)
|
|
254
|
+
return;
|
|
255
|
+
session.cancelled = true;
|
|
256
|
+
session.abort.abort();
|
|
257
|
+
},
|
|
170
258
|
});
|
|
171
259
|
}
|
|
172
|
-
async
|
|
260
|
+
async replayOperation(parent, session, step, capture) {
|
|
261
|
+
const startedMs = Date.now(), startedAt = new Date().toISOString();
|
|
262
|
+
// Separate structural state, not a new Run: no registration, root result, resolver,
|
|
263
|
+
// historical journal, controls, captures or original event stream are reused.
|
|
264
|
+
const context = {
|
|
265
|
+
request: session.request, plan: parent.plan, journal: new Map(), replayCaptures: new Map(),
|
|
266
|
+
replay: { session, capture, root: capture.structuralAddress },
|
|
267
|
+
events: [], listeners: [], abort: session.abort,
|
|
268
|
+
get cancelled() { return session.cancelled; }, get timedOut() { return session.timedOut; },
|
|
269
|
+
controlState: 'running', pauseRequested: false, stepRequested: false,
|
|
270
|
+
controlWaiters: [], hitBreakpoints: new Set(), startedMs, pausedDurationMs: 0,
|
|
271
|
+
};
|
|
272
|
+
let timer;
|
|
273
|
+
let operation, error;
|
|
274
|
+
let state = 'completed';
|
|
275
|
+
try {
|
|
276
|
+
const timeoutMs = session.request.options?.timeoutMs;
|
|
277
|
+
if (timeoutMs !== undefined)
|
|
278
|
+
timer = setTimeout(() => {
|
|
279
|
+
if (session.abort.signal.aborted)
|
|
280
|
+
return;
|
|
281
|
+
session.timedOut = true;
|
|
282
|
+
session.abort.abort();
|
|
283
|
+
}, timeoutMs);
|
|
284
|
+
this.emitReplay(session, 'replay_started', 'running');
|
|
285
|
+
operation = await this.executeStep(context, step, capture.lexicalScope, [], session.abort.signal, capture.iterations);
|
|
286
|
+
this.assertActive(session, session.abort.signal);
|
|
287
|
+
if (operation.state !== 'finished') {
|
|
288
|
+
state = 'failed';
|
|
289
|
+
error = operation.error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (cause) {
|
|
293
|
+
error = structuredClone(this.failure(cause, session));
|
|
294
|
+
state = session.timedOut ? 'failed' : session.cancelled ? 'cancelled' : 'failed';
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
if (timer !== undefined)
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
parent.activeReplay = undefined;
|
|
300
|
+
}
|
|
301
|
+
const terminalReason = session.timedOut ? 'timeout' : state === 'cancelled' ? 'cancelled' : state === 'failed' ? 'failed' : 'completed';
|
|
302
|
+
const result = {
|
|
303
|
+
parentRequestId: session.parentRequestId, sessionId: session.sessionId,
|
|
304
|
+
target: structuredClone(session.target), kind: 'operation', state, terminalReason,
|
|
305
|
+
startedAt, finishedAt: new Date().toISOString(), durationMs: Date.now() - startedMs,
|
|
306
|
+
inputs: structuredClone(capture.inputs), lexicalScope: structuredClone(capture.lexicalScope),
|
|
307
|
+
children: structuredClone(operation?.children ?? []), outputs: state === 'completed' ? structuredClone(operation?.outputs ?? {}) : {},
|
|
308
|
+
...(error ? { error: structuredClone(error) } : {}),
|
|
309
|
+
};
|
|
310
|
+
freezePlan(result);
|
|
311
|
+
session.result = result;
|
|
312
|
+
this.emitReplay(session, 'replay_finished', state, undefined, terminalReason);
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
async runFromHere(sourceRequestId, target) {
|
|
316
|
+
const source = this.runs.get(sourceRequestId);
|
|
317
|
+
const reject = (message) => { throw failure('unsupported-execution-context', message); };
|
|
318
|
+
if (!source?.result || source.cancelled || source.timedOut)
|
|
319
|
+
reject('Run From Here requires a terminal uninterrupted source Run.');
|
|
320
|
+
if (!isRecord(target) || Object.keys(target).some(k => !['structuralAddress', 'iterations'].includes(k))
|
|
321
|
+
|| hierarchyAddressIssues(target.structuralAddress).length || !Array.isArray(target.iterations) || target.iterations.length)
|
|
322
|
+
reject('Run From Here requires a top-level target with an empty iteration vector.');
|
|
323
|
+
const address = target.structuralAddress, segments = address.segments;
|
|
324
|
+
const topLevel = address.scope === 'routine'
|
|
325
|
+
? segments.length === 2 && segments[0].kind === 'flow' && segments[1].kind === 'step'
|
|
326
|
+
: segments.length === 1 && segments[0].kind === 'step';
|
|
327
|
+
if (!topLevel)
|
|
328
|
+
reject('Nested/control continuation is not supported by Run From Here.');
|
|
329
|
+
const parent = source;
|
|
330
|
+
const capture = parent.replayCaptures.get(invocationKey(address, []));
|
|
331
|
+
if (!capture || (capture.kind !== 'action' && capture.kind !== 'operation'))
|
|
332
|
+
reject('No eligible private original input capture exists.');
|
|
333
|
+
const package_ = structuredClone(parent.request.executionPackage);
|
|
334
|
+
const position = segments.at(-1);
|
|
335
|
+
if (position.kind !== 'step')
|
|
336
|
+
throw failure('unsupported-execution-context', 'Expected a top-level step.');
|
|
337
|
+
const stepPosition = position.position;
|
|
338
|
+
const omitted = [];
|
|
339
|
+
if (package_.executionContext.mode === 'routine') {
|
|
340
|
+
const first = segments[0];
|
|
341
|
+
if (first.kind !== 'flow')
|
|
342
|
+
throw failure('unsupported-execution-context', 'Routine selection requires an original Flow ordinal.');
|
|
343
|
+
const flowPosition = first.position;
|
|
344
|
+
const omittedFlowInputs = new Set((package_.flows ?? []).filter(f => f.structuralPosition < flowPosition).flatMap(f => (f.inputDeclarations ?? []).map(d => d.name)));
|
|
345
|
+
package_.flows = (package_.flows ?? []).filter(flow => {
|
|
346
|
+
if (flow.structuralPosition < flowPosition) {
|
|
347
|
+
omitted.push(...flow.steps.map(s => s.structuralAddress));
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
if (flow.structuralPosition === flowPosition)
|
|
351
|
+
flow.steps = flow.steps.filter(step => {
|
|
352
|
+
const last = step.structuralAddress.segments.at(-1);
|
|
353
|
+
if (last.kind === 'step' && last.position < stepPosition) {
|
|
354
|
+
omitted.push(step.structuralAddress);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return true;
|
|
358
|
+
});
|
|
359
|
+
return true;
|
|
360
|
+
});
|
|
361
|
+
const retainedRequirements = new Set([...(package_.inputDeclarations ?? []).map(d => d.name), ...(package_.flows ?? []).flatMap(f => [...(f.requiredInputs ?? []), ...(f.inputDeclarations ?? []).map(d => d.name)])]);
|
|
362
|
+
if (package_.requiredInputs)
|
|
363
|
+
package_.requiredInputs = package_.requiredInputs.filter(name => !omittedFlowInputs.has(name) || retainedRequirements.has(name));
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
package_.steps = package_.steps.filter(step => {
|
|
367
|
+
const last = step.structuralAddress.segments.at(-1);
|
|
368
|
+
if (last.kind === 'step' && last.position < stepPosition) {
|
|
369
|
+
omitted.push(step.structuralAddress);
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
// Keep existing omissions and original parent/Flow ordinals; never lift or renumber.
|
|
376
|
+
const omissions = new Map((package_.omittedOccurrences ?? []).map(o => [addressKey(o.address), o]));
|
|
377
|
+
for (const omittedAddress of omitted)
|
|
378
|
+
omissions.set(addressKey(omittedAddress), { address: omittedAddress, reason: 'Run From Here predecessor omitted.' });
|
|
379
|
+
package_.omittedOccurrences = [...omissions.values()];
|
|
380
|
+
const seed = {
|
|
381
|
+
structuralAddress: structuredClone(capture.structuralAddress), inputs: structuredClone(capture.inputs),
|
|
382
|
+
...(capture.kind === 'operation' ? { kind: 'operation', lexicalScope: structuredClone(capture.lexicalScope) } : { kind: 'action' }),
|
|
383
|
+
};
|
|
384
|
+
const provenance = {
|
|
385
|
+
kind: 'run-from-here', sourceRequestId, selection: structuredClone(target),
|
|
386
|
+
inputSeed: { kind: 'captured-inputs', inputs: structuredClone(capture.inputs),
|
|
387
|
+
...(capture.kind === 'operation' ? { lexicalScope: structuredClone(capture.lexicalScope) } : {}) },
|
|
388
|
+
omittedOccurrences: package_.omittedOccurrences.map(o => structuredClone(o.address)),
|
|
389
|
+
};
|
|
390
|
+
freezePlan(seed);
|
|
391
|
+
freezePlan(provenance);
|
|
392
|
+
// Normal Run creation is independent of replay admission and has no external evidence view.
|
|
393
|
+
return this.startRun({ ...structuredClone(parent.request), requestId: crypto.randomUUID(), executionPackage: package_ }, seed, provenance);
|
|
394
|
+
}
|
|
395
|
+
async run(run, validation) {
|
|
173
396
|
const startedAt = new Date().toISOString();
|
|
397
|
+
if (validation.length) {
|
|
398
|
+
this.finish(run, this.baseResult(run, startedAt, 'failed', undefined, validation));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
174
401
|
const timeoutMs = run.request.options?.timeoutMs;
|
|
175
402
|
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > this.maxTimeoutMs)) {
|
|
176
403
|
this.finish(run, this.baseResult(run, startedAt, 'failed', { code: 'PREFLIGHT_ERROR', message: `Timeout must be between 0 and ${this.maxTimeoutMs} ms.` }));
|
|
@@ -178,7 +405,8 @@ export class DefaultExecutor {
|
|
|
178
405
|
}
|
|
179
406
|
let timeout;
|
|
180
407
|
if (timeoutMs !== undefined)
|
|
181
|
-
timeout = setTimeout(() => {
|
|
408
|
+
timeout = setTimeout(() => { if (run.abort.signal.aborted)
|
|
409
|
+
return; run.timedOut = true; run.abort.abort(); }, timeoutMs);
|
|
182
410
|
this.emit(run, 'run_started', 'created', 'Execution accepted.');
|
|
183
411
|
this.emit(run, 'preflight_started', 'preflighting', 'Validating execution package.');
|
|
184
412
|
const arguments_ = resolveDeclaredInputs(run.request.executionPackage.inputDeclarations, run.request.inputs);
|
|
@@ -204,7 +432,7 @@ export class DefaultExecutor {
|
|
|
204
432
|
if (timeout)
|
|
205
433
|
clearTimeout(timeout);
|
|
206
434
|
const error = this.failure(cause, run);
|
|
207
|
-
this.finish(run, this.baseResult(run, startedAt, run.timedOut ? '
|
|
435
|
+
this.finish(run, this.baseResult(run, startedAt, run.cancelled || run.abort.signal.aborted && !run.timedOut ? 'cancelled' : 'failed', error));
|
|
208
436
|
}
|
|
209
437
|
}
|
|
210
438
|
async executeProject(run, startedAt) {
|
|
@@ -222,33 +450,34 @@ export class DefaultExecutor {
|
|
|
222
450
|
children.push(child);
|
|
223
451
|
if (child.state !== 'completed')
|
|
224
452
|
return this.baseResult(run, startedAt, child.state, child.error, undefined, children);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
? Object.fromEntries(Object.entries(flow.outputBindings).flatMap(([name, ref]) => {
|
|
228
|
-
const match = /^\$outputs\.(.+)$/.exec(ref);
|
|
229
|
-
return match && last[match[1]] !== undefined ? [[name, last[match[1]]]] : [];
|
|
230
|
-
}))
|
|
231
|
-
: last;
|
|
232
|
-
inputs = { ...inputs, ...mapped };
|
|
453
|
+
if (flow.outputBindings && Object.keys(flow.outputBindings).length)
|
|
454
|
+
throw failure('unsupported-context', 'Flow completion exports are outside this foundation slice.');
|
|
233
455
|
}
|
|
234
456
|
return this.baseResult(run, startedAt, 'completed', undefined, undefined, children);
|
|
235
457
|
}
|
|
236
|
-
async executeFlow(run, steps, initialInputs, path, startedAt, target = run.request.executionPackage.target, signal = run.abort.signal) {
|
|
237
|
-
const outputsById = new Map();
|
|
458
|
+
async executeFlow(run, steps, initialInputs, path, startedAt, target = run.request.executionPackage.target, signal = run.abort.signal, iterations = [], bodyFailurePolicy = 'stop') {
|
|
238
459
|
const results = [];
|
|
239
460
|
let state = 'completed';
|
|
240
461
|
let error;
|
|
241
|
-
for (const step of
|
|
462
|
+
for (const step of steps) {
|
|
463
|
+
if (run.cancelled || signal.aborted)
|
|
464
|
+
throw new Error('Execution cancelled.');
|
|
465
|
+
await this.boundary(run, step, [...path, step.id], signal, iterations);
|
|
242
466
|
if (run.cancelled || signal.aborted)
|
|
243
467
|
throw new Error('Execution cancelled.');
|
|
244
|
-
await this.
|
|
245
|
-
const result = await this.executeStep(run, step, initialInputs, outputsById, path, signal);
|
|
468
|
+
const result = await this.executeStep(run, step, initialInputs, path, signal, iterations);
|
|
246
469
|
results.push(result);
|
|
247
|
-
outputsById.set(step.id, result.outputs);
|
|
248
470
|
if (result.state === 'failed' || result.state === 'cancelled') {
|
|
471
|
+
const interrupted = result.state === 'cancelled' || run.cancelled || run.timedOut || signal.aborted
|
|
472
|
+
|| result.terminalReason === 'timeout' || result.error?.category === 'ACTION_TIMEOUT'
|
|
473
|
+
|| result.error?.code === 'ACTION_TIMEOUT' || result.error?.code === 'HTTP_TIMEOUT';
|
|
474
|
+
// Only this explicitly configured body's ordinary failures are tolerated.
|
|
475
|
+
// Its child failures remain recorded; policies never leak into nested bodies.
|
|
476
|
+
if (bodyFailurePolicy === 'continue' && !interrupted)
|
|
477
|
+
continue;
|
|
249
478
|
state = result.state === 'cancelled' ? 'cancelled' : 'failed';
|
|
250
479
|
error = result.error;
|
|
251
|
-
if (step.control?.onError !== 'continue')
|
|
480
|
+
if (interrupted || step.control?.onError !== 'continue')
|
|
252
481
|
break;
|
|
253
482
|
}
|
|
254
483
|
}
|
|
@@ -258,56 +487,82 @@ export class DefaultExecutor {
|
|
|
258
487
|
steps: results,
|
|
259
488
|
};
|
|
260
489
|
}
|
|
261
|
-
async executeStep(run, step, scope,
|
|
490
|
+
async executeStep(run, step, scope, path, signal = run.abort.signal, iterations = []) {
|
|
262
491
|
const startedMs = Date.now();
|
|
263
492
|
const invocationPath = [...path, step.id];
|
|
264
|
-
const base = { id: step.id, name: step.name, kind: step.kind ?? 'action', inputs: {}, outputs: {}, invocationId: invocationPath.join('/'), invocationPath };
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
this.emit(run, 'step_started', 'running', `${step.name} started.`, step);
|
|
493
|
+
const base = { id: step.id, name: step.name, kind: step.kind ?? 'action', inputs: {}, outputs: {}, structuralAddress: structuredClone(step.structuralAddress), iterations: structuredClone(iterations), invocationId: invocationPath.join('/'), invocationPath };
|
|
494
|
+
let inputs = {};
|
|
495
|
+
const attempts = [];
|
|
496
|
+
this.emit(run, 'step_started', 'running', `${step.name} started.`, step, undefined, iterations);
|
|
269
497
|
try {
|
|
498
|
+
const debugSeed = run.debugSeed && addressKey(run.debugSeed.structuralAddress) === addressKey(step.structuralAddress) ? run.debugSeed : undefined;
|
|
499
|
+
const rootCapture = run.replay && addressKey(run.replay.root) === addressKey(step.structuralAddress) ? run.replay.capture : debugSeed?.kind === 'operation' ? debugSeed : undefined;
|
|
500
|
+
inputs = debugSeed ? structuredClone(debugSeed.inputs) : rootCapture ? structuredClone(rootCapture.inputs) : { ...structuredClone(step.staticInputs ?? {}), ...this.resolveBindings(run, step.bindings ?? {}, scope, step.structuralAddress) };
|
|
270
501
|
if (step.delayMs) {
|
|
271
|
-
this.emit(run, 'waiting', 'waiting', `${step.name} is waiting for ${step.delayMs} ms.`, step);
|
|
502
|
+
this.emit(run, 'waiting', 'waiting', `${step.name} is waiting for ${step.delayMs} ms.`, step, undefined, iterations);
|
|
272
503
|
await delay(step.delayMs, signal);
|
|
273
504
|
}
|
|
274
505
|
if (step.control) {
|
|
275
|
-
const controlResult = await this.executeControl(run, step, inputs, scope,
|
|
506
|
+
const controlResult = await this.executeControl(run, step, inputs, scope, path, signal, iterations);
|
|
276
507
|
return { ...base, ...controlResult, inputs, durationMs: Date.now() - startedMs };
|
|
277
508
|
}
|
|
278
509
|
if (step.kind === 'operation' && step.children) {
|
|
279
|
-
const
|
|
280
|
-
|
|
510
|
+
const arguments_ = rootCapture ? { inputs: structuredClone(rootCapture.inputs), issues: [] } : resolveDeclaredInputs(step.inputDeclarations, inputs);
|
|
511
|
+
if (arguments_.issues.length)
|
|
512
|
+
throw failure('BINDING_RESOLUTION_ERROR', arguments_.issues.map(issue => issue.message).join(' '));
|
|
513
|
+
const childScope = rootCapture ? structuredClone(rootCapture.lexicalScope) : { ...scope, ...arguments_.inputs };
|
|
514
|
+
for (const declaration of step.inputDeclarations ?? [])
|
|
515
|
+
if (!Object.hasOwn(arguments_.inputs, declaration.name))
|
|
516
|
+
delete childScope[declaration.name];
|
|
517
|
+
if (!run.replay)
|
|
518
|
+
this.capture(run, step, iterations, arguments_.inputs, {
|
|
519
|
+
kind: 'operation', lexicalScope: structuredClone(childScope),
|
|
520
|
+
externalEvidence: this.captureExternalEvidence(run, step, iterations),
|
|
521
|
+
});
|
|
522
|
+
const nested = await this.executeFlow(run, step.children, childScope, [...path, step.id], new Date().toISOString(), run.request.executionPackage.target, signal, iterations);
|
|
523
|
+
const outputs = nested.state === 'completed'
|
|
524
|
+
? { ...structuredClone(step.staticOutputs ?? {}), ...this.resolveBindings(run, step.outputBindings ?? {}, childScope, step.structuralAddress, { phase: 'completion', address: step.structuralAddress, iterations }) }
|
|
525
|
+
: {};
|
|
281
526
|
const result = { ...base, state: nested.state === 'completed' ? 'finished' : nested.state, inputs, outputs, durationMs: Date.now() - startedMs, children: nested.steps, terminalReason: nested.terminalReason ?? (nested.state === 'completed' ? 'completed' : 'failed'), error: nested.error };
|
|
282
|
-
this.emit(run, 'step_finished', result.state, `${step.name} ${result.state}.`, step);
|
|
527
|
+
this.emit(run, 'step_finished', result.state, `${step.name} ${result.state}.`, step, undefined, iterations);
|
|
283
528
|
return result;
|
|
284
529
|
}
|
|
285
|
-
|
|
530
|
+
if (!run.replay)
|
|
531
|
+
this.capture(run, step, iterations, inputs, { kind: 'action' });
|
|
532
|
+
const actionResult = await this.executeAction(run, step, inputs, attempts, signal, run.replay ? (type, attempt, state) => this.emitReplayOccurrence(run.replay.session, type, state, step, iterations, attempt) : undefined);
|
|
533
|
+
if (signal.aborted || run.cancelled)
|
|
534
|
+
throw failure(run.timedOut ? 'ACTION_TIMEOUT' : 'ACTION_CANCELLED', 'Execution ended before evidence publication.');
|
|
535
|
+
run.journal.set(invocationKey(step.structuralAddress, iterations), { outputs: structuredClone(actionResult.outputs), ...(actionResult.evidence ? { evidence: structuredClone(actionResult.evidence) } : {}) });
|
|
536
|
+
freezePlan(attempts);
|
|
286
537
|
const result = { ...base, state: 'finished', inputs, outputs: actionResult.outputs, durationMs: Date.now() - startedMs, terminalReason: 'completed', attempts: actionResult.attempts, evidence: actionResult.evidence };
|
|
287
|
-
this.emit(run, 'step_finished', 'finished', `${step.name} finished.`, step);
|
|
538
|
+
this.emit(run, 'step_finished', 'finished', `${step.name} finished.`, step, undefined, iterations);
|
|
288
539
|
if (run.stopAfter === step.id) {
|
|
289
540
|
run.stopAfter = undefined;
|
|
290
|
-
await this.pauseAtBoundary(run, step);
|
|
541
|
+
await this.pauseAtBoundary(run, step, iterations);
|
|
291
542
|
}
|
|
292
543
|
return result;
|
|
293
544
|
}
|
|
294
545
|
catch (cause) {
|
|
295
546
|
const failure = this.failure(cause, run, signal);
|
|
296
547
|
const state = run.timedOut ? 'failed' : run.cancelled || signal.aborted ? 'cancelled' : 'failed';
|
|
297
|
-
|
|
298
|
-
|
|
548
|
+
freezePlan(attempts);
|
|
549
|
+
const result = { ...base, state, inputs, durationMs: Date.now() - startedMs, ...(attempts.length ? { attempts } : {}), terminalReason: run.timedOut ? 'timeout' : state === 'cancelled' ? 'cancelled' : 'failed', error: failure };
|
|
550
|
+
this.emit(run, 'step_finished', state, `${step.name} ${state}.`, step, undefined, iterations);
|
|
299
551
|
return result;
|
|
300
552
|
}
|
|
301
553
|
}
|
|
302
|
-
async executeControl(run, step, inputs, scope,
|
|
554
|
+
async executeControl(run, step, inputs, scope, path, signal = run.abort.signal, iterations = []) {
|
|
303
555
|
const control = step.control;
|
|
304
|
-
if (control.condition !== undefined && !evaluateCondition(control.condition, { ...scope, ...inputs },
|
|
556
|
+
if (control.condition !== undefined && !this.evaluateCondition(run, control.condition, { ...scope, ...inputs }, step.structuralAddress))
|
|
305
557
|
return { state: 'skipped', outputs: {}, terminalReason: 'condition-skipped' };
|
|
306
558
|
if (control.type === 'repeat') {
|
|
307
|
-
const total = control.iterations ?? 1;
|
|
559
|
+
const total = Math.max(1, Math.min(control.iterations ?? (control.durationMs !== undefined ? 32 : 1), 32));
|
|
560
|
+
const loopStarted = Date.now();
|
|
308
561
|
const children = [];
|
|
309
562
|
for (let current = 1; current <= total; current++) {
|
|
310
|
-
|
|
563
|
+
if (control.durationMs !== undefined && Date.now() - loopStarted >= control.durationMs)
|
|
564
|
+
break;
|
|
565
|
+
const child = await this.executeFlow(run, step.children ?? [], { ...scope, ...inputs }, [...path, step.id, String(current)], new Date().toISOString(), run.request.executionPackage.target, signal, [...iterations, { loopAddress: step.structuralAddress, iteration: current }]);
|
|
311
566
|
children.push(...child.steps.map(item => ({ ...item, iteration: { loopId: step.id, current, total } })));
|
|
312
567
|
if (child.state !== 'completed' && control.onError !== 'continue')
|
|
313
568
|
return { state: child.state, outputs: child.steps.at(-1)?.outputs ?? {}, children, terminalReason: child.terminalReason, error: child.error };
|
|
@@ -317,88 +572,197 @@ export class DefaultExecutor {
|
|
|
317
572
|
return { state: 'finished', outputs: children.at(-1)?.outputs ?? {}, children, terminalReason: 'completed' };
|
|
318
573
|
}
|
|
319
574
|
if (control.type === 'branch') {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
575
|
+
const children = [];
|
|
576
|
+
for (const branch of control.branches ?? []) {
|
|
577
|
+
if (!this.evaluateCondition(run, branch.condition, { ...scope, ...inputs }, step.structuralAddress))
|
|
578
|
+
continue;
|
|
579
|
+
const nested = await this.executeFlow(run, branch.children, { ...scope, ...inputs }, [...path, step.id, branch.id], new Date().toISOString(), run.request.executionPackage.target, signal, iterations);
|
|
580
|
+
children.push(...nested.steps);
|
|
581
|
+
if (nested.state !== 'completed' && control.onError !== 'continue')
|
|
582
|
+
return { state: nested.state, outputs: nested.steps.at(-1)?.outputs ?? {}, children, terminalReason: nested.terminalReason, error: nested.error };
|
|
583
|
+
}
|
|
584
|
+
return { state: children.length ? 'finished' : 'skipped', outputs: children.at(-1)?.outputs ?? {}, children, terminalReason: children.length ? 'completed' : 'branch-skipped' };
|
|
325
585
|
}
|
|
326
586
|
if (control.type === 'parallel') {
|
|
327
587
|
const branches = control.branches ?? [];
|
|
328
|
-
const selected = branches.filter(branch => evaluateCondition(branch.condition, { ...scope, ...inputs },
|
|
588
|
+
const selected = branches.filter(branch => this.evaluateCondition(run, branch.condition, { ...scope, ...inputs }, step.structuralAddress));
|
|
329
589
|
const controllers = selected.map(() => new AbortController());
|
|
330
|
-
|
|
590
|
+
const abortBranches = () => controllers.forEach(controller => controller.abort());
|
|
591
|
+
signal.addEventListener('abort', abortBranches, { once: true });
|
|
592
|
+
if (signal.aborted)
|
|
593
|
+
abortBranches();
|
|
331
594
|
const branchRuns = await Promise.all(selected.map(async (branch, index) => {
|
|
332
595
|
try {
|
|
333
|
-
const result = await this.executeFlow(run, branch.children, { ...scope, ...inputs }, [...path, step.id, branch.id], new Date().toISOString(), run.request.executionPackage.target, controllers[index].signal);
|
|
334
|
-
if (control.join === 'first' && result.state === 'completed' && winnerIndex === undefined) {
|
|
335
|
-
winnerIndex = index;
|
|
336
|
-
controllers.forEach((controller, candidate) => { if (candidate !== index)
|
|
337
|
-
controller.abort(); });
|
|
338
|
-
}
|
|
596
|
+
const result = await this.executeFlow(run, branch.children, { ...scope, ...inputs }, [...path, step.id, branch.id], new Date().toISOString(), run.request.executionPackage.target, controllers[index].signal, iterations);
|
|
339
597
|
return { branch, result };
|
|
340
598
|
}
|
|
341
599
|
catch (cause) {
|
|
342
600
|
return { branch, result: this.baseResult(run, new Date().toISOString(), controllers[index].signal.aborted ? 'cancelled' : 'failed', this.failure(cause, run, controllers[index].signal)) };
|
|
343
601
|
}
|
|
344
602
|
}));
|
|
603
|
+
signal.removeEventListener('abort', abortBranches);
|
|
345
604
|
const nested = branchRuns.map(item => item.result);
|
|
346
605
|
const children = nested.flatMap(item => item.steps);
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return { state: failed ? invocationState(failed.result.state) : 'finished', outputs: mergedOutputs, children, terminalReason: failed?.result.terminalReason ?? 'parallel-joined', error: failed?.result.error };
|
|
606
|
+
const failed = branchRuns.find(item => item.result.state !== 'completed');
|
|
607
|
+
if (control.mergeBindings)
|
|
608
|
+
throw failure('unsupported-context', 'Parallel output projections are outside this foundation slice.');
|
|
609
|
+
return { state: failed ? invocationState(failed.result.state) : 'finished', outputs: {}, children, terminalReason: failed?.result.terminalReason ?? 'parallel-joined', error: failed?.result.error };
|
|
352
610
|
}
|
|
353
|
-
const nested = await this.executeFlow(run, step.children ?? [], { ...scope, ...inputs }, [...path, step.id], new Date().toISOString(), run.request.executionPackage.target, signal);
|
|
611
|
+
const nested = await this.executeFlow(run, step.children ?? [], { ...scope, ...inputs }, [...path, step.id], new Date().toISOString(), run.request.executionPackage.target, signal, iterations, control.type === 'sequence' ? control.onError ?? 'stop' : 'stop');
|
|
354
612
|
return { state: nested.state === 'completed' ? 'finished' : nested.state, outputs: nested.steps.at(-1)?.outputs ?? {}, children: nested.steps, terminalReason: nested.terminalReason, error: nested.error };
|
|
355
613
|
}
|
|
356
|
-
|
|
614
|
+
resolveBindings(run, bindings, scope, consumer, context = { phase: 'entry', address: consumer }) {
|
|
615
|
+
return Object.fromEntries(Object.entries(bindings).map(([name, operand]) => [name, this.resolveOperand(run, operand, scope, consumer, context)]));
|
|
616
|
+
}
|
|
617
|
+
resolveOperand(run, operand, scope, consumer, context = { phase: 'entry', address: consumer }) {
|
|
618
|
+
const issues = executionOperandDependencyIssues(run.request.executionPackage, operand, context);
|
|
619
|
+
if (issues.length)
|
|
620
|
+
throw { code: issues[0].code, message: issues[0].reason, details: { consumer, operand } };
|
|
621
|
+
if (operand.type === 'input') {
|
|
622
|
+
let value = scope;
|
|
623
|
+
for (const part of operand.path) {
|
|
624
|
+
if (value === null || typeof value !== 'object' || !Object.hasOwn(value, part))
|
|
625
|
+
throw failure('BINDING_RESOLUTION_ERROR', `Required lexical input ${JSON.stringify(operand.path)} is absent.`);
|
|
626
|
+
value = value[part];
|
|
627
|
+
}
|
|
628
|
+
if (value === undefined)
|
|
629
|
+
throw failure('BINDING_RESOLUTION_ERROR', 'Required lexical input value is undefined.');
|
|
630
|
+
return structuredClone(value);
|
|
631
|
+
}
|
|
632
|
+
if (operand.type === 'unresolved-output')
|
|
633
|
+
throw failure(operand.code, operand.reason);
|
|
634
|
+
const source = run.plan.get(addressKey(operand.address));
|
|
635
|
+
const selectors = source.loops.map(loop => ({ loopAddress: loop, iteration: operand.iterations.find(i => addressKey(i.loopAddress) === addressKey(loop)).iteration }));
|
|
636
|
+
const key = invocationKey(source.address, selectors);
|
|
637
|
+
const invocation = run.replay && !ownsAddress(run.replay.root, source.address)
|
|
638
|
+
? run.replay.capture.externalEvidence.get(key) : run.journal.get(key);
|
|
639
|
+
if (!invocation || !Object.hasOwn(invocation.outputs, operand.outputName) || invocation.outputs[operand.outputName] === undefined)
|
|
640
|
+
throw { code: 'output-evidence-unavailable', message: run.replay ? 'Required successful Act output is unavailable in this replay evidence view.' : 'Required successful Act output is unavailable in this Run.', details: { missing: invocation ? 'output' : 'invocation', source: operand.address, iterations: selectors, outputName: operand.outputName, consumer } };
|
|
641
|
+
return structuredClone(invocation.outputs[operand.outputName]);
|
|
642
|
+
}
|
|
643
|
+
evaluateCondition(run, condition, scope, consumer) {
|
|
644
|
+
if (typeof condition === 'boolean')
|
|
645
|
+
return condition;
|
|
646
|
+
const value = this.resolveOperand(run, condition.binding, scope, consumer);
|
|
647
|
+
if (condition.exists !== undefined)
|
|
648
|
+
return condition.exists === (value !== undefined);
|
|
649
|
+
if ('equals' in condition)
|
|
650
|
+
return value === condition.equals;
|
|
651
|
+
if ('notEquals' in condition)
|
|
652
|
+
return value !== condition.notEquals;
|
|
653
|
+
return Boolean(value);
|
|
654
|
+
}
|
|
655
|
+
capture(run, step, iterations, inputs, extra) {
|
|
656
|
+
const capture = { structuralAddress: structuredClone(step.structuralAddress), iterations: structuredClone(iterations), inputs: structuredClone(inputs), ...extra };
|
|
657
|
+
freezePlan(capture);
|
|
658
|
+
run.replayCaptures.set(invocationKey(step.structuralAddress, iterations), capture);
|
|
659
|
+
}
|
|
660
|
+
captureExternalEvidence(run, operation, iterations) {
|
|
661
|
+
const evidence = new Map();
|
|
662
|
+
const owns = (address) => address.scope === operation.structuralAddress.scope
|
|
663
|
+
&& operation.structuralAddress.segments.every((segment, index) => JSON.stringify(segment) === JSON.stringify(address.segments[index]));
|
|
664
|
+
const captureOperand = (operand) => {
|
|
665
|
+
if (operand.type !== 'hierarchy-output' || owns(operand.address))
|
|
666
|
+
return;
|
|
667
|
+
// An exact external reference must already be causal at this boundary. No journal scan,
|
|
668
|
+
// legacy ID lookup, or inference of selectors from the current invocation is permitted.
|
|
669
|
+
if (executionOperandDependencyIssues(run.request.executionPackage, operand, { phase: 'completion', address: operation.structuralAddress, iterations }).length)
|
|
670
|
+
return;
|
|
671
|
+
const source = run.plan.get(addressKey(operand.address));
|
|
672
|
+
const selectors = source.loops.map(loop => ({ loopAddress: loop, iteration: operand.iterations.find(i => addressKey(i.loopAddress) === addressKey(loop)).iteration }));
|
|
673
|
+
const key = invocationKey(source.address, selectors);
|
|
674
|
+
const successful = run.journal.get(key);
|
|
675
|
+
if (!successful || !Object.hasOwn(successful.outputs, operand.outputName) || successful.outputs[operand.outputName] === undefined)
|
|
676
|
+
return;
|
|
677
|
+
const snapshot = structuredClone(successful);
|
|
678
|
+
freezePlan(snapshot);
|
|
679
|
+
evidence.set(key, snapshot);
|
|
680
|
+
};
|
|
681
|
+
const condition = (value) => { if (value && typeof value !== 'boolean')
|
|
682
|
+
captureOperand(value.binding); };
|
|
683
|
+
// Shared traversal has already assigned each occurrence exactly one structural identity,
|
|
684
|
+
// including branch children that also appear in a container's presentation children.
|
|
685
|
+
for (const occurrence of run.plan.values()) {
|
|
686
|
+
if (!owns(occurrence.address))
|
|
687
|
+
continue;
|
|
688
|
+
const step = occurrence.step;
|
|
689
|
+
Object.values(step.bindings ?? {}).forEach(captureOperand);
|
|
690
|
+
Object.values(step.outputBindings ?? {}).forEach(captureOperand);
|
|
691
|
+
condition(step.control?.condition);
|
|
692
|
+
for (const branch of step.control?.branches ?? [])
|
|
693
|
+
condition(branch.condition);
|
|
694
|
+
}
|
|
695
|
+
return evidence;
|
|
696
|
+
}
|
|
697
|
+
assertActive(run, signal) {
|
|
698
|
+
if (run.timedOut)
|
|
699
|
+
throw failure('ACTION_TIMEOUT', 'Execution exceeded its timeout.');
|
|
700
|
+
if (run.cancelled || signal.aborted)
|
|
701
|
+
throw failure('ACTION_CANCELLED', 'Execution was cancelled.');
|
|
702
|
+
}
|
|
703
|
+
async executeAction(run, step, inputs, attempts, signal = run.abort.signal, onAttempt) {
|
|
357
704
|
if (run.request.options?.dryRun)
|
|
358
|
-
return { outputs: {}, attempts
|
|
705
|
+
return { outputs: {}, attempts };
|
|
359
706
|
const executor = this.actionExecutors.find(candidate => candidate.supports(step.action));
|
|
360
707
|
if (!executor)
|
|
361
708
|
throw failure('UNSUPPORTED_ACTION', `No Action Executor is registered for ${step.action}.`);
|
|
709
|
+
const originalInputs = structuredClone(inputs);
|
|
710
|
+
freezePlan(originalInputs);
|
|
362
711
|
const maxAttempts = Math.max(1, step.retry?.maxAttempts ?? 1);
|
|
363
|
-
const attempts = [];
|
|
364
712
|
let lastFailure;
|
|
365
713
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
714
|
+
const context = { action: step.action, inputs: structuredClone(originalInputs), environment: run.request.environment, runtimeValues: run.request.runtimeValues ?? {}, step, signal };
|
|
715
|
+
this.assertActive(run, signal);
|
|
366
716
|
const startedAt = new Date().toISOString();
|
|
367
717
|
const startedMs = Date.now();
|
|
718
|
+
onAttempt?.('attempt_started', attempt, 'running');
|
|
368
719
|
try {
|
|
369
|
-
const context = { action: step.action, inputs, environment: run.request.environment, runtimeValues: run.request.runtimeValues ?? {}, step, signal };
|
|
370
720
|
const raw = await executor.execute(context);
|
|
721
|
+
this.assertActive(run, signal);
|
|
371
722
|
const normalized = isActionResult(raw) ? raw : { outputs: raw };
|
|
372
|
-
const
|
|
373
|
-
|
|
723
|
+
const history = { attempt, kind: 'automatic', state: 'finished', startedAt, finishedAt: new Date().toISOString(), inputs: structuredClone(originalInputs), outputs: structuredClone(normalized.outputs), durationMs: Date.now() - startedMs, evidence: structuredClone(normalized.evidence) };
|
|
724
|
+
freezePlan(history);
|
|
725
|
+
attempts.push(history);
|
|
726
|
+
onAttempt?.('attempt_finished', attempt, history.state);
|
|
374
727
|
return { outputs: normalized.outputs, evidence: normalized.evidence, attempts };
|
|
375
728
|
}
|
|
376
729
|
catch (cause) {
|
|
377
730
|
lastFailure = this.failure(cause, run, signal);
|
|
378
|
-
|
|
379
|
-
|
|
731
|
+
const history = { attempt, kind: 'automatic', state: run.timedOut ? 'failed' : run.cancelled || signal.aborted ? 'cancelled' : 'failed', startedAt, finishedAt: new Date().toISOString(), inputs: structuredClone(originalInputs), outputs: {}, durationMs: Date.now() - startedMs, error: structuredClone(lastFailure) };
|
|
732
|
+
freezePlan(history);
|
|
733
|
+
attempts.push(history);
|
|
734
|
+
onAttempt?.('attempt_finished', attempt, history.state);
|
|
735
|
+
this.assertActive(run, signal);
|
|
736
|
+
if (attempt < maxAttempts && step.retry?.delayMs) {
|
|
737
|
+
this.assertActive(run, signal);
|
|
380
738
|
await delay(step.retry.delayMs, signal);
|
|
739
|
+
this.assertActive(run, signal);
|
|
740
|
+
}
|
|
381
741
|
}
|
|
382
742
|
}
|
|
383
743
|
throw lastFailure ?? failure('ACTION_FAILED', `Action ${step.action} failed.`);
|
|
384
744
|
}
|
|
385
|
-
async boundary(run, step, path, signal = run.abort.signal) {
|
|
745
|
+
async boundary(run, step, path, signal = run.abort.signal, iterations = []) {
|
|
746
|
+
if (run.replay) {
|
|
747
|
+
this.assertActive(run, signal);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
386
750
|
const breakpointKey = stepPathForBoundary(step, run, path);
|
|
387
751
|
const breakpoints = run.request.debugSession?.breakpoints ?? [];
|
|
388
752
|
if (breakpoints.includes(step.id) && !run.hitBreakpoints.has(breakpointKey)) {
|
|
389
753
|
run.hitBreakpoints.add(breakpointKey);
|
|
390
754
|
run.pauseRequested = true;
|
|
391
|
-
this.emit(run, 'waiting', 'waiting', `Breakpoint reached before ${step.name}.`, step, { type: 'pause', stepId: step.id });
|
|
755
|
+
this.emit(run, 'waiting', 'waiting', `Breakpoint reached before ${step.name}.`, step, { type: 'pause', stepId: step.id }, iterations);
|
|
392
756
|
}
|
|
393
757
|
if (run.pauseRequested || run.controlState === 'pause-requested')
|
|
394
|
-
await this.pauseAtBoundary(run, step);
|
|
758
|
+
await this.pauseAtBoundary(run, step, iterations);
|
|
395
759
|
if (run.cancelled || signal.aborted)
|
|
396
760
|
throw new Error('Execution cancelled.');
|
|
397
761
|
}
|
|
398
|
-
async pauseAtBoundary(run, step) {
|
|
762
|
+
async pauseAtBoundary(run, step, iterations) {
|
|
399
763
|
run.controlState = 'paused';
|
|
400
764
|
run.pausedStartedMs = Date.now();
|
|
401
|
-
this.emit(run, 'waiting', 'waiting', `Paused before ${step.name}.`, step, { type: 'pause', stepId: step.id });
|
|
765
|
+
this.emit(run, 'waiting', 'waiting', `Paused before ${step.name}.`, step, { type: 'pause', stepId: step.id }, iterations);
|
|
402
766
|
await new Promise(resolve => run.controlWaiters.push(resolve));
|
|
403
767
|
run.pausedDurationMs += Date.now() - (run.pausedStartedMs ?? Date.now());
|
|
404
768
|
run.pausedStartedMs = undefined;
|
|
@@ -408,10 +772,25 @@ export class DefaultExecutor {
|
|
|
408
772
|
run.pauseRequested = true;
|
|
409
773
|
}
|
|
410
774
|
}
|
|
411
|
-
emit(run, type, state, message, step, control) {
|
|
412
|
-
|
|
775
|
+
emit(run, type, state, message, step, control, iterations = []) {
|
|
776
|
+
if (run.replay && step) {
|
|
777
|
+
this.emitReplayOccurrence(run.replay.session, type, state, step, iterations);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
run.events.push({ requestId: run.request.requestId, sequence: run.events.length + 1, type, state, message, timestamp: new Date().toISOString(), ...(step ? { stepId: step.id, stepName: step.name, structuralAddress: structuredClone(step.structuralAddress), iterations: structuredClone(iterations), kind: step.kind ?? 'action' } : {}), ...(control ? { control } : {}) });
|
|
413
781
|
run.listeners.splice(0).forEach(resolve => resolve());
|
|
414
782
|
}
|
|
783
|
+
emitReplayOccurrence(session, type, state, step, iterations, attempt) {
|
|
784
|
+
const event = {
|
|
785
|
+
parentRequestId: session.parentRequestId, sessionId: session.sessionId,
|
|
786
|
+
sequence: session.events.length + 1, timestamp: new Date().toISOString(),
|
|
787
|
+
structuralAddress: structuredClone(step.structuralAddress), iterations: structuredClone(iterations),
|
|
788
|
+
kind: step.kind ?? 'action', type, state, ...(attempt === undefined ? {} : { attempt }),
|
|
789
|
+
};
|
|
790
|
+
freezePlan(event);
|
|
791
|
+
session.events.push(event);
|
|
792
|
+
session.listeners.splice(0).forEach(resolve => resolve());
|
|
793
|
+
}
|
|
415
794
|
finish(run, result) {
|
|
416
795
|
const finishedAt = new Date().toISOString();
|
|
417
796
|
const finalResult = { ...result, finishedAt, executionTiming: { wallDurationMs: Date.now() - run.startedMs, activeDurationMs: Date.now() - run.startedMs - run.pausedDurationMs, pausedDurationMs: run.pausedDurationMs } };
|
|
@@ -422,15 +801,15 @@ export class DefaultExecutor {
|
|
|
422
801
|
run.listeners.splice(0).forEach(resolve => resolve());
|
|
423
802
|
}
|
|
424
803
|
baseResult(run, startedAt, state, error, issues, children) {
|
|
425
|
-
return { requestId: run.request.requestId, state, target: run.request.executionPackage.target, environment: run.request.environment, startedAt, finishedAt: new Date().toISOString(), steps: [], ...(children ? { children } : {}), ...(error ? { error } : {}), ...(issues ? { issues } : {}) };
|
|
804
|
+
return { ...(run.debugProvenance ? { debugProvenance: run.debugProvenance } : {}), requestId: run.request.requestId, state, target: run.request.executionPackage.target, environment: run.request.environment, startedAt, finishedAt: new Date().toISOString(), steps: [], ...(children ? { children } : {}), ...(error ? { error } : {}), ...(issues ? { issues } : {}) };
|
|
426
805
|
}
|
|
427
806
|
failure(cause, run, signal = run.abort.signal) {
|
|
428
|
-
if (isFailure(cause))
|
|
429
|
-
return cause;
|
|
430
807
|
if (run.timedOut)
|
|
431
808
|
return failure('ACTION_TIMEOUT', 'Execution exceeded its timeout.');
|
|
432
809
|
if (run.cancelled || signal.aborted)
|
|
433
810
|
return failure('ACTION_CANCELLED', 'Execution was cancelled.');
|
|
811
|
+
if (isFailure(cause))
|
|
812
|
+
return cause;
|
|
434
813
|
return failure('ACTION_FAILED', cause instanceof Error ? cause.message : 'Action execution failed.');
|
|
435
814
|
}
|
|
436
815
|
}
|
|
@@ -450,104 +829,9 @@ function preflight(pkg, inputs, runtimeValues) {
|
|
|
450
829
|
issues.push({ code: 'MISSING_RUNTIME_VALUE', message: `Runtime value '${name}' is missing.` });
|
|
451
830
|
return issues;
|
|
452
831
|
}
|
|
453
|
-
function orderSteps(steps) {
|
|
454
|
-
const byId = new Map();
|
|
455
|
-
for (const step of steps) {
|
|
456
|
-
if (byId.has(step.id))
|
|
457
|
-
throw failure('PREFLIGHT_ERROR', `Graph contains duplicate step id ${step.id}.`);
|
|
458
|
-
byId.set(step.id, step);
|
|
459
|
-
}
|
|
460
|
-
const remaining = [...steps];
|
|
461
|
-
const ordered = [];
|
|
462
|
-
while (remaining.length) {
|
|
463
|
-
const index = remaining.findIndex(step => (step.dependsOn ?? []).every(dependency => byId.has(dependency) && ordered.some(done => done.id === dependency)));
|
|
464
|
-
if (index < 0) {
|
|
465
|
-
const missing = remaining.flatMap(step => step.dependsOn ?? []).find(dependency => !byId.has(dependency));
|
|
466
|
-
throw failure('PREFLIGHT_ERROR', missing ? `Graph dependency ${missing} is missing.` : 'Graph contains a dependency cycle.');
|
|
467
|
-
}
|
|
468
|
-
ordered.push(remaining.splice(index, 1)[0]);
|
|
469
|
-
}
|
|
470
|
-
return ordered;
|
|
471
|
-
}
|
|
472
|
-
function findStep(results, stepId, invocationPath) {
|
|
473
|
-
for (const result of results) {
|
|
474
|
-
if (result.id === stepId && (!invocationPath || result.invocationPath?.join('.') === invocationPath.join('.')))
|
|
475
|
-
return result;
|
|
476
|
-
const nested = result.children && findStep(result.children, stepId, invocationPath);
|
|
477
|
-
if (nested)
|
|
478
|
-
return nested;
|
|
479
|
-
}
|
|
480
|
-
return undefined;
|
|
481
|
-
}
|
|
482
|
-
function findDefinition(steps, stepId, invocationPath) {
|
|
483
|
-
for (const step of steps) {
|
|
484
|
-
if (step.id === stepId && (!invocationPath || invocationPath.at(-1) === stepId))
|
|
485
|
-
return step;
|
|
486
|
-
const nested = step.children && findDefinition(step.children, stepId, invocationPath);
|
|
487
|
-
if (nested)
|
|
488
|
-
return nested;
|
|
489
|
-
for (const branch of step.control?.branches ?? []) {
|
|
490
|
-
const branchNested = findDefinition(branch.children, stepId, invocationPath);
|
|
491
|
-
if (branchNested)
|
|
492
|
-
return branchNested;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
return undefined;
|
|
496
|
-
}
|
|
497
832
|
function invocationState(state) {
|
|
498
833
|
return state === 'cancelled' ? 'cancelled' : state === 'failed' ? 'failed' : 'skipped';
|
|
499
834
|
}
|
|
500
|
-
function mergeBranchOutputs(branches, bindings, policy) {
|
|
501
|
-
if (bindings)
|
|
502
|
-
return Object.fromEntries(Object.entries(bindings).flatMap(([name, source]) => {
|
|
503
|
-
const match = /^([^.]+)\.(.+)$/.exec(source);
|
|
504
|
-
const branch = match ? branches.find(item => item.id === match[1]) : undefined;
|
|
505
|
-
return branch && match && Object.prototype.hasOwnProperty.call(branch.outputs, match[2]) ? [[name, branch.outputs[match[2]]]] : [];
|
|
506
|
-
}));
|
|
507
|
-
if (policy === 'first')
|
|
508
|
-
return { ...(branches[0]?.outputs ?? {}) };
|
|
509
|
-
if (policy === 'last')
|
|
510
|
-
return { ...(branches.at(-1)?.outputs ?? {}) };
|
|
511
|
-
return Object.fromEntries(branches.flatMap(branch => Object.entries(branch.outputs).map(([name, value]) => [`${branch.id}.${name}`, value])));
|
|
512
|
-
}
|
|
513
|
-
function resolveBindings(bindings, scope, outputs) {
|
|
514
|
-
return Object.fromEntries(Object.entries(bindings).map(([name, reference]) => [name, resolveReference(reference, scope, outputs)]));
|
|
515
|
-
}
|
|
516
|
-
function resolveReference(reference, scope, outputs) {
|
|
517
|
-
const input = /^\$inputs\.(.+)$/.exec(reference);
|
|
518
|
-
if (input)
|
|
519
|
-
return readPath(scope, input[1]);
|
|
520
|
-
const step = /^\$steps\.([^.]+)\.(.+)$/.exec(reference);
|
|
521
|
-
if (step)
|
|
522
|
-
return readPath(outputs.get(step[1]) ?? {}, step[2]);
|
|
523
|
-
if (reference.startsWith('$'))
|
|
524
|
-
throw failure('BINDING_RESOLUTION_ERROR', `Binding '${reference}' could not be resolved.`);
|
|
525
|
-
return reference;
|
|
526
|
-
}
|
|
527
|
-
function readPath(value, path) {
|
|
528
|
-
const result = path.split('.').reduce((current, segment) => isRecord(current) ? current[segment] : undefined, value);
|
|
529
|
-
if (result === undefined)
|
|
530
|
-
throw failure('BINDING_RESOLUTION_ERROR', `Binding path '${path}' could not be resolved.`);
|
|
531
|
-
return result;
|
|
532
|
-
}
|
|
533
|
-
function evaluateCondition(condition, scope, outputs) {
|
|
534
|
-
if (typeof condition === 'boolean')
|
|
535
|
-
return condition;
|
|
536
|
-
let value;
|
|
537
|
-
try {
|
|
538
|
-
value = resolveReference(condition.binding, scope, outputs);
|
|
539
|
-
}
|
|
540
|
-
catch {
|
|
541
|
-
value = undefined;
|
|
542
|
-
}
|
|
543
|
-
if (condition.exists !== undefined)
|
|
544
|
-
return condition.exists === (value !== undefined);
|
|
545
|
-
if ('equals' in condition)
|
|
546
|
-
return value === condition.equals;
|
|
547
|
-
if ('notEquals' in condition)
|
|
548
|
-
return value !== condition.notEquals;
|
|
549
|
-
return Boolean(value);
|
|
550
|
-
}
|
|
551
835
|
function delay(durationMs, signal) {
|
|
552
836
|
return new Promise((resolve, reject) => {
|
|
553
837
|
if (signal.aborted) {
|
|
@@ -562,3 +846,7 @@ function failure(code, message) { return { code, message, category: code }; }
|
|
|
562
846
|
function isFailure(value) { return isRecord(value) && typeof value.code === 'string' && typeof value.message === 'string'; }
|
|
563
847
|
function isActionResult(value) { return isRecord(value) && isRecord(value.outputs); }
|
|
564
848
|
function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
849
|
+
function ownsAddress(root, address) {
|
|
850
|
+
return root.segments.length <= address.segments.length
|
|
851
|
+
&& addressKey(root) === addressKey({ scope: address.scope, segments: address.segments.slice(0, root.segments.length) });
|
|
852
|
+
}
|