@qaick/executor 0.1.0 → 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.
@@ -1,4 +1,4 @@
1
- import type { ActionExecutor, ControlCommand, ExecutionControlState, Executor, ExecutorConfiguration, ExecutorEvent, ExecutorRequest, ExecutorResult, ExecutorRun } from './types.js';
1
+ import type { ActReplayTarget, ActReplayHandle, OperationReplayHandle, ActionExecutor, ControlCommand, ExecutionControlState, Executor, ExecutorConfiguration, ExecutorEvent, ExecutorRequest, ExecutorResult, ExecutorRun } from './types.js';
2
2
  export declare function createExecutor(configuration?: ExecutorConfiguration): Executor;
3
3
  export declare class DefaultExecutor implements Executor {
4
4
  private readonly runs;
@@ -7,23 +7,36 @@ export declare class DefaultExecutor implements Executor {
7
7
  constructor(configuration?: ExecutorConfiguration);
8
8
  registerActionExecutor(executor: ActionExecutor): void;
9
9
  execute(request: ExecutorRequest): Promise<ExecutorRun>;
10
+ private startRun;
10
11
  cancel(requestId: string): Promise<void>;
11
12
  control(requestId: string, command: ControlCommand): Promise<void>;
12
13
  controlState(requestId: string): ExecutionControlState | undefined;
13
14
  events(requestId: string): AsyncIterable<ExecutorEvent>;
14
15
  result(requestId: string): Promise<ExecutorResult>;
15
- retryAction(requestId: string, stepId: string, invocationPath?: string[]): Promise<void>;
16
- retryOperation(requestId: string, stepId: string, invocationPath?: string[]): Promise<void>;
17
- runFromHere(requestId: string, stepId: string, invocationPath?: string[]): Promise<ExecutorRun | undefined>;
16
+ private replayTarget;
17
+ retryAction(requestId: string, target: ActReplayTarget): Promise<ActReplayHandle>;
18
+ private replayEvents;
19
+ private emitReplay;
20
+ private replayAct;
21
+ retryOperation(requestId: string, target: ActReplayTarget): Promise<OperationReplayHandle>;
22
+ private replayOperation;
23
+ runFromHere(sourceRequestId: string, target: ActReplayTarget): Promise<ExecutorRun>;
18
24
  private run;
19
25
  private executeProject;
20
26
  private executeFlow;
21
27
  private executeStep;
22
28
  private executeControl;
29
+ private resolveBindings;
30
+ private resolveOperand;
31
+ private evaluateCondition;
32
+ private capture;
33
+ private captureExternalEvidence;
34
+ private assertActive;
23
35
  private executeAction;
24
36
  private boundary;
25
37
  private pauseAtBoundary;
26
38
  private emit;
39
+ private emitReplayOccurrence;
27
40
  private finish;
28
41
  private baseResult;
29
42
  private failure;
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,92 +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';
125
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;
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 arguments_ = resolveDeclaredInputs(definition.inputDeclarations, located.inputs);
136
- if (arguments_.issues.length)
137
- return;
138
- const childScope = { ...run.request.inputs, ...arguments_.inputs };
139
- const nested = await this.executeFlow(run, definition.children, childScope, [...(located.invocationPath ?? []).slice(0, -1), definition.id], new Date().toISOString());
140
- const outputs = definition.outputBindings && nested.state === 'completed'
141
- ? { ...definition.staticOutputs, ...resolveBindings(definition.outputBindings, childScope, new Map(nested.steps.map(child => [child.id, child.outputs]))) }
142
- : nested.steps.at(-1)?.outputs ?? {};
143
- const previous = located.attempts ?? [];
144
- 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, durationMs: nested.steps.reduce((sum, child) => sum + child.durationMs, 0), children: nested.steps, ...(nested.error ? { error: nested.error } : {}) }];
145
- located.children = nested.steps;
146
- located.outputs = outputs;
147
- located.state = nested.state === 'completed' ? 'finished' : nested.state === 'cancelled' ? 'cancelled' : 'failed';
148
- located.terminalReason = nested.terminalReason ?? (located.state === 'finished' ? 'completed' : 'failed');
149
- located.error = nested.error;
150
- if (located.state === 'finished' && run.result.state === 'failed') {
151
- run.result.state = 'completed';
152
- run.result.terminalReason = 'completed';
153
- delete run.result.error;
154
- }
155
- }
156
- async runFromHere(requestId, stepId, invocationPath) {
157
- const run = this.runs.get(requestId);
158
- if (!run)
159
- return undefined;
160
- if (invocationPath?.length)
161
- return undefined;
162
- const index = run.request.executionPackage.steps.findIndex(step => step.id === stepId);
163
- if (index < 0)
164
- return undefined;
165
- const selected = findStep(run.result?.steps ?? [], stepId, invocationPath);
166
- if (!selected)
167
- return undefined;
168
- const requestIdForRun = `${requestId}:from:${stepId}`;
169
- const remainingSteps = run.request.executionPackage.steps.slice(index).map((step, offset) => offset === 0
170
- ? { ...step, bindings: undefined, staticInputs: { ...(step.staticInputs ?? {}), ...selected.inputs } }
171
- : step);
172
- return this.execute({
173
- ...run.request,
174
- requestId: requestIdForRun,
175
- executionPackage: { ...run.request.executionPackage, steps: remainingSteps, target: { kind: 'flow', id: run.request.executionPackage.target.id, name: run.request.executionPackage.target.name } },
176
- 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
+ },
177
258
  });
