@qaick/executor 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/executor.d.ts +30 -0
- package/dist/executor.js +556 -0
- package/dist/in-memory-action-executor.d.ts +5 -0
- package/dist/in-memory-action-executor.js +6 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/migrated-adapter.d.ts +5 -0
- package/dist/migrated-adapter.js +4 -0
- package/dist/migrated-artifact-hash.d.ts +3 -0
- package/dist/migrated-artifact-hash.js +19 -0
- package/dist/migrated-executor.d.ts +22 -0
- package/dist/migrated-executor.js +61 -0
- package/dist/migrated-project-engine.d.ts +21 -0
- package/dist/migrated-project-engine.js +118 -0
- package/dist/migrated-runner-engine.d.ts +33 -0
- package/dist/migrated-runner-engine.js +705 -0
- package/dist/migrated-types.d.ts +77 -0
- package/dist/migrated-types.js +4 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.js +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { InMemoryActionAdapter } from './migrated-adapter.js';
|
|
2
|
+
import { hashExecutionPackage } from './migrated-artifact-hash.js';
|
|
3
|
+
import { RUNNER_PACKAGE_SCHEMA_VERSION } from './migrated-types.js';
|
|
4
|
+
function withRuntimeValues(context, runtimeValues) {
|
|
5
|
+
return { ...context, runtimeValues: runtimeValues ?? {} };
|
|
6
|
+
}
|
|
7
|
+
const MAX_EXECUTION_TIMEOUT_MS = 300_000;
|
|
8
|
+
const MAX_PARALLEL_BRANCHES = 32;
|
|
9
|
+
export class MigratedRunnerEngine {
|
|
10
|
+
adapters;
|
|
11
|
+
runs = new Map();
|
|
12
|
+
constructor(adapters = [new InMemoryActionAdapter()]) {
|
|
13
|
+
this.adapters = adapters;
|
|
14
|
+
}
|
|
15
|
+
async submit(request) {
|
|
16
|
+
let resultResolve;
|
|
17
|
+
const resultPromise = new Promise(resolve => { resultResolve = resolve; });
|
|
18
|
+
const timeoutMs = request.options?.timeoutMs;
|
|
19
|
+
const debugSession = { breakpoints: [...(request.debugSession?.breakpoints ?? [])], ...(request.debugSession?.stopAfterStepId ? { stopAfterStepId: request.debugSession.stopAfterStepId } : {}), ...(request.debugSession?.selectedStepId ? { selectedStepId: request.debugSession.selectedStepId } : {}) };
|
|
20
|
+
const run = { request, debugSession, hitBreakpoints: new Set(), events: [], listeners: [], resultResolve, cancelled: false, timedOut: false, timeoutAt: timeoutMs === undefined ? undefined : Date.now() + timeoutMs, abortController: new AbortController(), controlState: 'idle', pauseRequested: false, stepRequested: false, controlWaiters: [], startedMs: Date.now(), pausedDurationMs: 0 };
|
|
21
|
+
this.runs.set(request.requestId, run);
|
|
22
|
+
void this.execute(run, resultPromise);
|
|
23
|
+
return { requestId: request.requestId };
|
|
24
|
+
}
|
|
25
|
+
async cancel(requestId) { const run = this.runs.get(requestId); if (run) {
|
|
26
|
+
run.cancelled = true;
|
|
27
|
+
run.controlState = 'stopped';
|
|
28
|
+
run.abortController.abort();
|
|
29
|
+
run.controlWaiters.splice(0).forEach(resolve => resolve());
|
|
30
|
+
run.listeners.splice(0).forEach(resolve => resolve());
|
|
31
|
+
} }
|
|
32
|
+
async pauseExecution(requestId) { const run = this.runs.get(requestId); if (!run || run.result || run.cancelled || run.controlState === 'paused' || run.controlState === 'pause-requested')
|
|
33
|
+
return; run.pauseRequested = true; run.controlState = 'pause-requested'; this.emit(run, 'waiting', 'waiting', 'Pause requested; Runner will pause before the next executable boundary.', undefined, undefined, undefined, { type: 'pause-requested' }); }
|
|
34
|
+
async resumeExecution(requestId) { const run = this.runs.get(requestId); if (!run || run.result || run.cancelled || (!run.pauseRequested && run.controlState !== 'paused'))
|
|
35
|
+
return; run.pauseRequested = false; run.controlState = 'resuming'; run.controlWaiters.splice(0).forEach(resolve => resolve()); this.emit(run, 'waiting', 'waiting', 'Execution resumed.', undefined, undefined, undefined, { type: 'resumed' }); }
|
|
36
|
+
async stepExecution(requestId) { const run = this.runs.get(requestId); if (!run || run.result || run.cancelled || run.controlState !== 'paused')
|
|
37
|
+
return; run.pauseRequested = false; run.stepRequested = true; run.controlState = 'resuming'; run.controlWaiters.splice(0).forEach(resolve => resolve()); this.emit(run, 'waiting', 'waiting', 'Step requested; Runner will execute one boundary.', undefined, undefined, undefined, { type: 'step-requested' }); }
|
|
38
|
+
async retryAction(requestId, stepId, invocationPath) { const run = this.runs.get(requestId); if (!run || !run.result || run.cancelled)
|
|
39
|
+
return; const stepResult = findStepResult(run.result.steps, stepId, invocationPath); const step = findExecutionStep(run.request.executionPackage.steps, stepId, invocationPath); if (!stepResult || !step || step.kind === 'operation' || step.kind === 'control-block' || stepResult.state === 'skipped') {
|
|
40
|
+
this.emit(run, 'waiting', 'waiting', `Interactive retry unavailable for ${stepId}.`, step, undefined, undefined, { type: 'interactive-retry-requested', stepId });
|
|
41
|
+
return;
|
|
42
|
+
} const adapter = this.adapters.find(candidate => candidate.supports(step.action)); if (!adapter) {
|
|
43
|
+
this.emit(run, 'waiting', 'waiting', `Interactive retry unavailable for ${step.name}: no Action adapter.`, step, undefined, undefined, { type: 'interactive-retry-requested', stepId });
|
|
44
|
+
return;
|
|
45
|
+
} const previousAttempts = stepResult.attempts ?? [{ attempt: 1, kind: 'automatic', state: stepResult.state === 'failed' ? 'failed' : 'finished', startedAt: run.result.startedAt, finishedAt: run.result.finishedAt, inputs: { ...stepResult.inputs }, outputs: { ...stepResult.outputs }, durationMs: stepResult.durationMs, ...(stepResult.error ? { error: stepResult.error } : {}) }]; const attempt = previousAttempts.length + 1; const startedAt = new Date().toISOString(); const startedMs = Date.now(); this.emit(run, 'waiting', 'waiting', `Interactive retry requested for ${step.name}.`, step, undefined, undefined, { type: 'interactive-retry-requested', stepId }); this.emit(run, 'step_started', 'running', `${step.name} retry attempt ${attempt} started.`, step); let outputs; let error; try {
|
|
46
|
+
outputs = await adapter.execute(withRuntimeValues({ action: step.action, inputs: { ...stepResult.inputs }, environment: run.request.environment, step, signal: run.abortController.signal }, run.request.runtimeValues));
|
|
47
|
+
}
|
|
48
|
+
catch (cause) {
|
|
49
|
+
error = { code: isAdapterError(cause) ? cause.code : 'ACTION_FAILED', message: cause instanceof Error ? cause.message : `Action ${step.action} failed.` };
|
|
50
|
+
} const finishedAt = new Date().toISOString(); const durationMs = Date.now() - startedMs; const interactiveAttempt = { attempt, kind: 'interactive', state: outputs ? 'finished' : 'failed', startedAt, finishedAt, inputs: { ...stepResult.inputs }, outputs: outputs ?? {}, durationMs, ...(error ? { error } : {}) }; stepResult.attempts = [...previousAttempts, interactiveAttempt]; stepResult.durationMs = durationMs; if (outputs) {
|
|
51
|
+
stepResult.state = 'finished';
|
|
52
|
+
stepResult.outputs = outputs;
|
|
53
|
+
delete stepResult.error;
|
|
54
|
+
stepResult.terminalReason = 'completed';
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
stepResult.state = 'failed';
|
|
58
|
+
stepResult.outputs = {};
|
|
59
|
+
stepResult.error = error;
|
|
60
|
+
stepResult.terminalReason = 'failed';
|
|
61
|
+
} this.emit(run, 'step_finished', outputs ? 'finished' : 'failed', `${step.name} retry attempt ${attempt} ${outputs ? 'finished' : 'failed'}.`, step); this.emit(run, 'waiting', 'waiting', `Interactive retry attempt ${attempt} ${outputs ? 'completed' : 'failed'}.`, step, undefined, undefined, { type: 'retry-attempt-completed', stepId }); if (outputs && run.result.state === 'failed') {
|
|
62
|
+
run.result.state = 'completed';
|
|
63
|
+
run.result.terminalReason = 'completed';
|
|
64
|
+
delete run.result.error;
|
|
65
|
+
} }
|
|
66
|
+
async retryOperation(requestId, stepId, invocationPath) { const run = this.runs.get(requestId); if (!run || !run.result || run.cancelled)
|
|
67
|
+
return; const stepResult = findStepResult(run.result.steps, stepId, invocationPath); const step = findExecutionStep(run.request.executionPackage.steps, stepId, invocationPath); if (!stepResult || !step || step.kind !== 'operation' || !step.children?.length || stepResult.state === 'skipped') {
|
|
68
|
+
this.emit(run, 'waiting', 'waiting', `Interactive Operation retry unavailable for ${stepId}.`, step, undefined, undefined, { type: 'interactive-retry-requested', stepId });
|
|
69
|
+
return;
|
|
70
|
+
} const childPackage = { ...run.request.executionPackage, target: { kind: 'flow', id: `${run.request.executionPackage.target.id}:${step.id}`, name: step.name }, steps: step.children, flows: undefined, requiredInputs: [] }; const childEngine = new MigratedRunnerEngine(this.adapters); const retryRequestId = `${requestId}:operation-retry:${step.id}:${(stepResult.attempts?.length ?? 0) + 1}`; const startedAt = new Date().toISOString(); const startedMs = Date.now(); this.emit(run, 'waiting', 'waiting', `Interactive Operation retry requested for ${step.name}.`, step, undefined, undefined, { type: 'interactive-retry-requested', stepId }); this.emit(run, 'step_started', 'running', `${step.name} retry attempt ${(stepResult.attempts?.length ?? 0) + 1} started.`, step); await childEngine.submit({ requestId: retryRequestId, executionPackage: childPackage, inputs: run.request.inputs, environment: run.request.environment, runtimeValues: run.request.runtimeValues, options: run.request.options }); const retryResult = await childEngine.result(retryRequestId); const finishedAt = new Date().toISOString(); const durationMs = Date.now() - startedMs; const previousAttempts = stepResult.attempts ?? [{ attempt: 1, kind: 'automatic', state: stepResult.state === 'failed' ? 'failed' : 'finished', startedAt: run.result.startedAt, finishedAt: run.result.finishedAt, inputs: {}, outputs: { ...stepResult.outputs }, durationMs: stepResult.durationMs, children: stepResult.children?.map(child => ({ ...child, attempts: child.attempts })) }]; const attempt = previousAttempts.length + 1; const interactiveAttempt = { attempt, kind: 'interactive', state: retryResult.state === 'completed' ? 'finished' : 'failed', startedAt, finishedAt, inputs: {}, outputs: { ...stepResult.outputs }, durationMs, children: retryResult.steps, ...(retryResult.error ? { error: retryResult.error } : {}) }; stepResult.attempts = [...previousAttempts, interactiveAttempt]; stepResult.children = retryResult.steps; stepResult.state = retryResult.state === 'completed' ? 'finished' : 'failed'; stepResult.durationMs = durationMs; if (retryResult.state === 'completed') {
|
|
71
|
+
stepResult.outputs = retryResult.steps.at(-1)?.outputs ?? {};
|
|
72
|
+
delete stepResult.error;
|
|
73
|
+
stepResult.terminalReason = 'completed';
|
|
74
|
+
if (run.result.state === 'failed') {
|
|
75
|
+
run.result.state = 'completed';
|
|
76
|
+
run.result.terminalReason = 'completed';
|
|
77
|
+
delete run.result.error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
stepResult.error = retryResult.error;
|
|
82
|
+
stepResult.terminalReason = 'failed';
|
|
83
|
+
} this.emit(run, 'step_finished', retryResult.state === 'completed' ? 'finished' : 'failed', `${step.name} retry attempt ${attempt} ${retryResult.state === 'completed' ? 'finished' : 'failed'}.`, step); this.emit(run, 'waiting', 'waiting', `Interactive Operation retry attempt ${attempt} ${retryResult.state === 'completed' ? 'completed' : 'failed'}.`, step, undefined, undefined, { type: 'retry-attempt-completed', stepId }); }
|
|
84
|
+
async runFromHere(requestId, stepId, invocationPath) { const run = this.runs.get(requestId); if (!run?.result)
|
|
85
|
+
return undefined; const step = findExecutionStep(run.request.executionPackage.steps, stepId, invocationPath); const selected = findStepResult(run.result.steps, stepId, invocationPath); if (!step || !selected || selected.state === 'skipped')
|
|
86
|
+
return undefined; const available = new Map(); for (const item of flattenStepResults(run.result.steps))
|
|
87
|
+
for (const [name, value] of Object.entries(item.outputs))
|
|
88
|
+
available.set(`${item.id}.${name}`, value); const staticInputs = { ...(step.staticInputs ?? {}) }; for (const [name, reference] of Object.entries(step.bindings ?? {})) {
|
|
89
|
+
const match = /^\$steps\.([^.]+)\.(.+)$/.exec(reference);
|
|
90
|
+
if (match) {
|
|
91
|
+
const value = available.get(`${match[1]}.${match[2]}`);
|
|
92
|
+
if (value === undefined)
|
|
93
|
+
return undefined;
|
|
94
|
+
staticInputs[name] = value;
|
|
95
|
+
}
|
|
96
|
+
} const targetedStep = { ...step, bindings: undefined, staticInputs }; const targetedPackage = { ...run.request.executionPackage, target: { kind: 'flow', id: `${run.request.executionPackage.target.id}:from:${step.id}`, name: `${run.request.executionPackage.target.name} · ${step.name}` }, steps: step.kind === 'operation' ? step.children ?? [targetedStep] : [targetedStep], flows: undefined, requiredInputs: [] }; const targetedRequestId = `${requestId}:from:${step.id}:${Date.now()}`; await this.submit({ requestId: targetedRequestId, executionPackage: targetedPackage, inputs: run.request.inputs, environment: run.request.environment, runtimeValues: run.request.runtimeValues, options: run.request.options }); const targetRun = this.runs.get(targetedRequestId); if (targetRun)
|
|
97
|
+
void this.result(targetedRequestId).then(result => { result.provenance = { kind: 'run-from-here', sourceRequestId: requestId, sourceArtifactHash: run.result?.artifactHash, selectedStepId: step.id, ...(selected.invocationPath ? { selectedInvocationPath: selected.invocationPath } : {}), omittedStepIds: run.request.executionPackage.steps.flatMap(item => item.id === step.id ? [] : [item.id]) }; }); return { requestId: targetedRequestId }; }
|
|
98
|
+
controlState(requestId) { return this.runs.get(requestId)?.controlState; }
|
|
99
|
+
debugSession(requestId) { const session = this.runs.get(requestId)?.debugSession; return session ? { ...session, breakpoints: [...session.breakpoints] } : undefined; }
|
|
100
|
+
async *events(requestId) {
|
|
101
|
+
const run = this.runs.get(requestId);
|
|
102
|
+
if (!run)
|
|
103
|
+
return;
|
|
104
|
+
let index = 0;
|
|
105
|
+
while (true) {
|
|
106
|
+
while (index < run.events.length)
|
|
107
|
+
yield run.events[index++];
|
|
108
|
+
if (run.result)
|
|
109
|
+
return;
|
|
110
|
+
await new Promise(resolve => run.listeners.push(resolve));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async result(requestId) {
|
|
114
|
+
const run = this.runs.get(requestId);
|
|
115
|
+
if (!run)
|
|
116
|
+
throw new Error(`Unknown request: ${requestId}`);
|
|
117
|
+
if (run.result)
|
|
118
|
+
return run.result;
|
|
119
|
+
return new Promise(resolve => { const previous = run.resultResolve; run.resultResolve = result => { previous(result); resolve(result); }; });
|
|
120
|
+
}
|
|
121
|
+
async execute(run, resultPromise) {
|
|
122
|
+
const { request } = run;
|
|
123
|
+
const startedAt = new Date().toISOString();
|
|
124
|
+
run.controlState = 'running';
|
|
125
|
+
this.emit(run, 'run_started', 'created', 'Run accepted.');
|
|
126
|
+
this.emit(run, 'preflight_started', 'preflighting', 'Validating package and runtime inputs.');
|
|
127
|
+
if (!await this.pause(run, 250)) {
|
|
128
|
+
this.finishCancelled(run, startedAt, []);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (request.options?.timeoutMs !== undefined && (request.options.timeoutMs <= 0 || request.options.timeoutMs > MAX_EXECUTION_TIMEOUT_MS)) {
|
|
132
|
+
this.finish(run, { requestId: request.requestId, state: 'failed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps: [], error: { code: 'EXECUTION_BUDGET_EXCEEDED', message: `Execution timeout must be between 1ms and ${MAX_EXECUTION_TIMEOUT_MS}ms.` } });
|
|
133
|
+
await resultPromise;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const validationIssues = this.validateAll(request.executionPackage, request.inputs, request.runtimeValues ?? {});
|
|
137
|
+
const validationWarnings = this.validateBrowserWarnings(request.executionPackage);
|
|
138
|
+
if (request.options?.dryRun) {
|
|
139
|
+
const dryRunResult = { requestId: request.requestId, state: validationIssues.length ? 'failed' : 'completed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps: [], terminalReason: validationIssues.length ? 'failed' : 'completed', issues: validationIssues, warnings: validationWarnings, ...(validationIssues.length ? { error: { code: 'DRY_RUN_ISSUES', message: `${validationIssues.length} issue${validationIssues.length === 1 ? '' : 's'} found during dry run.` } } : {}) };
|
|
140
|
+
this.finish(run, dryRunResult);
|
|
141
|
+
await resultPromise;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const validationError = validationIssues.find(issue => !['INVALID_BINDING', 'MISSING_INPUT_BINDING', 'MISSING_STEP_BINDING'].includes(issue.code));
|
|
145
|
+
if (validationError) {
|
|
146
|
+
this.finish(run, { requestId: request.requestId, state: 'failed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps: [], warnings: validationWarnings, error: validationError });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const steps = [];
|
|
150
|
+
const stepOutputs = {};
|
|
151
|
+
const failedStepIds = new Set();
|
|
152
|
+
const loopStartedAt = new Map();
|
|
153
|
+
let plannedSteps;
|
|
154
|
+
try {
|
|
155
|
+
plannedSteps = this.orderSteps(this.flattenSteps(request.executionPackage.steps, [], 0, false, undefined, undefined, undefined, request.inputs));
|
|
156
|
+
}
|
|
157
|
+
catch (cause) {
|
|
158
|
+
const error = { code: cause instanceof Error && cause.message.startsWith('Graph') ? 'INVALID_GRAPH' : 'RECURSION_LIMIT', message: cause instanceof Error ? cause.message : 'The execution graph is invalid.' };
|
|
159
|
+
this.finish(run, { requestId: request.requestId, state: 'failed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps: [], error });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (let plannedIndex = 0; plannedIndex < plannedSteps.length; plannedIndex += 1) {
|
|
163
|
+
const planned = plannedSteps[plannedIndex];
|
|
164
|
+
if (!await this.waitForSafeBoundary(run, planned.step, planned.path)) {
|
|
165
|
+
this.finishCancelled(run, startedAt, steps);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (planned.parallelGroup) {
|
|
169
|
+
const group = [];
|
|
170
|
+
while (plannedIndex < plannedSteps.length && plannedSteps[plannedIndex].parallelGroup === planned.parallelGroup)
|
|
171
|
+
group.push(plannedSteps[plannedIndex++]);
|
|
172
|
+
plannedIndex -= 1;
|
|
173
|
+
// Parallel execution uses a run-level cooperative policy: inspect every
|
|
174
|
+
// branch before starting any adapter. A breakpoint in any branch pauses
|
|
175
|
+
// the whole group at this safe boundary.
|
|
176
|
+
for (const candidate of group.slice(1)) {
|
|
177
|
+
if (!await this.waitForSafeBoundary(run, candidate.step, candidate.path)) {
|
|
178
|
+
this.finishCancelled(run, startedAt, steps);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const parallel = await this.executeParallelGroup(run, group, request, stepOutputs, failedStepIds);
|
|
183
|
+
steps.push(...parallel.results);
|
|
184
|
+
for (const item of parallel.outputs)
|
|
185
|
+
stepOutputs[item.id] = item.outputs;
|
|
186
|
+
if (parallel.mergedOutputs)
|
|
187
|
+
stepOutputs[planned.parallelGroup] = parallel.mergedOutputs;
|
|
188
|
+
for (const item of parallel.failed)
|
|
189
|
+
failedStepIds.add(item);
|
|
190
|
+
if (parallel.cancelled) {
|
|
191
|
+
this.finishCancelled(run, startedAt, steps);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const stopAfterStep = parallel.results.find(result => result.state === 'finished' && result.id === run.debugSession.stopAfterStepId);
|
|
195
|
+
if (stopAfterStep) {
|
|
196
|
+
const plannedStopAfter = group.find(item => item.step.id === stopAfterStep.id);
|
|
197
|
+
if (plannedStopAfter && !await this.pauseAfterStopAfter(run, plannedStopAfter.step)) {
|
|
198
|
+
this.finishCancelled(run, startedAt, steps);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const terminalFailure = parallel.errors.find(item => !item.continueOnError);
|
|
203
|
+
if (terminalFailure) {
|
|
204
|
+
this.finish(run, { requestId: request.requestId, state: 'failed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps, error: terminalFailure.error });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const { step } = planned;
|
|
210
|
+
const invocationId = `${request.requestId}:${planned.path.join('.')}`;
|
|
211
|
+
if (run.cancelled) {
|
|
212
|
+
this.finish(run, { requestId: request.requestId, state: 'cancelled', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (planned.guards?.some(condition => !conditionMatches(condition, request.inputs, stepOutputs))) {
|
|
216
|
+
steps.push({ id: step.id, name: step.name, state: 'skipped', terminalReason: 'condition-skipped', inputs: {}, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (step.dependsOn?.some(dependency => failedStepIds.has(dependency))) {
|
|
220
|
+
steps.push({ id: step.id, name: step.name, state: 'skipped', terminalReason: 'dependency-skipped', inputs: {}, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (planned.iteration && planned.loopDurationMs !== undefined) {
|
|
224
|
+
const loopStarted = loopStartedAt.get(planned.iteration.loopId) ?? Date.now();
|
|
225
|
+
loopStartedAt.set(planned.iteration.loopId, loopStarted);
|
|
226
|
+
if (Date.now() - loopStarted >= planned.loopDurationMs)
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (planned.iteration && planned.iteration.current > 1 && planned.iterationDelayMs && !await this.pause(run, planned.iterationDelayMs)) {
|
|
230
|
+
this.finishCancelled(run, startedAt, steps);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const stepInputs = this.resolveInputs(step, request.inputs, stepOutputs);
|
|
234
|
+
if (!stepInputs) {
|
|
235
|
+
const error = { code: 'BINDING_FAILED', message: `Bindings for ${step.name} could not be resolved.` };
|
|
236
|
+
steps.push({ id: step.id, name: step.name, state: 'failed', inputs: {}, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration, error });
|
|
237
|
+
failedStepIds.add(step.id);
|
|
238
|
+
if (!planned.continueOnError) {
|
|
239
|
+
this.finish(run, { requestId: request.requestId, state: 'failed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps, error });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const stepStarted = Date.now();
|
|
245
|
+
this.emit(run, 'step_started', 'running', `${step.name} started.`, step, planned.iteration);
|
|
246
|
+
if (!await this.pause(run, 450)) {
|
|
247
|
+
this.finishCancelled(run, startedAt, steps);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.emit(run, 'waiting', 'waiting', `${step.name} waiting for its Action adapter.`, step, planned.iteration);
|
|
251
|
+
if (!await this.pause(run, 450)) {
|
|
252
|
+
this.finishCancelled(run, startedAt, steps);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const adapter = this.adapters.find(candidate => candidate.supports(step.action));
|
|
256
|
+
if (!adapter) {
|
|
257
|
+
const error = { code: 'MISSING_ACTION_ADAPTER', message: `No adapter is registered for ${step.action}.` };
|
|
258
|
+
steps.push({ id: step.id, name: step.name, state: 'failed', inputs: stepInputs, outputs: {}, durationMs: Date.now() - stepStarted, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration, error });
|
|
259
|
+
failedStepIds.add(step.id);
|
|
260
|
+
if (!planned.continueOnError) {
|
|
261
|
+
this.finish(run, { requestId: request.requestId, state: 'failed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps, error });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const maxAttempts = Math.max(1, step.retry?.maxAttempts ?? 1);
|
|
267
|
+
const transportStartedAt = new Date().toISOString();
|
|
268
|
+
const transportStartedMs = Date.now();
|
|
269
|
+
let outputs;
|
|
270
|
+
let lastError;
|
|
271
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
272
|
+
try {
|
|
273
|
+
outputs = await adapter.execute(withRuntimeValues({ action: step.action, inputs: stepInputs, environment: request.environment, step, signal: run.abortController.signal }, request.runtimeValues));
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
catch (cause) {
|
|
277
|
+
lastError = cause;
|
|
278
|
+
if (run.cancelled || run.timedOut) {
|
|
279
|
+
this.finishCancelled(run, startedAt, steps);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (attempt < maxAttempts) {
|
|
283
|
+
this.emit(run, 'waiting', 'waiting', `${step.name} retrying (${attempt + 1}/${maxAttempts}).`, step, planned.iteration);
|
|
284
|
+
if (!await this.pause(run, step.retry?.delayMs ?? 100)) {
|
|
285
|
+
this.finishCancelled(run, startedAt, steps);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!outputs) {
|
|
292
|
+
const error = { code: isAdapterError(lastError) ? lastError.code : 'ACTION_FAILED', message: lastError instanceof Error ? lastError.message : `Action ${step.action} failed.` };
|
|
293
|
+
steps.push({ id: step.id, name: step.name, state: 'failed', inputs: stepInputs, outputs: {}, durationMs: Date.now() - stepStarted, transportTiming: { requestStartedAt: transportStartedAt, responseReceivedAt: new Date().toISOString(), durationMs: Date.now() - transportStartedMs }, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration, error });
|
|
294
|
+
failedStepIds.add(step.id);
|
|
295
|
+
if (!planned.continueOnError) {
|
|
296
|
+
this.finish(run, { requestId: request.requestId, state: 'failed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps, error });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (step.delayMs && !await this.pause(run, step.delayMs)) {
|
|
302
|
+
this.finishCancelled(run, startedAt, steps);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
stepOutputs[step.id] = outputs;
|
|
306
|
+
steps.push({ id: step.id, name: step.name, state: 'finished', inputs: stepInputs, outputs, durationMs: Date.now() - stepStarted, transportTiming: { requestStartedAt: transportStartedAt, responseReceivedAt: new Date().toISOString(), durationMs: Date.now() - transportStartedMs }, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration });
|
|
307
|
+
this.emit(run, 'step_finished', 'finished', `${step.name} finished.`, step, planned.iteration);
|
|
308
|
+
if (!await this.pauseAfterStopAfter(run, step)) {
|
|
309
|
+
this.finishCancelled(run, startedAt, steps);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
this.finish(run, { requestId: request.requestId, state: 'completed', target: request.executionPackage.target, environment: request.environment, startedAt, finishedAt: new Date().toISOString(), steps: this.normalizeResults(steps, request.executionPackage.steps), warnings: validationWarnings });
|
|
314
|
+
await resultPromise;
|
|
315
|
+
}
|
|
316
|
+
validateAll(pack, inputs, runtimeValues) {
|
|
317
|
+
const issues = [];
|
|
318
|
+
if (pack.schemaVersion !== RUNNER_PACKAGE_SCHEMA_VERSION)
|
|
319
|
+
issues.push({ code: 'UNSUPPORTED_SCHEMA', message: `Schema ${pack.schemaVersion} is not supported.` });
|
|
320
|
+
if (!pack.steps.length)
|
|
321
|
+
issues.push({ code: 'EMPTY_TARGET', message: 'The selected target contains no executable steps.' });
|
|
322
|
+
for (const step of walkSteps(pack.steps)) {
|
|
323
|
+
for (const issue of unsupportedControlSettings(step))
|
|
324
|
+
issues.push({ ...issue, stepId: step.id });
|
|
325
|
+
}
|
|
326
|
+
const requiredInputs = pack.requiredInputs ?? ['customerId'];
|
|
327
|
+
for (const name of requiredInputs.filter(name => inputs[name] === undefined || inputs[name] === null || inputs[name] === ''))
|
|
328
|
+
issues.push({ code: 'MISSING_INPUT', message: `Required input ${name} is missing.` });
|
|
329
|
+
for (const missingRuntime of (pack.runtimeRequirements ?? []).filter(name => runtimeValues[name] === undefined || runtimeValues[name] === null || runtimeValues[name] === ''))
|
|
330
|
+
issues.push({ code: 'MISSING_RUNTIME_VALUE', message: `Required runtime value ${missingRuntime} is missing.` });
|
|
331
|
+
let planned = [];
|
|
332
|
+
try {
|
|
333
|
+
planned = this.flattenSteps(pack.steps);
|
|
334
|
+
}
|
|
335
|
+
catch (cause) {
|
|
336
|
+
const message = cause instanceof Error ? cause.message : 'The execution graph is invalid.';
|
|
337
|
+
issues.push({ code: message.startsWith('Nested execution depth') ? 'RECURSION_LIMIT' : 'INVALID_GRAPH', message });
|
|
338
|
+
}
|
|
339
|
+
const graphStepIds = new Set();
|
|
340
|
+
const graphStepsById = new Map();
|
|
341
|
+
const collectGraphStepIds = (steps) => steps.forEach(step => { graphStepIds.add(step.id); graphStepsById.set(step.id, step); if (step.children)
|
|
342
|
+
collectGraphStepIds(step.children); if (step.control?.branches)
|
|
343
|
+
step.control.branches.forEach(branch => collectGraphStepIds(branch.children)); });
|
|
344
|
+
collectGraphStepIds(pack.steps);
|
|
345
|
+
for (const step of walkSteps(pack.steps))
|
|
346
|
+
if (step.control?.type === 'parallel' && (step.control.branches?.length ?? 0) > MAX_PARALLEL_BRANCHES)
|
|
347
|
+
issues.push({ code: 'PARALLEL_LIMIT_EXCEEDED', message: `Parallel Control Block ${step.name} has ${step.control.branches.length} branches; the maximum is ${MAX_PARALLEL_BRANCHES}.`, stepId: step.id });
|
|
348
|
+
for (const item of planned) {
|
|
349
|
+
if (!pack.definitions.some(definition => definition.kind === 'action' && definition.id === item.step.action) && item.step.kind !== 'control-block')
|
|
350
|
+
issues.push({ code: 'MISSING_ACTION_DEFINITION', message: `Action definition ${item.step.action} is not present in the package.`, stepId: item.step.id });
|
|
351
|
+
for (const [name, reference] of Object.entries(item.step.bindings ?? {})) {
|
|
352
|
+
const match = /^\$(inputs|steps)\.([^.]+)(?:\.(.+))?$/.exec(reference);
|
|
353
|
+
if (!match)
|
|
354
|
+
issues.push({ code: 'INVALID_BINDING', message: `Binding ${name} on ${item.step.name} is invalid.`, stepId: item.step.id });
|
|
355
|
+
else if (match[1] === 'inputs' && (inputs[match[2]] === undefined || inputs[match[2]] === null || inputs[match[2]] === ''))
|
|
356
|
+
issues.push({ code: 'MISSING_INPUT_BINDING', message: `Binding ${name} on ${item.step.name} requires input ${match[2]}.`, stepId: item.step.id });
|
|
357
|
+
else if (match[1] === 'steps' && !graphStepIds.has(match[2]))
|
|
358
|
+
issues.push({ code: 'MISSING_STEP_BINDING', message: `Binding ${name} on ${item.step.name} references step ${match[2]}, but that step is not present in the loaded execution graph. Check for a stale Studio node ID, a deleted or moved node, or a binding that crosses an Operation/loop scope.`, stepId: item.step.id });
|
|
359
|
+
else if (match[1] === 'steps' && match[3] && !Object.prototype.hasOwnProperty.call(graphStepsById.get(match[2])?.output ?? {}, match[3]))
|
|
360
|
+
issues.push({ code: 'MISSING_STEP_OUTPUT', message: `Binding ${name} on ${item.step.name} requests output ${match[3]} from step ${match[2]}, but that output is not declared by the source step.`, stepId: item.step.id });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return [...new Map(issues.map(issue => [`${issue.code}|${issue.stepId ?? ''}|${issue.message}`, issue])).values()];
|
|
364
|
+
}
|
|
365
|
+
validateBrowserWarnings(pack) {
|
|
366
|
+
const warnings = [];
|
|
367
|
+
for (const step of walkSteps(pack.steps)) {
|
|
368
|
+
if (!step.action.startsWith('http.'))
|
|
369
|
+
continue;
|
|
370
|
+
warnings.push({ code: 'BROWSER_CORS_REQUIRED', message: `HTTP Action ${step.name} runs in the browser and requires the target API to allow CORS.`, stepId: step.id });
|
|
371
|
+
const headers = step.staticInputs?.headers;
|
|
372
|
+
const hasCredentialHeader = headers && typeof headers === 'object' && Object.keys(headers).some(name => /(authorization|token|secret|password|api[-_]?key|cookie)/i.test(name));
|
|
373
|
+
const hasCredentialTemplate = JSON.stringify(step.staticInputs ?? {}).match(/\{\{\s*\$?(?:inputs\.)?(?:authorization|token|secret|password|api[-_]?key|cookie)/i);
|
|
374
|
+
if (hasCredentialHeader || hasCredentialTemplate)
|
|
375
|
+
warnings.push({ code: 'BROWSER_CREDENTIAL_EXPOSURE', message: `HTTP Action ${step.name} sends credential-like data from the browser; credentials may be exposed to page and network tooling.`, stepId: step.id });
|
|
376
|
+
}
|
|
377
|
+
return warnings;
|
|
378
|
+
}
|
|
379
|
+
flattenSteps(steps, parentPath = [], depth = 0, continueOnError = false, iteration, loopDurationMs, iterationDelayMs, inputs = {}, guards = []) {
|
|
380
|
+
if (depth > 32)
|
|
381
|
+
throw new Error('Nested execution depth exceeded.');
|
|
382
|
+
return steps.flatMap(step => {
|
|
383
|
+
if ((step.control?.type === 'condition' || step.control?.type === 'repeat') && step.control.condition === false)
|
|
384
|
+
return [];
|
|
385
|
+
const nestedChildren = step.children;
|
|
386
|
+
if (!nestedChildren?.length)
|
|
387
|
+
return [{ step, path: [...parentPath, step.id], continueOnError, iteration, loopDurationMs: loopDurationMs ?? step.control?.durationMs, iterationDelayMs: iterationDelayMs ?? step.control?.iterationDelayMs, guards }];
|
|
388
|
+
const childContinueOnError = continueOnError || step.control?.onError === 'continue';
|
|
389
|
+
const conditionGuards = step.control?.type === 'condition' && step.control.condition !== undefined ? [...guards, step.control.condition] : guards;
|
|
390
|
+
if (step.control?.type === 'repeat') {
|
|
391
|
+
const iterations = Math.max(1, Math.min(step.control.iterations ?? (step.control.durationMs !== undefined ? 32 : 1), 32));
|
|
392
|
+
const repeatGuards = step.control.condition === undefined ? conditionGuards : [...conditionGuards, step.control.condition];
|
|
393
|
+
return Array.from({ length: iterations }, (_, index) => this.flattenSteps(nestedChildren, [...parentPath, `${step.id}[${index + 1}]`], depth + 1, childContinueOnError, { loopId: step.id, current: index + 1, total: iterations }, step.control?.durationMs, step.control?.iterationDelayMs, inputs, repeatGuards)).flat();
|
|
394
|
+
}
|
|
395
|
+
if ((step.control?.type === 'branch' || step.control?.type === 'parallel') && step.control.branches?.length) {
|
|
396
|
+
const branches = step.control.branches;
|
|
397
|
+
const parallelGroup = step.control.type === 'parallel' ? step.id : undefined;
|
|
398
|
+
return branches.flatMap(branch => this.flattenStepsWithParallel(branch.children, [...parentPath, `${step.id}.${branch.id}`], depth + 1, childContinueOnError, iteration, loopDurationMs, iterationDelayMs, inputs, [...conditionGuards, branch.condition], parallelGroup, step.control?.join ?? 'all', step.control?.merge ?? (step.control?.join === 'first' ? 'first' : 'collect'), step.control?.mergeBindings));
|
|
399
|
+
}
|
|
400
|
+
return this.flattenSteps(nestedChildren, [...parentPath, step.id], depth + 1, childContinueOnError, iteration, loopDurationMs, iterationDelayMs, inputs, conditionGuards);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
flattenStepsWithParallel(steps, parentPath, depth, continueOnError, iteration, loopDurationMs, iterationDelayMs, inputs, guards, parallelGroup, parallelJoin, parallelMerge, parallelMergeBindings) {
|
|
404
|
+
return this.flattenSteps(steps, parentPath, depth, continueOnError, iteration, loopDurationMs, iterationDelayMs, inputs, guards).map(planned => ({ ...planned, parallelGroup, parallelJoin, parallelMerge, parallelMergeBindings }));
|
|
405
|
+
}
|
|
406
|
+
async executeParallelGroup(run, group, request, stepOutputs, failedStepIds) {
|
|
407
|
+
const groupId = group[0].parallelGroup ?? 'parallel';
|
|
408
|
+
const join = group[0].parallelJoin ?? 'all';
|
|
409
|
+
const controllers = new Map();
|
|
410
|
+
let winnerPath;
|
|
411
|
+
this.emit(run, 'waiting', 'waiting', `Parallel group ${groupId} started with ${group.length} branches.`, undefined, undefined, { groupId, completed: 0, total: group.length, join });
|
|
412
|
+
const outcomes = await Promise.all(group.map(async (planned) => {
|
|
413
|
+
const { step } = planned;
|
|
414
|
+
const invocationId = `${request.requestId}:${planned.path.join('.')}`;
|
|
415
|
+
const pathKey = planned.path.join('.');
|
|
416
|
+
const controller = new AbortController();
|
|
417
|
+
controllers.set(pathKey, controller);
|
|
418
|
+
const abortFromRun = () => controller.abort();
|
|
419
|
+
run.abortController.signal.addEventListener('abort', abortFromRun, { once: true });
|
|
420
|
+
if (planned.guards?.some(condition => !conditionMatches(condition, request.inputs, stepOutputs)))
|
|
421
|
+
return { result: { id: step.id, name: step.name, state: 'skipped', terminalReason: 'condition-skipped', inputs: {}, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: false, continueOnError: planned.continueOnError };
|
|
422
|
+
if (step.dependsOn?.some(dependency => failedStepIds.has(dependency)))
|
|
423
|
+
return { result: { id: step.id, name: step.name, state: 'skipped', terminalReason: 'dependency-skipped', inputs: {}, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: false, continueOnError: planned.continueOnError };
|
|
424
|
+
const stepInputs = this.resolveInputs(step, request.inputs, stepOutputs);
|
|
425
|
+
if (!stepInputs) {
|
|
426
|
+
const error = { code: 'BINDING_FAILED', message: `Bindings for ${step.name} could not be resolved.` };
|
|
427
|
+
return { result: { id: step.id, name: step.name, state: 'failed', inputs: {}, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration, error }, output: undefined, failed: true, error, cancelled: false, continueOnError: planned.continueOnError };
|
|
428
|
+
}
|
|
429
|
+
this.emit(run, 'step_started', 'running', `${step.name} started.`, step, planned.iteration);
|
|
430
|
+
if (!await this.pause(run, 450))
|
|
431
|
+
return { result: { id: step.id, name: step.name, state: 'cancelled', terminalReason: run.timedOut ? 'timeout' : 'cancelled', inputs: stepInputs, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: true, continueOnError: planned.continueOnError };
|
|
432
|
+
this.emit(run, 'waiting', 'waiting', `${step.name} waiting for its Action adapter.`, step, planned.iteration);
|
|
433
|
+
if (!await this.pause(run, 450))
|
|
434
|
+
return { result: { id: step.id, name: step.name, state: 'cancelled', terminalReason: run.timedOut ? 'timeout' : 'cancelled', inputs: stepInputs, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: true, continueOnError: planned.continueOnError };
|
|
435
|
+
const adapter = this.adapters.find(candidate => candidate.supports(step.action));
|
|
436
|
+
if (!adapter) {
|
|
437
|
+
const error = { code: 'MISSING_ACTION_ADAPTER', message: `No adapter is registered for ${step.action}.` };
|
|
438
|
+
return { result: { id: step.id, name: step.name, state: 'failed', inputs: stepInputs, outputs: {}, durationMs: 0, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration, error }, output: undefined, failed: true, error, cancelled: false, continueOnError: planned.continueOnError };
|
|
439
|
+
}
|
|
440
|
+
const startedAt = Date.now();
|
|
441
|
+
const transportStartedAt = new Date().toISOString();
|
|
442
|
+
let outputs;
|
|
443
|
+
let lastError;
|
|
444
|
+
const maxAttempts = Math.max(1, step.retry?.maxAttempts ?? 1);
|
|
445
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
446
|
+
try {
|
|
447
|
+
outputs = await adapter.execute(withRuntimeValues({ action: step.action, inputs: stepInputs, environment: request.environment, step, signal: controller.signal }, request.runtimeValues));
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
catch (cause) {
|
|
451
|
+
lastError = cause;
|
|
452
|
+
if (run.cancelled || run.timedOut || controller.signal.aborted)
|
|
453
|
+
return { result: { id: step.id, name: step.name, state: 'cancelled', terminalReason: run.timedOut ? 'timeout' : 'cancelled', inputs: stepInputs, outputs: {}, durationMs: Date.now() - startedAt, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: true, continueOnError: planned.continueOnError };
|
|
454
|
+
if (attempt < maxAttempts) {
|
|
455
|
+
this.emit(run, 'waiting', 'waiting', `${step.name} retrying (${attempt + 1}/${maxAttempts}).`, step, planned.iteration);
|
|
456
|
+
if (!await this.pause(run, step.retry?.delayMs ?? 100))
|
|
457
|
+
return { result: { id: step.id, name: step.name, state: 'cancelled', terminalReason: run.timedOut ? 'timeout' : 'cancelled', inputs: stepInputs, outputs: {}, durationMs: Date.now() - startedAt, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: true, continueOnError: planned.continueOnError };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (!outputs) {
|
|
462
|
+
const error = { code: 'ACTION_FAILED', message: lastError instanceof Error ? lastError.message : `Action ${step.action} failed.` };
|
|
463
|
+
return { result: { id: step.id, name: step.name, state: 'failed', inputs: stepInputs, outputs: {}, durationMs: Date.now() - startedAt, transportTiming: { requestStartedAt: transportStartedAt, responseReceivedAt: new Date().toISOString(), durationMs: Date.now() - startedAt }, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration, error }, output: undefined, failed: true, error, cancelled: false, continueOnError: planned.continueOnError };
|
|
464
|
+
}
|
|
465
|
+
if (step.delayMs && !await this.pause(run, step.delayMs))
|
|
466
|
+
return { result: { id: step.id, name: step.name, state: 'cancelled', terminalReason: run.timedOut ? 'timeout' : 'cancelled', inputs: stepInputs, outputs: {}, durationMs: Date.now() - startedAt, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: true, continueOnError: planned.continueOnError };
|
|
467
|
+
if (join === 'first' && winnerPath === undefined) {
|
|
468
|
+
winnerPath = pathKey;
|
|
469
|
+
controllers.forEach((candidate, candidatePath) => { if (candidatePath !== pathKey)
|
|
470
|
+
candidate.abort(); });
|
|
471
|
+
}
|
|
472
|
+
if (join === 'first' && winnerPath !== pathKey)
|
|
473
|
+
return { result: { id: step.id, name: step.name, state: 'cancelled', terminalReason: 'parallel-joined', inputs: stepInputs, outputs: {}, durationMs: Date.now() - startedAt, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: undefined, failed: false, error: undefined, cancelled: false, continueOnError: planned.continueOnError };
|
|
474
|
+
this.emit(run, 'step_finished', 'finished', `${step.name} finished.`, step, planned.iteration);
|
|
475
|
+
return { result: { id: step.id, name: step.name, state: 'finished', inputs: stepInputs, outputs, durationMs: Date.now() - startedAt, transportTiming: { requestStartedAt: transportStartedAt, responseReceivedAt: new Date().toISOString(), durationMs: Date.now() - startedAt }, invocationId, invocationPath: planned.path, kind: step.kind ?? 'action', iteration: planned.iteration }, output: outputs, failed: false, error: undefined, cancelled: false, continueOnError: planned.continueOnError };
|
|
476
|
+
}));
|
|
477
|
+
const settled = join === 'first' && winnerPath ? outcomes.filter(outcome => outcome.result.invocationPath?.join('.') === winnerPath) : outcomes;
|
|
478
|
+
this.emit(run, 'waiting', 'waiting', `Parallel group ${groupId} joined after ${join === 'first' ? 'the first branch' : 'all branches'} settled.`, undefined, undefined, { groupId, completed: settled.length, total: group.length, join });
|
|
479
|
+
const mergedOutputs = mergeParallelOutputs(settled, groupId, group[0].parallelMerge ?? (join === 'first' ? 'first' : 'collect'), group[0].parallelMergeBindings);
|
|
480
|
+
return { results: outcomes.map(outcome => outcome.result), outputs: settled.flatMap(outcome => outcome.output ? [{ id: outcome.result.id, outputs: outcome.output }] : []), mergedOutputs, failed: settled.flatMap(outcome => outcome.failed ? [outcome.result.id] : []), errors: settled.flatMap(outcome => outcome.error ? [{ continueOnError: outcome.continueOnError, error: outcome.error }] : []), cancelled: settled.some(outcome => outcome.cancelled) };
|
|
481
|
+
}
|
|
482
|
+
orderSteps(planned) {
|
|
483
|
+
const byId = new Map();
|
|
484
|
+
for (const item of planned) {
|
|
485
|
+
const existing = byId.get(item.step.id);
|
|
486
|
+
if (existing && existing.path.join('.') === item.path.join('.'))
|
|
487
|
+
throw new Error(`Graph contains duplicate step id ${item.step.id}.`);
|
|
488
|
+
if (!existing)
|
|
489
|
+
byId.set(item.step.id, item);
|
|
490
|
+
}
|
|
491
|
+
const ordered = [];
|
|
492
|
+
const remaining = [...planned];
|
|
493
|
+
while (remaining.length) {
|
|
494
|
+
const nextIndex = remaining.findIndex(item => (item.step.dependsOn ?? []).every(dependency => byId.has(dependency) && ordered.some(done => done.step.id === dependency)));
|
|
495
|
+
if (nextIndex < 0) {
|
|
496
|
+
const missing = remaining.flatMap(item => item.step.dependsOn ?? []).find(dependency => !byId.has(dependency));
|
|
497
|
+
throw new Error(missing ? `Graph dependency ${missing} is missing.` : 'Graph contains a dependency cycle.');
|
|
498
|
+
}
|
|
499
|
+
ordered.push(remaining.splice(nextIndex, 1)[0]);
|
|
500
|
+
}
|
|
501
|
+
return ordered;
|
|
502
|
+
}
|
|
503
|
+
normalizeResults(results, definitions, prefix = []) {
|
|
504
|
+
if (definitions.every(definition => !definition.children?.length)) {
|
|
505
|
+
const paths = new Set(definitions.map(definition => [...prefix, definition.id].join('.')));
|
|
506
|
+
return results.filter(result => result.invocationPath && paths.has(result.invocationPath.join('.')));
|
|
507
|
+
}
|
|
508
|
+
return definitions.flatMap(definition => {
|
|
509
|
+
const path = [...prefix, definition.id];
|
|
510
|
+
if ((definition.control?.type === 'branch' || definition.control?.type === 'parallel') && definition.control.branches?.length) {
|
|
511
|
+
const branches = definition.control.branches.flatMap(branch => {
|
|
512
|
+
const branchPath = [...prefix, `${definition.id}.${branch.id}`];
|
|
513
|
+
const branchChildren = this.normalizeResults(results, branch.children, branchPath);
|
|
514
|
+
return branchChildren.length ? [{ id: branch.id, name: branch.name ?? branch.id, kind: 'control-block', state: branchChildren.every(child => child.state === 'finished') ? 'finished' : 'failed', inputs: {}, outputs: branchChildren.at(-1)?.outputs ?? {}, durationMs: branchChildren.reduce((total, child) => total + child.durationMs, 0), invocationPath: branchPath, children: branchChildren }] : [];
|
|
515
|
+
});
|
|
516
|
+
const merge = definition.control.merge ?? (definition.control.join === 'first' ? 'first' : 'collect');
|
|
517
|
+
const mergedOutputs = mergeBranchResultOutputs(branches, definition.control.mergeBindings, merge);
|
|
518
|
+
return [{ id: definition.id, name: definition.name, kind: definition.kind ?? 'control-block', state: branches.every(branch => branch.state === 'finished') ? 'finished' : 'failed', terminalReason: branches.every(branch => branch.state === 'finished') ? definition.control.type === 'parallel' ? 'parallel-joined' : 'completed' : 'continued-after-failure', inputs: {}, outputs: mergedOutputs, durationMs: branches.reduce((total, branch) => total + branch.durationMs, 0), invocationPath: path, children: branches }];
|
|
519
|
+
}
|
|
520
|
+
if (definition.children?.length) {
|
|
521
|
+
const iterationGroups = definition.control?.type === 'repeat'
|
|
522
|
+
? [...new Set(results.map(result => result.invocationPath?.slice(prefix.length, prefix.length + 1)[0]).filter(value => value?.startsWith(`${definition.id}[`)))]
|
|
523
|
+
: [];
|
|
524
|
+
const children = iterationGroups.length
|
|
525
|
+
? iterationGroups.map((iterationPath, index) => ({ id: iterationPath, name: `Iteration ${index + 1}`, kind: 'control-block', state: 'finished', inputs: {}, outputs: {}, durationMs: 0, invocationPath: [...prefix, iterationPath], iteration: { loopId: definition.id, current: index + 1, total: iterationGroups.length }, children: this.normalizeResults(results, definition.children, [...prefix, iterationPath]) }))
|
|
526
|
+
: this.normalizeResults(results, definition.children, path);
|
|
527
|
+
const durationMs = children.reduce((total, child) => total + child.durationMs, 0);
|
|
528
|
+
const last = children.at(-1);
|
|
529
|
+
return [{ id: definition.id, name: definition.name, kind: definition.kind ?? 'operation', state: children.every(child => child.state === 'finished') ? 'finished' : 'failed', terminalReason: children.every(child => child.state === 'finished') ? 'completed' : 'continued-after-failure', inputs: {}, outputs: last?.outputs ?? {}, durationMs, invocationId: `${results[0]?.invocationId?.split(':')[0] ?? 'run'}:${path.join('.')}`, invocationPath: path, children }];
|
|
530
|
+
}
|
|
531
|
+
return results.filter(result => result.invocationPath?.join('.') === path.join('.'));
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
resolveInputs(step, invocationInputs, stepOutputs) {
|
|
535
|
+
const resolved = { ...step.staticInputs };
|
|
536
|
+
if (!step.bindings)
|
|
537
|
+
return { ...invocationInputs, ...resolved };
|
|
538
|
+
for (const [name, reference] of Object.entries(step.bindings)) {
|
|
539
|
+
const match = /^\$(inputs|steps)\.([^.]+)(?:\.(.+))?$/.exec(reference);
|
|
540
|
+
if (!match)
|
|
541
|
+
return undefined;
|
|
542
|
+
const source = match[1] === 'inputs'
|
|
543
|
+
? invocationInputs[match[2]]
|
|
544
|
+
: match[3] ? stepOutputs[match[2]]?.[match[3]] : undefined;
|
|
545
|
+
if (source === undefined || source === null)
|
|
546
|
+
return undefined;
|
|
547
|
+
resolved[name] = source;
|
|
548
|
+
}
|
|
549
|
+
return resolved;
|
|
550
|
+
}
|
|
551
|
+
emit(run, type, state, message, step, iteration, parallel, control) { run.events.push({ requestId: run.request.requestId, sequence: run.events.length + 1, type, state, stepId: step?.id, stepName: step?.name, message, timestamp: new Date().toISOString(), ...(iteration ? { iteration } : {}), ...(parallel ? { parallel } : {}), ...(control ? { control: { ...control, ...(step?.id ? { stepId: step.id } : {}) } } : {}) }); run.listeners.splice(0).forEach(resolve => resolve()); }
|
|
552
|
+
finish(run, result) { const terminalReason = result.terminalReason ?? (result.error?.code === 'TIMEOUT' ? 'timeout' : result.state === 'completed' ? 'completed' : result.state === 'cancelled' ? 'cancelled' : 'failed'); run.controlState = result.state === 'completed' ? 'completed' : result.state === 'cancelled' ? 'stopped' : 'failed'; const wallDurationMs = Math.max(0, Date.now() - run.startedMs); const pausedDurationMs = run.pausedStartedMs ? run.pausedDurationMs + Date.now() - run.pausedStartedMs : run.pausedDurationMs; run.result = { ...result, artifactHash: result.artifactHash ?? hashExecutionPackage(run.request.executionPackage), terminalReason, executionTiming: result.executionTiming ?? { wallDurationMs, pausedDurationMs, activeDurationMs: Math.max(0, wallDurationMs - pausedDurationMs) } }; this.emit(run, 'run_finished', result.state, result.error?.message ?? `Run ${result.state}.`); run.resultResolve(run.result); }
|
|
553
|
+
async waitForSafeBoundary(run, step, path) { const breakpointKey = path.join('.'); const breakpointHit = run.debugSession.breakpoints.includes(step.id) && !run.hitBreakpoints.has(breakpointKey); const stepBoundary = run.stepRequested; if (breakpointHit) {
|
|
554
|
+
run.hitBreakpoints.add(breakpointKey);
|
|
555
|
+
run.controlState = 'paused';
|
|
556
|
+
this.emit(run, 'waiting', 'waiting', `Breakpoint reached before ${step.name}.`, step, undefined, undefined, { type: 'breakpoint-reached' });
|
|
557
|
+
this.emit(run, 'waiting', 'waiting', `Execution paused before ${step.name}.`, step, undefined, undefined, { type: 'paused' });
|
|
558
|
+
}
|
|
559
|
+
else if (run.pauseRequested) {
|
|
560
|
+
run.controlState = 'paused';
|
|
561
|
+
this.emit(run, 'waiting', 'waiting', `Execution paused before ${step.name}.`, step, undefined, undefined, { type: 'paused' });
|
|
562
|
+
}
|
|
563
|
+
else if (stepBoundary) {
|
|
564
|
+
run.stepRequested = false;
|
|
565
|
+
run.controlState = 'paused';
|
|
566
|
+
this.emit(run, 'waiting', 'waiting', `Step completed; execution paused before ${step.name}.`, step, undefined, undefined, { type: 'step-completed' });
|
|
567
|
+
this.emit(run, 'waiting', 'waiting', `Execution paused before ${step.name}.`, step, undefined, undefined, { type: 'paused' });
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
if (run.controlState === 'resuming')
|
|
571
|
+
run.controlState = 'running';
|
|
572
|
+
return !run.cancelled;
|
|
573
|
+
} run.pausedStartedMs = Date.now(); await new Promise(resolve => run.controlWaiters.push(resolve)); if (run.pausedStartedMs) {
|
|
574
|
+
run.pausedDurationMs += Date.now() - run.pausedStartedMs;
|
|
575
|
+
run.pausedStartedMs = undefined;
|
|
576
|
+
} if (run.cancelled)
|
|
577
|
+
return false; run.controlState = 'running'; return true; }
|
|
578
|
+
async pauseAfterStopAfter(run, step) { if (run.debugSession.stopAfterStepId !== step.id)
|
|
579
|
+
return !run.cancelled; run.debugSession.stopAfterStepId = undefined; run.controlState = 'paused'; this.emit(run, 'waiting', 'waiting', `Stop After reached after ${step.name}.`, step, undefined, undefined, { type: 'stop-after-reached' }); this.emit(run, 'waiting', 'waiting', `Execution paused after ${step.name}.`, step, undefined, undefined, { type: 'paused' }); run.pausedStartedMs = Date.now(); await new Promise(resolve => run.controlWaiters.push(resolve)); if (run.pausedStartedMs) {
|
|
580
|
+
run.pausedDurationMs += Date.now() - run.pausedStartedMs;
|
|
581
|
+
run.pausedStartedMs = undefined;
|
|
582
|
+
} if (run.cancelled)
|
|
583
|
+
return false; run.controlState = 'running'; return true; }
|
|
584
|
+
finishCancelled(run, startedAt, steps) { if (run.timedOut) {
|
|
585
|
+
const error = { code: 'TIMEOUT', message: `Run exceeded its ${run.request.options?.timeoutMs}ms timeout.` };
|
|
586
|
+
this.finish(run, { requestId: run.request.requestId, state: 'failed', target: run.request.executionPackage.target, environment: run.request.environment, startedAt, finishedAt: new Date().toISOString(), steps, error });
|
|
587
|
+
}
|
|
588
|
+
else
|
|
589
|
+
this.finish(run, { requestId: run.request.requestId, state: 'cancelled', target: run.request.executionPackage.target, environment: run.request.environment, startedAt, finishedAt: new Date().toISOString(), steps }); }
|
|
590
|
+
pause(run, ms) { return new Promise(resolve => { const started = Date.now(); const timer = globalThis.setInterval(() => { if (run.cancelled) {
|
|
591
|
+
globalThis.clearInterval(timer);
|
|
592
|
+
resolve(false);
|
|
593
|
+
}
|
|
594
|
+
else if (run.timeoutAt !== undefined && Date.now() >= run.timeoutAt) {
|
|
595
|
+
run.timedOut = true;
|
|
596
|
+
run.abortController.abort();
|
|
597
|
+
globalThis.clearInterval(timer);
|
|
598
|
+
resolve(false);
|
|
599
|
+
}
|
|
600
|
+
else if (Date.now() - started >= ms) {
|
|
601
|
+
globalThis.clearInterval(timer);
|
|
602
|
+
resolve(true);
|
|
603
|
+
} }, 25); }); }
|
|
604
|
+
}
|
|
605
|
+
function walkSteps(steps) { return steps.flatMap(step => [step, ...(step.children ? walkSteps(step.children) : [])]); }
|
|
606
|
+
function unsupportedControlSettings(step) {
|
|
607
|
+
const control = step.control;
|
|
608
|
+
if (!control)
|
|
609
|
+
return [];
|
|
610
|
+
const issues = [];
|
|
611
|
+
const unsupported = (field, supportedFor) => issues.push({ code: 'UNSUPPORTED_CONTROL_SETTING', message: `Control Block ${step.name} sets ${field}, which is supported only for ${supportedFor} controls.` });
|
|
612
|
+
if (control.type !== 'repeat' && control.iterations !== undefined)
|
|
613
|
+
unsupported('iterations', 'repeat');
|
|
614
|
+
if (control.type !== 'repeat' && control.durationMs !== undefined)
|
|
615
|
+
unsupported('durationMs', 'repeat');
|
|
616
|
+
if (control.type !== 'repeat' && control.iterationDelayMs !== undefined)
|
|
617
|
+
unsupported('iterationDelayMs', 'repeat');
|
|
618
|
+
if (control.type !== 'condition' && control.type !== 'repeat' && control.condition !== undefined)
|
|
619
|
+
unsupported('condition', 'condition or repeat');
|
|
620
|
+
if (control.type !== 'branch' && control.type !== 'parallel' && control.branches !== undefined)
|
|
621
|
+
unsupported('branches', 'branch or parallel');
|
|
622
|
+
if (control.type !== 'parallel' && control.join !== undefined)
|
|
623
|
+
unsupported('join', 'parallel');
|
|
624
|
+
if (control.type !== 'parallel' && control.merge !== undefined)
|
|
625
|
+
unsupported('merge', 'parallel');
|
|
626
|
+
if (control.type !== 'parallel' && control.mergeBindings !== undefined)
|
|
627
|
+
unsupported('mergeBindings', 'parallel');
|
|
628
|
+
return issues;
|
|
629
|
+
}
|
|
630
|
+
function conditionMatches(condition, inputs, stepOutputs = {}) {
|
|
631
|
+
if (typeof condition === 'boolean')
|
|
632
|
+
return condition;
|
|
633
|
+
const inputMatch = /^\$inputs\.([^.]+)$/.exec(condition.binding);
|
|
634
|
+
const stepMatch = /^\$steps\.([^.]+)\.(.+)$/.exec(condition.binding);
|
|
635
|
+
const value = inputMatch ? inputs[inputMatch[1]] : stepMatch ? stepOutputs[stepMatch[1]]?.[stepMatch[2]] : undefined;
|
|
636
|
+
if (condition.exists !== undefined)
|
|
637
|
+
return condition.exists ? value !== undefined && value !== null && value !== '' : value === undefined || value === null || value === '';
|
|
638
|
+
if (Object.prototype.hasOwnProperty.call(condition, 'equals'))
|
|
639
|
+
return value === condition.equals;
|
|
640
|
+
if (Object.prototype.hasOwnProperty.call(condition, 'notEquals'))
|
|
641
|
+
return value !== condition.notEquals;
|
|
642
|
+
return Boolean(value);
|
|
643
|
+
}
|
|
644
|
+
function mergeParallelOutputs(outcomes, groupId, policy, bindings) {
|
|
645
|
+
if (bindings)
|
|
646
|
+
return Object.fromEntries(Object.entries(bindings).flatMap(([name, source]) => {
|
|
647
|
+
const match = /^([^.]+)\.(.+)$/.exec(source);
|
|
648
|
+
if (!match)
|
|
649
|
+
return [];
|
|
650
|
+
const outcome = outcomes.find(item => item.result.invocationPath?.includes(`${groupId}.${match[1]}`));
|
|
651
|
+
return outcome?.output && Object.prototype.hasOwnProperty.call(outcome.output, match[2]) ? [[name, outcome.output[match[2]]]] : [];
|
|
652
|
+
}));
|
|
653
|
+
if (policy === 'first')
|
|
654
|
+
return { ...outcomes[0]?.output };
|
|
655
|
+
if (policy === 'last')
|
|
656
|
+
return { ...outcomes.at(-1)?.output };
|
|
657
|
+
return Object.fromEntries(outcomes.flatMap(outcome => {
|
|
658
|
+
const path = outcome.result.invocationPath ?? [];
|
|
659
|
+
const branchIndex = path.indexOf(groupId);
|
|
660
|
+
const branchId = branchIndex >= 0 ? path[branchIndex + 1] ?? outcome.result.id : outcome.result.id;
|
|
661
|
+
return Object.entries(outcome.output ?? {}).map(([name, value]) => [`${branchId}.${name}`, value]);
|
|
662
|
+
}));
|
|
663
|
+
}
|
|
664
|
+
function mergeBranchResultOutputs(branches, bindings, policy) {
|
|
665
|
+
if (bindings)
|
|
666
|
+
return Object.fromEntries(Object.entries(bindings).flatMap(([name, source]) => { const match = /^([^.]+)\.(.+)$/.exec(source); const branch = match ? branches.find(item => item.id === match[1]) : undefined; return branch && match && Object.prototype.hasOwnProperty.call(branch.outputs, match[2]) ? [[name, branch.outputs[match[2]]]] : []; }));
|
|
667
|
+
if (policy === 'first')
|
|
668
|
+
return { ...branches[0]?.outputs };
|
|
669
|
+
if (policy === 'last')
|
|
670
|
+
return { ...branches.at(-1)?.outputs };
|
|
671
|
+
return Object.fromEntries(branches.flatMap(branch => Object.entries(branch.outputs).map(([name, value]) => [`${branch.id}.${name}`, value])));
|
|
672
|
+
}
|
|
673
|
+
function findStepResult(results, stepId, invocationPath) {
|
|
674
|
+
for (const result of results) {
|
|
675
|
+
if (result.id === stepId && (!invocationPath || result.invocationPath?.join('.') === invocationPath.join('.')))
|
|
676
|
+
return result;
|
|
677
|
+
const nested = result.children && findStepResult(result.children, stepId, invocationPath);
|
|
678
|
+
if (nested)
|
|
679
|
+
return nested;
|
|
680
|
+
}
|
|
681
|
+
const operationChildren = resultsUnderPath(results, invocationPath ?? [stepId]);
|
|
682
|
+
if (operationChildren.length) {
|
|
683
|
+
const synthetic = { id: stepId, name: stepId, state: operationChildren.every(child => child.state === 'finished') ? 'finished' : 'failed', inputs: {}, outputs: operationChildren.at(-1)?.outputs ?? {}, durationMs: operationChildren.reduce((total, child) => total + child.durationMs, 0), invocationPath: invocationPath ?? [stepId], kind: 'operation', children: operationChildren };
|
|
684
|
+
results.splice(0, results.length, synthetic);
|
|
685
|
+
return synthetic;
|
|
686
|
+
}
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
function resultsUnderPath(results, prefix) { return results.filter(result => result.invocationPath?.slice(0, prefix.length).join('.') === prefix.join('.')); }
|
|
690
|
+
function flattenStepResults(results) { return results.flatMap(result => [result, ...(result.children ? flattenStepResults(result.children) : [])]); }
|
|
691
|
+
function findExecutionStep(steps, stepId, invocationPath, prefix = []) {
|
|
692
|
+
for (const step of steps) {
|
|
693
|
+
const path = [...prefix, step.id];
|
|
694
|
+
if (step.id === stepId && (!invocationPath || path.join('.') === invocationPath.join('.')))
|
|
695
|
+
return step;
|
|
696
|
+
const nested = step.children && findExecutionStep(step.children, stepId, invocationPath, path);
|
|
697
|
+
if (nested)
|
|
698
|
+
return nested;
|
|
699
|
+
const branches = step.control?.branches?.flatMap(branch => findExecutionStep(branch.children, stepId, invocationPath, [...prefix, `${step.id}.${branch.id}`]) ?? []) ?? [];
|
|
700
|
+
if (branches[0])
|
|
701
|
+
return branches[0];
|
|
702
|
+
}
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
function isAdapterError(value) { return typeof value === 'object' && value !== null && 'code' in value && typeof value.code === 'string'; }
|