@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.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
- async retryAction(requestId, stepId, invocationPath) {
96
- const run = this.runs.get(requestId);
97
- if (!run?.result || run.cancelled || run.abort.signal.aborted)
98
- return;
99
- const located = findStep(run.result.steps, stepId, invocationPath);
100
- const definition = findDefinition(run.request.executionPackage.steps, stepId, invocationPath);
101
- if (!located || !definition || definition.kind === 'operation' || definition.control || located.state === 'skipped')
102
- return;
103
- this.emit(run, 'waiting', 'waiting', `Interactive retry requested for ${located.name}.`, definition, { type: 'step', stepId });
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
- const action = await this.executeAction(run, definition, located.inputs);
106
- const previous = located.attempts ?? [];
107
- located.attempts = [...previous, ...action.attempts.map(attempt => ({ ...attempt, kind: 'interactive' }))];
108
- located.outputs = action.outputs;
109
- located.evidence = action.evidence;
110
- located.state = 'finished';
111
- located.terminalReason = 'completed';
112
- delete located.error;
113
- if (run.result.state === 'failed') {
114
- run.result.state = 'completed';
115
- run.result.terminalReason = 'completed';
116
- delete run.result.error;
117
- }
118
- this.emit(run, 'step_finished', 'finished', `${located.name} interactive retry completed.`, definition);
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
- located.state = 'failed';
122
- located.terminalReason = 'failed';
123
- located.error = this.failure(cause, run);
124
- this.emit(run, 'step_finished', 'failed', `${located.name} interactive retry failed.`, definition);
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, stepId, invocationPath) {
128
- const run = this.runs.get(requestId);
129
- if (!run?.result || run.cancelled || run.abort.signal.aborted)
130
- return;
131
- const located = findStep(run.result.steps, stepId, invocationPath);
132
- const definition = findDefinition(run.request.executionPackage.steps, stepId, invocationPath);
133
- if (!located || !definition?.children?.length || definition.control || definition.kind !== 'operation')
134
- return;
135
- const nested = await this.executeFlow(run, definition.children, located.inputs, [...(located.invocationPath ?? []).slice(0, -1), definition.id], new Date().toISOString());
136
- const previous = located.attempts ?? [];
137
- located.attempts = [...previous, { attempt: previous.length + 1, kind: 'interactive', state: nested.state === 'completed' ? 'finished' : 'failed', startedAt: nested.startedAt, finishedAt: nested.finishedAt, inputs: located.inputs, outputs: nested.steps.at(-1)?.outputs ?? {}, durationMs: nested.steps.reduce((sum, child) => sum + child.durationMs, 0), children: nested.steps, ...(nested.error ? { error: nested.error } : {}) }];
138
- located.children = nested.steps;
139
- located.outputs = nested.steps.at(-1)?.outputs ?? {};
140
- located.state = nested.state === 'completed' ? 'finished' : nested.state === 'cancelled' ? 'cancelled' : 'failed';
141
- located.terminalReason = nested.terminalReason ?? (located.state === 'finished' ? 'completed' : 'failed');
142
- located.error = nested.error;
143
- if (located.state === 'finished' && run.result.state === 'failed') {
144
- run.result.state = 'completed';
145
- run.result.terminalReason = 'completed';
146
- delete run.result.error;
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 run(run) {
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(() => { run.timedOut = true; run.abort.abort(); }, timeoutMs);
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 ? 'failed' : 'cancelled', error));
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
- const last = child.steps.at(-1)?.outputs ?? {};
226
- const mapped = flow.outputBindings
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 orderSteps(steps)) {
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.boundary(run, step, [...path, step.id], signal);
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, outputsById, path, signal = run.abort.signal) {
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
- if (step.dependsOn?.some(id => !outputsById.has(id)))
266
- return { ...base, state: 'skipped', durationMs: 0, terminalReason: 'dependency-skipped' };
267
- const inputs = { ...(step.staticInputs ?? {}), ...(step.bindings ? resolveBindings(step.bindings, scope, outputsById) : {}) };
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, outputsById, path, signal);
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 nested = await this.executeFlow(run, step.children, { ...scope, ...inputs }, [...path, step.id], new Date().toISOString(), run.request.executionPackage.target, signal);
280
- const outputs = nested.steps.at(-1)?.outputs ?? {};
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
- const actionResult = await this.executeAction(run, step, inputs, signal);
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
- const result = { ...base, state, inputs, durationMs: Date.now() - startedMs, terminalReason: run.timedOut ? 'timeout' : state === 'cancelled' ? 'cancelled' : 'failed', error: failure };
298
- this.emit(run, 'step_finished', state, `${step.name} ${state}.`, step);
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, outputs, path, signal = run.abort.signal) {
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 }, outputs))
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
- const child = await this.executeFlow(run, step.children ?? [], { ...scope, ...inputs }, [...path, step.id, String(current)], new Date().toISOString(), run.request.executionPackage.target, signal);
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 branch = control.branches?.find(candidate => evaluateCondition(candidate.condition, { ...scope, ...inputs }, outputs));
321
- if (!branch)
322
- return { state: 'skipped', outputs: {}, terminalReason: 'branch-skipped' };
323
- const nested = await this.executeFlow(run, branch.children, { ...scope, ...inputs }, [...path, step.id, branch.id], new Date().toISOString(), run.request.executionPackage.target, signal);
324
- return { state: nested.state === 'completed' ? 'finished' : nested.state, outputs: nested.steps.at(-1)?.outputs ?? {}, children: nested.steps, terminalReason: nested.terminalReason, error: nested.error };
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 }, outputs));
588
+ const selected = branches.filter(branch => this.evaluateCondition(run, branch.condition, { ...scope, ...inputs }, step.structuralAddress));
329
589
  const controllers = selected.map(() => new AbortController());
330
- let winnerIndex;
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 considered = control.join === 'first' && winnerIndex !== undefined ? [branchRuns[winnerIndex]] : branchRuns;
348
- const failed = considered.find(item => item.result.state !== 'completed');
349
- const branchOutputs = considered.map(({ branch, result }) => ({ id: branch.id, outputs: result.steps.at(-1)?.outputs ?? {} }));
350
- const mergedOutputs = mergeBranchOutputs(branchOutputs, control.mergeBindings, control.merge ?? (control.join === 'first' ? 'first' : 'collect'));
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
- async executeAction(run, step, inputs, signal = run.abort.signal) {
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 finishedAt = new Date().toISOString();
373
- attempts.push({ attempt, state: 'finished', startedAt, finishedAt, inputs, outputs: normalized.outputs, durationMs: Date.now() - startedMs, evidence: normalized.evidence });
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
- attempts.push({ attempt, state: run.timedOut ? 'failed' : run.cancelled || signal.aborted ? 'cancelled' : 'failed', startedAt, finishedAt: new Date().toISOString(), inputs, outputs: {}, durationMs: Date.now() - startedMs, error: lastFailure });
379
- if (attempt < maxAttempts && step.retry?.delayMs)
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
- 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 } : {}), ...(control ? { control } : {}) });
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
+ }