178
259
  }
179
- 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) {
180
396
  const startedAt = new Date().toISOString();
397
+ if (validation.length) {
398
+ this.finish(run, this.baseResult(run, startedAt, 'failed', undefined, validation));
399
+ return;
400
+ }
181
401
  const timeoutMs = run.request.options?.timeoutMs;
182
402
  if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > this.maxTimeoutMs)) {
183
403
  this.finish(run, this.baseResult(run, startedAt, 'failed', { code: 'PREFLIGHT_ERROR', message: `Timeout must be between 0 and ${this.maxTimeoutMs} ms.` }));
@@ -185,7 +405,8 @@ export class DefaultExecutor {
185
405
  }
186
406
  let timeout;
187
407
  if (timeoutMs !== undefined)
188
- 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);
189
410
  this.emit(run, 'run_started', 'created', 'Execution accepted.');
190
411
  this.emit(run, 'preflight_started', 'preflighting', 'Validating execution package.');
191
412
  const arguments_ = resolveDeclaredInputs(run.request.executionPackage.inputDeclarations, run.request.inputs);
@@ -211,7 +432,7 @@ export class DefaultExecutor {
211
432
  if (timeout)
212
433
  clearTimeout(timeout);
213
434
  const error = this.failure(cause, run);
214
- 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));
215
436
  }
216
437
  }
217
438
  async executeProject(run, startedAt) {
@@ -229,33 +450,34 @@ export class DefaultExecutor {
229
450
  children.push(child);
230
451
  if (child.state !== 'completed')
231
452
  return this.baseResult(run, startedAt, child.state, child.error, undefined, children);
232
- const last = child.steps.at(-1)?.outputs ?? {};
233
- const mapped = flow.outputBindings
234
- ? Object.fromEntries(Object.entries(flow.outputBindings).flatMap(([name, ref]) => {
235
- const match = /^\$outputs\.(.+)$/.exec(ref);
236
- return match && last[match[1]] !== undefined ? [[name, last[match[1]]]] : [];
237
- }))
238
- : last;
239
- 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.');
240
455
  }
241
456
  return this.baseResult(run, startedAt, 'completed', undefined, undefined, children);
242
457
  }
243
- async executeFlow(run, steps, initialInputs, path, startedAt, target = run.request.executionPackage.target, signal = run.abort.signal) {
244
- const outputsById = new Map();
458
+ async executeFlow(run, steps, initialInputs, path, startedAt, target = run.request.executionPackage.target, signal = run.abort.signal, iterations = [], bodyFailurePolicy = 'stop') {
245
459
  const results = [];
246
460
  let state = 'completed';
247
461
  let error;
248
- for (const step of run.request.executionPackage.ordering === 'routine' ? steps : orderSteps(steps)) {
462
+ for (const step of steps) {
249
463
  if (run.cancelled || signal.aborted)
250
464
  throw new Error('Execution cancelled.');
251
- await this.boundary(run, step, [...path, step.id], signal);
252
- const result = await this.executeStep(run, step, initialInputs, outputsById, path, signal);
465
+ await this.boundary(run, step, [...path, step.id], signal, iterations);
466
+ if (run.cancelled || signal.aborted)
467
+ throw new Error('Execution cancelled.');
468
+ const result = await this.executeStep(run, step, initialInputs, path, signal, iterations);
253
469
  results.push(result);
254
- outputsById.set(step.id, result.outputs);
255
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;
256
478
  state = result.state === 'cancelled' ? 'cancelled' : 'failed';
257
479
  error = result.error;
258
- if (step.control?.onError !== 'continue')
480
+ if (interrupted || step.control?.onError !== 'continue')
259
481
  break;
260
482
  }
261
483
  }
@@ -265,68 +487,82 @@ export class DefaultExecutor {
265
487
  steps: results,
266
488
  };
267
489
  }
268
- async executeStep(run, step, scope, outputsById, path, signal = run.abort.signal) {
490
+ async executeStep(run, step, scope, path, signal = run.abort.signal, iterations = []) {
269
491
  const startedMs = Date.now();
270
492
  const invocationPath = [...path, step.id];
271
- const base = { id: step.id, name: step.name, kind: step.kind ?? 'action', inputs: {}, outputs: {}, invocationId: invocationPath.join('/'), invocationPath };
272
- if (step.dependsOn?.some(id => !outputsById.has(id)))
273
- return { ...base, state: 'skipped', durationMs: 0, terminalReason: 'dependency-skipped' };
274
- const inputs = { ...(step.staticInputs ?? {}), ...(step.bindings ? resolveBindings(step.bindings, scope, outputsById) : {}) };
275
- 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);
276
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) };
277
501
  if (step.delayMs) {
278
- 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);
279
503
  await delay(step.delayMs, signal);
280
504
  }
281
505
  if (step.control) {
282
- 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);
283
507
  return { ...base, ...controlResult, inputs, durationMs: Date.now() - startedMs };
284
508
  }
285
509
  if (step.kind === 'operation' && step.children) {
286
- const arguments_ = resolveDeclaredInputs(step.inputDeclarations, inputs);
510
+ const arguments_ = rootCapture ? { inputs: structuredClone(rootCapture.inputs), issues: [] } : resolveDeclaredInputs(step.inputDeclarations, inputs);
287
511
  if (arguments_.issues.length)
288
512
  throw failure('BINDING_RESOLUTION_ERROR', arguments_.issues.map(issue => issue.message).join(' '));
289
- const childScope = { ...scope, ...arguments_.inputs };
513
+ const childScope = rootCapture ? structuredClone(rootCapture.lexicalScope) : { ...scope, ...arguments_.inputs };
290
514
  for (const declaration of step.inputDeclarations ?? [])
291
515
  if (!Object.hasOwn(arguments_.inputs, declaration.name))
292
516
  delete childScope[declaration.name];
293
- const nested = await this.executeFlow(run, step.children, childScope, [...path, step.id], new Date().toISOString(), run.request.executionPackage.target, signal);
294
- const outputs = step.outputBindings && nested.state === 'completed'
295
- ? { ...step.staticOutputs, ...resolveBindings(step.outputBindings, childScope, new Map(nested.steps.map(child => [child.id, child.outputs]))) }
296
- : nested.steps.at(-1)?.outputs ?? {};
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
+ : {};
297
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 };
298
- 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);
299
528
  return result;
300
529
  }
301
- 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);
302
537
  const result = { ...base, state: 'finished', inputs, outputs: actionResult.outputs, durationMs: Date.now() - startedMs, terminalReason: 'completed', attempts: actionResult.attempts, evidence: actionResult.evidence };
303
- this.emit(run, 'step_finished', 'finished', `${step.name} finished.`, step);
538
+ this.emit(run, 'step_finished', 'finished', `${step.name} finished.`, step, undefined, iterations);
304
539
  if (run.stopAfter === step.id) {
305
540
  run.stopAfter = undefined;
306
- await this.pauseAtBoundary(run, step);
541
+ await this.pauseAtBoundary(run, step, iterations);
307
542
  }
308
543
  return result;
309
544
  }
310
545
  catch (cause) {
311
546
  const failure = this.failure(cause, run, signal);
312
547
  const state = run.timedOut ? 'failed' : run.cancelled || signal.aborted ? 'cancelled' : 'failed';
313
- const result = { ...base, state, inputs, durationMs: Date.now() - startedMs, terminalReason: run.timedOut ? 'timeout' : state === 'cancelled' ? 'cancelled' : 'failed', error: failure };
314
- 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);
315
551
  return result;
316
552
  }
317
553
  }
318
- 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 = []) {
319
555
  const control = step.control;
320
- if (control.condition !== undefined && !evaluateCondition(control.condition, { ...scope, ...inputs }, outputs))
556
+ if (control.condition !== undefined && !this.evaluateCondition(run, control.condition, { ...scope, ...inputs }, step.structuralAddress))
321
557
  return { state: 'skipped', outputs: {}, terminalReason: 'condition-skipped' };
322
558
  if (control.type === 'repeat') {
323
- const total = run.request.executionPackage.ordering === 'routine' ? Math.max(1, Math.min(control.iterations ?? (control.durationMs !== undefined ? 32 : 1), 32)) : control.iterations ?? 1;
559
+ const total = Math.max(1, Math.min(control.iterations ?? (control.durationMs !== undefined ? 32 : 1), 32));
324
560
  const loopStarted = Date.now();
325
561
  const children = [];
326
562
  for (let current = 1; current <= total; current++) {
327
- if (run.request.executionPackage.ordering === 'routine' && control.durationMs !== undefined && Date.now() - loopStarted >= control.durationMs)
563
+ if (control.durationMs !== undefined && Date.now() - loopStarted >= control.durationMs)
328
564
  break;
329
- const child = await this.executeFlow(run, step.children ?? [], { ...scope, ...inputs }, [...path, step.id, String(current)], new Date().toISOString(), run.request.executionPackage.target, signal);
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 }]);
330
566
  children.push(...child.steps.map(item => ({ ...item, iteration: { loopId: step.id, current, total } })));
331
567
  if (child.state !== 'completed' && control.onError !== 'continue')
332
568
  return { state: child.state, outputs: child.steps.at(-1)?.outputs ?? {}, children, terminalReason: child.terminalReason, error: child.error };
@@ -335,43 +571,29 @@ export class DefaultExecutor {
335
571
  }
336
572
  return { state: 'finished', outputs: children.at(-1)?.outputs ?? {}, children, terminalReason: 'completed' };
337
573
  }
338
- if (control.type === 'branch' && run.request.executionPackage.ordering === 'routine') {
574
+ if (control.type === 'branch') {
339
575
  const children = [];
340
576
  for (const branch of control.branches ?? []) {
341
- if (!evaluateCondition(branch.condition, { ...scope, ...inputs }, outputs))
577
+ if (!this.evaluateCondition(run, branch.condition, { ...scope, ...inputs }, step.structuralAddress))
342
578
  continue;
343
- const nested = await this.executeFlow(run, branch.children, { ...scope, ...inputs }, [...path, step.id, branch.id], new Date().toISOString(), run.request.executionPackage.target, signal);
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);
344
580
  children.push(...nested.steps);
345
581
  if (nested.state !== 'completed' && control.onError !== 'continue')
346
582
  return { state: nested.state, outputs: nested.steps.at(-1)?.outputs ?? {}, children, terminalReason: nested.terminalReason, error: nested.error };
347
583
  }
348
584
  return { state: children.length ? 'finished' : 'skipped', outputs: children.at(-1)?.outputs ?? {}, children, terminalReason: children.length ? 'completed' : 'branch-skipped' };
349
585
  }
350
- if (control.type === 'branch') {
351
- const branch = control.branches?.find(candidate => evaluateCondition(candidate.condition, { ...scope, ...inputs }, outputs));
352
- if (!branch)
353
- return { state: 'skipped', outputs: {}, terminalReason: 'branch-skipped' };
354
- const nested = await this.executeFlow(run, branch.children, { ...scope, ...inputs }, [...path, step.id, branch.id], new Date().toISOString(), run.request.executionPackage.target, signal);
355
- return { state: nested.state === 'completed' ? 'finished' : nested.state, outputs: nested.steps.at(-1)?.outputs ?? {}, children: nested.steps, terminalReason: nested.terminalReason, error: nested.error };
356
- }
357
586
  if (control.type === 'parallel') {
358
587
  const branches = control.branches ?? [];
359
- const join = run.request.executionPackage.ordering === 'routine' ? 'all' : control.join;
360
- 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));
361
589
  const controllers = selected.map(() => new AbortController());
362
590
  const abortBranches = () => controllers.forEach(controller => controller.abort());
363
591
  signal.addEventListener('abort', abortBranches, { once: true });
364
592
  if (signal.aborted)
365
593
  abortBranches();
366
- let winnerIndex;
367
594
  const branchRuns = await Promise.all(selected.map(async (branch, index) => {
368
595
  try {
369
- 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);
370
- if (join === 'first' && result.state === 'completed' && winnerIndex === undefined) {
371
- winnerIndex = index;
372
- controllers.forEach((controller, candidate) => { if (candidate !== index)
373
- controller.abort(); });
374
- }
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);
375
597
  return { branch, result };
376
598
  }
377
599
  catch (cause) {
@@ -381,61 +603,166 @@ export class DefaultExecutor {
381
603
  signal.removeEventListener('abort', abortBranches);
382
604
  const nested = branchRuns.map(item => item.result);
383
605
  const children = nested.flatMap(item => item.steps);
384
- const considered = join === 'first' && winnerIndex !== undefined ? [branchRuns[winnerIndex]] : branchRuns;
385
- const failed = considered.find(item => item.result.state !== 'completed');
386
- const branchOutputs = considered.map(({ branch, result }) => ({ id: branch.id, outputs: result.steps.at(-1)?.outputs ?? {} }));
387
- const mergedOutputs = mergeBranchOutputs(branchOutputs, control.mergeBindings, control.merge ?? (join === 'first' ? 'first' : 'collect'));
388
- 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 };
389
610
  }
390
- 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');
391
612
  return { state: nested.state === 'completed' ? 'finished' : nested.state, outputs: nested.steps.at(-1)?.outputs ?? {}, children: nested.steps, terminalReason: nested.terminalReason, error: nested.error };
392
613
  }
393
- 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) {
394
704
  if (run.request.options?.dryRun)
395
- return { outputs: {}, attempts: [] };
705
+ return { outputs: {}, attempts };
396
706
  const executor = this.actionExecutors.find(candidate => candidate.supports(step.action));
397
707
  if (!executor)
398
708
  throw failure('UNSUPPORTED_ACTION', `No Action Executor is registered for ${step.action}.`);
709
+ const originalInputs = structuredClone(inputs);
710
+ freezePlan(originalInputs);
399
711
  const maxAttempts = Math.max(1, step.retry?.maxAttempts ?? 1);
400
- const attempts = [];
401
712
  let lastFailure;
402
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);
403
716
  const startedAt = new Date().toISOString();
404
717
  const startedMs = Date.now();
718
+ onAttempt?.('attempt_started', attempt, 'running');
405
719
  try {
406
- const context = { action: step.action, inputs, environment: run.request.environment, runtimeValues: run.request.runtimeValues ?? {}, step, signal };
407
720
  const raw = await executor.execute(context);
721
+ this.assertActive(run, signal);
408
722
  const normalized = isActionResult(raw) ? raw : { outputs: raw };
409
- const finishedAt = new Date().toISOString();
410
- 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);
411
727
  return { outputs: normalized.outputs, evidence: normalized.evidence, attempts };
412
728
  }
413
729
  catch (cause) {
414
730
  lastFailure = this.failure(cause, run, signal);
415
- 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 });
416
- 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);
417
738
  await delay(step.retry.delayMs, signal);
739
+ this.assertActive(run, signal);
740
+ }
418
741
  }
419
742
  }
420
743
  throw lastFailure ?? failure('ACTION_FAILED', `Action ${step.action} failed.`);
421
744
  }
422
- 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
+ }
423
750
  const breakpointKey = stepPathForBoundary(step, run, path);
424
751
  const breakpoints = run.request.debugSession?.breakpoints ?? [];
425
752
  if (breakpoints.includes(step.id) && !run.hitBreakpoints.has(breakpointKey)) {
426
753
  run.hitBreakpoints.add(breakpointKey);
427
754
  run.pauseRequested = true;
428
- 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);
429
756
  }
430
757
  if (run.pauseRequested || run.controlState === 'pause-requested')
431
- await this.pauseAtBoundary(run, step);
758
+ await this.pauseAtBoundary(run, step, iterations);
432
759
  if (run.cancelled || signal.aborted)
433
760
  throw new Error('Execution cancelled.');
434
761
  }
435
- async pauseAtBoundary(run, step) {
762
+ async pauseAtBoundary(run, step, iterations) {
436
763
  run.controlState = 'paused';
437
764
  run.pausedStartedMs = Date.now();
438
- 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);
439
766
  await new Promise(resolve => run.controlWaiters.push(resolve));
440
767
  run.pausedDurationMs += Date.now() - (run.pausedStartedMs ?? Date.now());
441
768
  run.pausedStartedMs = undefined;
@@ -445,10 +772,25 @@ export class DefaultExecutor {
445
772
  run.pauseRequested = true;
446
773
  }
447
774
  }
448
- emit(run, type, state, message, step, control) {
449
- 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 } : {}) });
450
781
  run.listeners.splice(0).forEach(resolve => resolve());
451
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
+ }
452
794
  finish(run, result) {
453
795
  const finishedAt = new Date().toISOString();
454
796
  const finalResult = { ...result, finishedAt, executionTiming: { wallDurationMs: Date.now() - run.startedMs, activeDurationMs: Date.now() - run.startedMs - run.pausedDurationMs, pausedDurationMs: run.pausedDurationMs } };
@@ -459,15 +801,15 @@ export class DefaultExecutor {
459
801
  run.listeners.splice(0).forEach(resolve => resolve());
460
802
  }
461
803
  baseResult(run, startedAt, state, error, issues, children) {
462
- 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 } : {}) };
463
805
  }
464
806
  failure(cause, run, signal = run.abort.signal) {
465
- if (isFailure(cause))
466
- return cause;
467
807
  if (run.timedOut)
468
808
  return failure('ACTION_TIMEOUT', 'Execution exceeded its timeout.');
469
809
  if (run.cancelled || signal.aborted)
470
810
  return failure('ACTION_CANCELLED', 'Execution was cancelled.');
811
+ if (isFailure(cause))
812
+ return cause;
471
813
  return failure('ACTION_FAILED', cause instanceof Error ? cause.message : 'Action execution failed.');
472
814
  }
473
815
  }
@@ -487,104 +829,9 @@ function preflight(pkg, inputs, runtimeValues) {
487
829
  issues.push({ code: 'MISSING_RUNTIME_VALUE', message: `Runtime value '${name}' is missing.` });
488
830
  return issues;
489
831
  }
490
- function orderSteps(steps) {
491
- const byId = new Map();
492
- for (const step of steps) {
493
- if (byId.has(step.id))
494
- throw failure('PREFLIGHT_ERROR', `Graph contains duplicate step id ${step.id}.`);
495
- byId.set(step.id, step);
496
- }
497
- const remaining = [...steps];
498
- const ordered = [];
499
- while (remaining.length) {
500
- const index = remaining.findIndex(step => (step.dependsOn ?? []).every(dependency => byId.has(dependency) && ordered.some(done => done.id === dependency)));
501
- if (index < 0) {
502
- const missing = remaining.flatMap(step => step.dependsOn ?? []).find(dependency => !byId.has(dependency));
503
- throw failure('PREFLIGHT_ERROR', missing ? `Graph dependency ${missing} is missing.` : 'Graph contains a dependency cycle.');
504
- }
505
- ordered.push(remaining.splice(index, 1)[0]);
506
- }
507
- return ordered;
508
- }
509
- function findStep(results, stepId, invocationPath) {
510
- for (const result of results) {
511
- if (result.id === stepId && (!invocationPath || result.invocationPath?.join('.') === invocationPath.join('.')))
512
- return result;
513
- const nested = result.children && findStep(result.children, stepId, invocationPath);
514
- if (nested)
515
- return nested;
516
- }
517
- return undefined;
518
- }
519
- function findDefinition(steps, stepId, invocationPath) {
520
- for (const step of steps) {
521
- if (step.id === stepId && (!invocationPath || invocationPath.at(-1) === stepId))
522
- return step;
523
- const nested = step.children && findDefinition(step.children, stepId, invocationPath);
524
- if (nested)
525
- return nested;
526
- for (const branch of step.control?.branches ?? []) {
527
- const branchNested = findDefinition(branch.children, stepId, invocationPath);
528
- if (branchNested)
529
- return branchNested;
530
- }
531
- }
532
- return undefined;
533
- }
534
832
  function invocationState(state) {
535
833
  return state === 'cancelled' ? 'cancelled' : state === 'failed' ? 'failed' : 'skipped';
536
834
  }
537
- function mergeBranchOutputs(branches, bindings, policy) {
538
- if (bindings)
539
- return Object.fromEntries(Object.entries(bindings).flatMap(([name, source]) => {
540
- const match = /^([^.]+)\.(.+)$/.exec(source);
541
- const branch = match ? branches.find(item => item.id === match[1]) : undefined;
542
- return branch && match && Object.prototype.hasOwnProperty.call(branch.outputs, match[2]) ? [[name, branch.outputs[match[2]]]] : [];
543
- }));
544
- if (policy === 'first')
545
- return { ...(branches[0]?.outputs ?? {}) };
546
- if (policy === 'last')
547
- return { ...(branches.at(-1)?.outputs ?? {}) };
548
- return Object.fromEntries(branches.flatMap(branch => Object.entries(branch.outputs).map(([name, value]) => [`${branch.id}.${name}`, value])));
549
- }
550
- function resolveBindings(bindings, scope, outputs) {
551
- return Object.fromEntries(Object.entries(bindings).map(([name, reference]) => [name, resolveReference(reference, scope, outputs)]));
552
- }
553
- function resolveReference(reference, scope, outputs) {
554
- const input = /^\$inputs\.(.+)$/.exec(reference);
555
- if (input)
556
- return readPath(scope, input[1]);
557
- const step = /^\$steps\.([^.]+)\.(.+)$/.exec(reference);
558
- if (step)
559
- return readPath(outputs.get(step[1]) ?? {}, step[2]);
560
- if (reference.startsWith('$'))
561
- throw failure('BINDING_RESOLUTION_ERROR', `Binding '${reference}' could not be resolved.`);
562
- return reference;
563
- }
564
- function readPath(value, path) {
565
- const result = path.split('.').reduce((current, segment) => isRecord(current) ? current[segment] : undefined, value);
566
- if (result === undefined)
567
- throw failure('BINDING_RESOLUTION_ERROR', `Binding path '${path}' could not be resolved.`);
568
- return result;
569
- }
570
- function evaluateCondition(condition, scope, outputs) {
571
- if (typeof condition === 'boolean')
572
- return condition;
573
- let value;
574
- try {
575
- value = resolveReference(condition.binding, scope, outputs);
576
- }
577
- catch {
578
- value = undefined;
579
- }
580
- if (condition.exists !== undefined)
581
- return condition.exists === (value !== undefined);
582
- if ('equals' in condition)
583
- return value === condition.equals;
584
- if ('notEquals' in condition)
585
- return value !== condition.notEquals;
586
- return Boolean(value);
587
- }
588
835
  function delay(durationMs, signal) {
589
836
  return new Promise((resolve, reject) => {
590
837
  if (signal.aborted) {
@@ -599,3 +846,7 @@ function failure(code, message) { return { code, message, category: code }; }
599
846
  function isFailure(value) { return isRecord(value) && typeof value.code === 'string' && typeof value.message === 'string'; }
600
847
  function isActionResult(value) { return isRecord(value) && isRecord(value.outputs); }
601
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
+ }
@@ -1,3 +1,3 @@
1
- import type { ExecutionPackage } from './types';
1
+ import type { ExecutionPackage } from './migrated-types';
2
2
  /** Stable, dependency-free content identity for the normalized execution package. */
3
3
  export declare function hashExecutionPackage(pack: ExecutionPackage): string;
@@ -1,4 +1,4 @@
1
- import type { ActionExecutor, Executor, ExecutorConfiguration, ExecutorEvent, ExecutorRequest, ExecutorResult, ExecutorRun, ControlCommand, ExecutionControlState } from './types.js';
1
+ import type { ActReplayTarget, ActReplayHandle, OperationReplayHandle, ActionExecutor, Executor, ExecutorConfiguration, ExecutorEvent, ExecutorRequest, ExecutorResult, ExecutorRun, ControlCommand, ExecutionControlState } from './types.js';
2
2
  export declare function createMigratedExecutor(configuration?: ExecutorConfiguration): Executor;
3
3
  /**
4
4
  * Compatibility facade over the extracted Runner engine. The facade owns the
@@ -18,7 +18,7 @@ export declare class MigratedExecutor implements Executor {
18
18
  controlState(requestId: string): ExecutionControlState | undefined;
19
19
  events(requestId: string): AsyncIterable<ExecutorEvent>;
20
20
  result(requestId: string): Promise<ExecutorResult>;
21
- retryAction(requestId: string, stepId: string, invocationPath?: string[]): Promise<void>;
22
- retryOperation(requestId: string, stepId: string, invocationPath?: string[]): Promise<void>;
23
- runFromHere(requestId: string, stepId: string, invocationPath?: string[]): Promise<ExecutorRun | undefined>;
21
+ retryAction(requestId: string, target: ActReplayTarget): Promise<ActReplayHandle>;
22
+ retryOperation(requestId: string, target: ActReplayTarget): Promise<OperationReplayHandle>;
23
+ runFromHere(requestId: string, target: ActReplayTarget): Promise<ExecutorRun>;
24
24
  }
@@ -48,17 +48,22 @@ export class MigratedExecutor {
48
48
  controlState(requestId) { return this.routineRuns.has(requestId) ? this.routine.controlState(requestId) : this.engine.controlState(requestId); }
49
49
  async *events(requestId) { yield* (this.routineRuns.has(requestId) ? this.routine.events(requestId) : this.engine.events(requestId)); }
50
50
  async result(requestId) { return (this.routineRuns.has(requestId) ? this.routine.result(requestId) : this.engine.result(requestId)); }
51
- async retryAction(requestId, stepId, invocationPath) { await (this.routineRuns.has(requestId) ? this.routine.retryAction(requestId, stepId, invocationPath) : this.engine.retryAction(requestId, stepId, invocationPath)); }
52
- async retryOperation(requestId, stepId, invocationPath) { await (this.routineRuns.has(requestId) ? this.routine.retryOperation(requestId, stepId, invocationPath) : this.engine.retryOperation(requestId, stepId, invocationPath)); }
53
- async runFromHere(requestId, stepId, invocationPath) {
54
- if (this.routineRuns.has(requestId)) {
55
- const targeted = await this.routine.runFromHere(requestId, stepId, invocationPath);
56
- if (targeted)
57
- this.routineRuns.add(targeted.requestId);
58
- return targeted;
59
- }
60
- const accepted = await this.engine.runFromHere(requestId, stepId, invocationPath);
61
- return accepted ? { requestId: accepted.requestId, result: this.result(accepted.requestId) } : undefined;
51
+ async retryAction(requestId, target) {
52
+ if (this.routineRuns.has(requestId))
53
+ return this.routine.retryAction(requestId, target);
54
+ throw { code: 'unsupported-execution-context', message: 'Structural Act replay requires the canonical execution path.' };
55
+ }
56
+ async retryOperation(requestId, target) {
57
+ if (this.routineRuns.has(requestId))
58
+ return this.routine.retryOperation(requestId, target);
59
+ throw { code: 'unsupported-execution-context', message: 'Structural Operation replay requires the canonical execution path.' };
60
+ }
61
+ async runFromHere(requestId, target) {
62
+ if (!this.routineRuns.has(requestId))
63
+ throw { code: 'unsupported-execution-context', message: 'Structural Run From Here requires the canonical execution path.' };
64
+ const started = await this.routine.runFromHere(requestId, target);
65
+ this.routineRuns.add(started.requestId);
66
+ return started;
62
67
  }
63
68
  }
64
69
  function toActionAdapter(executor) {
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { ExecutionPackage, ExecutionStep, ExecutionState, InvocationState, TerminalReason } from '@qaick/contract';
2
- export type { ExecutionPackage, ExecutionStep, ExecutionState, InvocationState, TerminalReason } from '@qaick/contract';
1
+ import type { ExecutionPackageV7 as ExecutionPackage, ExecutionStepV7 as ExecutionStep, ExecutionState, InvocationState, TerminalReason } from '@qaick/contract';
2
+ export type { ExecutionPackageV7 as ExecutionPackage, ExecutionStepV7 as ExecutionStep, ExecutionState, InvocationState, TerminalReason } from '@qaick/contract';
3
3
  export type ActionFailureCode = 'ACTION_FAILED' | 'ACTION_TIMEOUT' | 'ACTION_CANCELLED' | 'UNSUPPORTED_ACTION' | 'BINDING_RESOLUTION_ERROR' | 'CONTROL_BLOCK_ERROR' | 'PREFLIGHT_ERROR' | 'INTERNAL_EXECUTION_ERROR';
4
4
  export interface ActionFailure {
5
5
  code: string;
@@ -12,7 +12,7 @@ export interface ActionExecutionContext {
12
12
  inputs: Record<string, unknown>;
13
13
  environment: string;
14
14
  runtimeValues: Record<string, unknown>;
15
- step: ExecutionStep;
15
+ step: Pick<ExecutionStep, 'id' | 'name' | 'action' | 'output' | 'adapterConfig'>;
16
16
  signal: AbortSignal;
17
17
  }
18
18
  export interface ActionExecutionResult {
@@ -63,6 +63,8 @@ export interface ExecutorStepResult {
63
63
  inputs: Record<string, unknown>;
64
64
  outputs: Record<string, unknown>;
65
65
  durationMs: number;
66
+ structuralAddress?: import('@qaick/contract').HierarchyAddress;
67
+ iterations?: import('@qaick/contract').IterationSelection[];
66
68
  invocationId?: string;
67
69
  invocationPath?: string[];
68
70
  kind?: 'action' | 'operation' | 'control-block';
@@ -78,6 +80,7 @@ export interface ExecutorStepResult {
78
80
  error?: ActionFailure;
79
81
  }
80
82
  export interface ExecutorResult {
83
+ readonly debugProvenance?: RunFromHereProvenance;
81
84
  requestId: string;
82
85
  state: Exclude<ExecutionState, 'created' | 'preflighting' | 'running'>;
83
86
  target: ExecutionPackage['target'];
@@ -100,6 +103,9 @@ export interface ExecutorResult {
100
103
  };
101
104
  }
102
105
  export interface ExecutorEvent {
106
+ structuralAddress?: import('@qaick/contract').HierarchyAddress;
107
+ iterations?: import('@qaick/contract').IterationSelection[];
108
+ kind?: 'action' | 'operation' | 'control-block';
103
109
  requestId: string;
104
110
  sequence: number;
105
111
  type: 'run_started' | 'preflight_started' | 'step_started' | 'waiting' | 'step_finished' | 'run_finished';
@@ -138,9 +144,79 @@ export type ControlCommand = {
138
144
  };
139
145
  export type ExecutionControlState = 'idle' | 'running' | 'pause-requested' | 'paused' | 'resuming' | 'stopped' | 'failed' | 'completed';
140
146
  export interface ExecutorRun {
147
+ readonly debugProvenance?: RunFromHereProvenance;
141
148
  requestId: string;
142
149
  result: Promise<ExecutorResult>;
143
150
  }
151
+ /** Runtime diagnostic target; never serialized into an Execution v7 package. */
152
+ export interface ActReplayTarget {
153
+ structuralAddress: import('@qaick/contract').HierarchyAddress;
154
+ iterations: import('@qaick/contract').IterationSelection[];
155
+ }
156
+ /** Descriptive runtime provenance, not an execution operand or persisted package field. */
157
+ export interface RunFromHereProvenance {
158
+ readonly kind: 'run-from-here';
159
+ readonly sourceRequestId: string;
160
+ readonly selection: Readonly<ActReplayTarget>;
161
+ readonly inputSeed: {
162
+ readonly kind: 'captured-inputs';
163
+ readonly inputs: Readonly<Record<string, unknown>>;
164
+ readonly lexicalScope?: Readonly<Record<string, unknown>>;
165
+ };
166
+ readonly omittedOccurrences: readonly import('@qaick/contract').HierarchyAddress[];
167
+ }
168
+ export interface ActReplayResult {
169
+ readonly parentRequestId: string;
170
+ readonly sessionId: string;
171
+ readonly target: Readonly<ActReplayTarget>;
172
+ readonly kind: 'action';
173
+ readonly state: 'completed' | 'failed' | 'cancelled';
174
+ readonly terminalReason: 'completed' | 'failed' | 'cancelled' | 'timeout';
175
+ readonly startedAt: string;
176
+ readonly finishedAt: string;
177
+ readonly durationMs: number;
178
+ readonly inputs: Readonly<Record<string, unknown>>;
179
+ readonly outputs: Readonly<Record<string, unknown>>;
180
+ readonly evidence?: Readonly<Record<string, unknown>>;
181
+ readonly attempts: readonly ExecutionAttempt[];
182
+ readonly error?: Readonly<ActionFailure>;
183
+ }
184
+ export interface ActReplayEvent {
185
+ readonly parentRequestId: string;
186
+ readonly sessionId: string;
187
+ readonly sequence: number;
188
+ readonly timestamp: string;
189
+ readonly structuralAddress: import('@qaick/contract').HierarchyAddress;
190
+ readonly iterations: import('@qaick/contract').IterationSelection[];
191
+ readonly kind: 'action';
192
+ readonly type: 'replay_started' | 'attempt_started' | 'attempt_finished' | 'replay_finished';
193
+ readonly state: 'running' | 'finished' | 'completed' | 'failed' | 'cancelled';
194
+ readonly attempt?: number;
195
+ readonly terminalReason?: ActReplayResult['terminalReason'];
196
+ }
197
+ /** A replay is associated with a historical Run; this is not a new ExecutorRun. */
198
+ export interface ActReplayHandle {
199
+ readonly parentRequestId: string;
200
+ readonly sessionId: string;
201
+ readonly target: Readonly<ActReplayTarget>;
202
+ readonly result: Promise<ActReplayResult>;
203
+ events(): AsyncIterable<ActReplayEvent>;
204
+ cancel(): Promise<void>;
205
+ }
206
+ export interface OperationReplayResult extends Omit<ActReplayResult, 'kind' | 'attempts' | 'evidence'> {
207
+ readonly kind: 'operation';
208
+ readonly lexicalScope: Readonly<Record<string, unknown>>;
209
+ readonly children: readonly ExecutorStepResult[];
210
+ }
211
+ export interface OperationReplayEvent extends Omit<ActReplayEvent, 'kind' | 'type' | 'state'> {
212
+ readonly kind: 'action' | 'operation' | 'control-block';
213
+ readonly type: ActReplayEvent['type'] | 'step_started' | 'step_finished' | 'waiting';
214
+ readonly state: ActReplayEvent['state'] | 'waiting' | 'skipped';
215
+ }
216
+ export interface OperationReplayHandle extends Omit<ActReplayHandle, 'result' | 'events'> {
217
+ readonly result: Promise<OperationReplayResult>;
218
+ events(): AsyncIterable<OperationReplayEvent>;
219
+ }
144
220
  export interface ExecutorConfiguration {
145
221
  actionExecutors?: ActionExecutor[];
146
222
  maxTimeoutMs?: number;
@@ -152,8 +228,8 @@ export interface Executor {
152
228
  controlState(requestId: string): ExecutionControlState | undefined;
153
229
  events(requestId: string): AsyncIterable<ExecutorEvent>;
154
230
  result(requestId: string): Promise<ExecutorResult>;
155
- retryAction(requestId: string, stepId: string, invocationPath?: string[]): Promise<void>;
156
- retryOperation(requestId: string, stepId: string, invocationPath?: string[]): Promise<void>;
157
- runFromHere(requestId: string, stepId: string, invocationPath?: string[]): Promise<ExecutorRun | undefined>;
231
+ retryAction(requestId: string, target: ActReplayTarget): Promise<ActReplayHandle>;
232
+ retryOperation(requestId: string, target: ActReplayTarget): Promise<OperationReplayHandle>;
233
+ runFromHere(requestId: string, target: ActReplayTarget): Promise<ExecutorRun>;
158
234
  registerActionExecutor(executor: ActionExecutor): void;
159
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaick/executor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Host-neutral QAick action execution primitives.",
5
5
  "files": [
6
6
  "dist",
@@ -34,7 +34,7 @@
34
34
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
35
35
  },
36
36
  "dependencies": {
37
- "@qaick/contract": "0.2.13"
37
+ "@qaick/contract": "0.3.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^24.13.3",