@voyantjs/workflows 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +52 -0
- package/README.md +46 -0
- package/package.json +78 -0
- package/src/auth/index.ts +156 -0
- package/src/conditions.ts +43 -0
- package/src/handler/index.ts +361 -0
- package/src/index.ts +18 -0
- package/src/protocol/index.ts +133 -0
- package/src/rate-limit/index.ts +182 -0
- package/src/runtime/ctx.ts +838 -0
- package/src/runtime/determinism.ts +75 -0
- package/src/runtime/errors.ts +58 -0
- package/src/runtime/executor.ts +427 -0
- package/src/runtime/journal.ts +79 -0
- package/src/testing/index.ts +729 -0
- package/src/trigger.ts +146 -0
- package/src/types.ts +81 -0
- package/src/workflow.ts +306 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
// @voyantjs/workflows/testing
|
|
2
|
+
//
|
|
3
|
+
// In-process test harness with mocked steps, waits, and invokes.
|
|
4
|
+
// Contract in docs/sdk-surface.md §11.
|
|
5
|
+
//
|
|
6
|
+
// Drives `executeWorkflowStep` across resumptions. Steps resolve
|
|
7
|
+
// from a user-supplied stub map; waitpoints resolve from fixtures.
|
|
8
|
+
|
|
9
|
+
import type { WorkflowHandle, StepContext, EnvironmentContext, MetadataValue } from "../workflow.js";
|
|
10
|
+
import type { WorkflowDefinition } from "../workflow.js";
|
|
11
|
+
import { getWorkflow } from "../workflow.js";
|
|
12
|
+
import type { RunStatus } from "../types.js";
|
|
13
|
+
import type { SerializedError } from "../protocol/index.js";
|
|
14
|
+
import { executeWorkflowStep, type CompensationReport, type ExecuteWorkflowStepResponse, type MetadataMutation, type StreamChunk, type WaitpointRegistration } from "../runtime/executor.js";
|
|
15
|
+
import type { JournalSlice, StepJournalEntry, WaitpointResolutionEntry } from "../runtime/journal.js";
|
|
16
|
+
import { emptyJournal } from "../runtime/journal.js";
|
|
17
|
+
|
|
18
|
+
export interface TestOptions<_TIn> {
|
|
19
|
+
/** Map of stepId → function run in place of the real step body. */
|
|
20
|
+
steps?: Record<string, (stepCtx: StepContext) => unknown | Promise<unknown>>;
|
|
21
|
+
/**
|
|
22
|
+
* Map of eventType → payload (or payload array). First
|
|
23
|
+
* match-per-eventType wins.
|
|
24
|
+
*/
|
|
25
|
+
waitForEvent?: Record<string, unknown | unknown[]>;
|
|
26
|
+
/** Map of signalName → payload. */
|
|
27
|
+
waitForSignal?: Record<string, unknown>;
|
|
28
|
+
/** Map of tokenId → payload. */
|
|
29
|
+
waitForToken?: Record<string, unknown>;
|
|
30
|
+
/** Workflow invoke stubs keyed by child workflow id. */
|
|
31
|
+
invoke?: Record<string, unknown>;
|
|
32
|
+
/** Fake env bindings, passed through to `ctx.environment` for tests. */
|
|
33
|
+
env?: Record<string, unknown>;
|
|
34
|
+
environment?: Partial<EnvironmentContext>;
|
|
35
|
+
/** Fixed wall-clock basis for the run. Defaults to Date.now(). */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
random?: () => number;
|
|
38
|
+
/** Max resumption cycles. Defaults to 16 — guards runaway loops. */
|
|
39
|
+
maxInvocations?: number;
|
|
40
|
+
/**
|
|
41
|
+
* When true, the harness stops and returns a "waiting" TestResult as
|
|
42
|
+
* soon as any registered waitpoint has no fixture resolution (instead
|
|
43
|
+
* of throwing). Callers can then persist the run and resume it later
|
|
44
|
+
* via `resumeWorkflowForTest` once the waitpoint is resolved from a
|
|
45
|
+
* live source (e.g. a dashboard HTTP injection).
|
|
46
|
+
*
|
|
47
|
+
* DATETIME waitpoints (sleeps) are always auto-resolved regardless of
|
|
48
|
+
* this flag, since a local dev loop is not the right place to
|
|
49
|
+
* synthesize wall-clock delays.
|
|
50
|
+
*/
|
|
51
|
+
pauseOnWait?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TestResult<TOut> {
|
|
55
|
+
status: Extract<
|
|
56
|
+
RunStatus,
|
|
57
|
+
"completed" | "failed" | "cancelled" | "compensated" | "compensation_failed" | "waiting"
|
|
58
|
+
>;
|
|
59
|
+
output?: TOut;
|
|
60
|
+
error?: { category: SerializedError["category"]; code: string; message: string };
|
|
61
|
+
steps: { id: string; status: "ok" | "err" | "skipped"; duration: number; output?: unknown }[];
|
|
62
|
+
events: { type: string; at: number; data: unknown }[];
|
|
63
|
+
metadata: Record<string, MetadataValue>;
|
|
64
|
+
compensations: CompensationReport[];
|
|
65
|
+
/** Chunks emitted via `ctx.stream()` / `ctx.stream.{text,json,bytes}`, grouped by streamId in emission order. */
|
|
66
|
+
streams: Record<string, StreamChunk[]>;
|
|
67
|
+
invocations: number;
|
|
68
|
+
/**
|
|
69
|
+
* Populated when `status === "waiting"`. Holds the persisted executor
|
|
70
|
+
* state needed to resume the run via `resumeWorkflowForTest`.
|
|
71
|
+
*/
|
|
72
|
+
pause?: {
|
|
73
|
+
journal: JournalSlice;
|
|
74
|
+
pendingWaitpoints: WaitpointRegistration[];
|
|
75
|
+
startedAt: number;
|
|
76
|
+
invocationCount: number;
|
|
77
|
+
metadataAppliedCount: number;
|
|
78
|
+
fixtureCursors: { event: Record<string, number>; signal: Record<string, number> };
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runWorkflowForTest<TIn, TOut>(
|
|
83
|
+
workflow: WorkflowHandle<TIn, TOut>,
|
|
84
|
+
input: TIn,
|
|
85
|
+
opts: TestOptions<TIn> = {},
|
|
86
|
+
): Promise<TestResult<TOut>> {
|
|
87
|
+
const def = workflow as WorkflowDefinition<TIn, TOut>;
|
|
88
|
+
const now = opts.now ?? (() => Date.now());
|
|
89
|
+
const startedAt = now();
|
|
90
|
+
const journal: JournalSlice = emptyJournal();
|
|
91
|
+
const metadata: Record<string, MetadataValue> = {};
|
|
92
|
+
const events: TestResult<TOut>["events"] = [];
|
|
93
|
+
const streams: Record<string, StreamChunk[]> = {};
|
|
94
|
+
const maxInvocations = opts.maxInvocations ?? 16;
|
|
95
|
+
|
|
96
|
+
const environment: EnvironmentContext = {
|
|
97
|
+
name: opts.environment?.name ?? "development",
|
|
98
|
+
git: opts.environment?.git,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let invocationCount = 0;
|
|
102
|
+
let last: ExecuteWorkflowStepResponse | undefined;
|
|
103
|
+
// Track how many metadata mutations have already been applied so we
|
|
104
|
+
// only apply the delta on each invocation. Otherwise replays
|
|
105
|
+
// double-count `increment` / duplicate `append` values. (Positional
|
|
106
|
+
// dedup; mirrors what the real orchestrator does with journaled ids.)
|
|
107
|
+
let metadataAppliedCount = 0;
|
|
108
|
+
// Per-fixture cursors into iterable event/signal arrays, persisted
|
|
109
|
+
// across invocations so replay doesn't restart consumption.
|
|
110
|
+
const cursors: FixtureCursors = { event: new Map(), signal: new Map() };
|
|
111
|
+
|
|
112
|
+
while (invocationCount < maxInvocations) {
|
|
113
|
+
invocationCount += 1;
|
|
114
|
+
|
|
115
|
+
const response = await executeWorkflowStep(def as unknown as WorkflowDefinition, {
|
|
116
|
+
runId: `run_test_${def.id}`,
|
|
117
|
+
workflowId: def.id,
|
|
118
|
+
workflowVersion: "test",
|
|
119
|
+
input,
|
|
120
|
+
journal,
|
|
121
|
+
invocationCount,
|
|
122
|
+
environment: {
|
|
123
|
+
run: {
|
|
124
|
+
id: `run_test_${def.id}`,
|
|
125
|
+
number: 1,
|
|
126
|
+
attempt: 1,
|
|
127
|
+
triggeredBy: { kind: "api" },
|
|
128
|
+
tags: [],
|
|
129
|
+
startedAt,
|
|
130
|
+
},
|
|
131
|
+
workflow: { id: def.id, version: "test" },
|
|
132
|
+
environment,
|
|
133
|
+
project: { id: "prj_test", slug: "test" },
|
|
134
|
+
organization: { id: "org_test", slug: "test" },
|
|
135
|
+
},
|
|
136
|
+
triggeredBy: { kind: "api" },
|
|
137
|
+
runStartedAt: startedAt,
|
|
138
|
+
tags: [],
|
|
139
|
+
stepRunner: createStepRunner(opts, events, now),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
last = response;
|
|
143
|
+
|
|
144
|
+
const newMutations = response.metadataUpdates.slice(metadataAppliedCount);
|
|
145
|
+
applyMetadata(metadata, newMutations);
|
|
146
|
+
metadataAppliedCount = response.metadataUpdates.length;
|
|
147
|
+
|
|
148
|
+
for (const chunk of response.streamChunks) {
|
|
149
|
+
const bucket = streams[chunk.streamId] ??= [];
|
|
150
|
+
bucket.push(chunk);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (response.status === "completed") {
|
|
154
|
+
return {
|
|
155
|
+
status: "completed",
|
|
156
|
+
output: response.output as TOut,
|
|
157
|
+
steps: stepsFromJournal(journal),
|
|
158
|
+
events,
|
|
159
|
+
metadata,
|
|
160
|
+
compensations: [],
|
|
161
|
+
streams,
|
|
162
|
+
invocations: invocationCount,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (response.status === "failed") {
|
|
167
|
+
return {
|
|
168
|
+
status: "failed",
|
|
169
|
+
error: { category: response.error.category, code: response.error.code, message: response.error.message },
|
|
170
|
+
steps: stepsFromJournal(journal),
|
|
171
|
+
events,
|
|
172
|
+
metadata,
|
|
173
|
+
compensations: [],
|
|
174
|
+
streams,
|
|
175
|
+
invocations: invocationCount,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (response.status === "cancelled") {
|
|
180
|
+
return {
|
|
181
|
+
status: "cancelled",
|
|
182
|
+
steps: stepsFromJournal(journal),
|
|
183
|
+
events,
|
|
184
|
+
metadata,
|
|
185
|
+
compensations: response.compensations,
|
|
186
|
+
streams,
|
|
187
|
+
invocations: invocationCount,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (response.status === "compensated" || response.status === "compensation_failed") {
|
|
192
|
+
return {
|
|
193
|
+
status: response.status,
|
|
194
|
+
error: response.error
|
|
195
|
+
? {
|
|
196
|
+
category: response.error.category,
|
|
197
|
+
code: response.error.code,
|
|
198
|
+
message: response.error.message,
|
|
199
|
+
}
|
|
200
|
+
: undefined,
|
|
201
|
+
steps: stepsFromJournal(journal),
|
|
202
|
+
events,
|
|
203
|
+
metadata,
|
|
204
|
+
compensations: response.compensations,
|
|
205
|
+
streams,
|
|
206
|
+
invocations: invocationCount,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Waiting: resolve waitpoints from fixtures and loop. Waitpoints
|
|
211
|
+
// that have no fixture match either throw (default) or, when
|
|
212
|
+
// `pauseOnWait` is set, park the run and return a "waiting" result.
|
|
213
|
+
const stillPending: WaitpointRegistration[] = [];
|
|
214
|
+
for (const wp of response.waitpoints) {
|
|
215
|
+
const resolved = await resolveWaitpoint(wp, opts, now, events, cursors);
|
|
216
|
+
if (!resolved) {
|
|
217
|
+
if (opts.pauseOnWait) {
|
|
218
|
+
stillPending.push(wp);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
throw new Error(
|
|
222
|
+
`test harness: waitpoint ${wp.clientWaitpointId} (${wp.kind}) has no fixture resolution. ` +
|
|
223
|
+
`Provide one via TestOptions.waitForEvent / waitForSignal / (sleeps auto-resolve), ` +
|
|
224
|
+
`or set TestOptions.pauseOnWait to park the run.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
journal.waitpointsResolved[wp.clientWaitpointId] = resolved;
|
|
228
|
+
events.push({ type: `waitpoint.resolved:${wp.kind}`, at: resolved.resolvedAt, data: resolved.payload ?? null });
|
|
229
|
+
}
|
|
230
|
+
if (stillPending.length > 0) {
|
|
231
|
+
return {
|
|
232
|
+
status: "waiting",
|
|
233
|
+
steps: stepsFromJournal(journal),
|
|
234
|
+
events,
|
|
235
|
+
metadata,
|
|
236
|
+
compensations: [],
|
|
237
|
+
streams,
|
|
238
|
+
invocations: invocationCount,
|
|
239
|
+
pause: {
|
|
240
|
+
journal,
|
|
241
|
+
pendingWaitpoints: stillPending,
|
|
242
|
+
startedAt,
|
|
243
|
+
invocationCount,
|
|
244
|
+
metadataAppliedCount,
|
|
245
|
+
fixtureCursors: {
|
|
246
|
+
event: Object.fromEntries(cursors.event.entries()),
|
|
247
|
+
signal: Object.fromEntries(cursors.signal.entries()),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
throw new Error(
|
|
255
|
+
`test harness exceeded maxInvocations (${maxInvocations}). ` +
|
|
256
|
+
`Last status: ${last?.status ?? "<none>"}. Possible infinite waitpoint loop.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A single injected waitpoint resolution — the tenant-side analogue of
|
|
262
|
+
* the orchestrator delivering an event, signal, or token payload to a
|
|
263
|
+
* parked run. The matcher (`kind` + `key`) identifies which pending
|
|
264
|
+
* waitpoint to resolve; `payload` is the value surfaced to the body.
|
|
265
|
+
*/
|
|
266
|
+
export type WaitpointInjection =
|
|
267
|
+
| { kind: "EVENT"; eventType: string; payload?: unknown }
|
|
268
|
+
| { kind: "SIGNAL"; name: string; payload?: unknown }
|
|
269
|
+
| { kind: "MANUAL"; tokenId: string; payload?: unknown };
|
|
270
|
+
|
|
271
|
+
export interface ResumeOptions<TIn> extends TestOptions<TIn> {
|
|
272
|
+
/** Persisted pause state returned from a previous `runWorkflowForTest` or `resumeWorkflowForTest`. */
|
|
273
|
+
pause: NonNullable<TestResult<unknown>["pause"]>;
|
|
274
|
+
/** The single waitpoint resolution to inject on resume. */
|
|
275
|
+
injection: WaitpointInjection;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Resume a parked run. Matches the injection against one of the
|
|
280
|
+
* `pause.pendingWaitpoints`, records it in the journal, and re-enters
|
|
281
|
+
* the executor loop until the next pause or terminal state.
|
|
282
|
+
*/
|
|
283
|
+
export async function resumeWorkflowForTest<TIn, TOut>(
|
|
284
|
+
workflow: WorkflowHandle<TIn, TOut>,
|
|
285
|
+
input: TIn,
|
|
286
|
+
opts: ResumeOptions<TIn>,
|
|
287
|
+
): Promise<TestResult<TOut>> {
|
|
288
|
+
const def = workflow as WorkflowDefinition<TIn, TOut>;
|
|
289
|
+
const now = opts.now ?? (() => Date.now());
|
|
290
|
+
const maxInvocations = opts.maxInvocations ?? 16;
|
|
291
|
+
|
|
292
|
+
const matched = matchWaitpoint(opts.pause.pendingWaitpoints, opts.injection);
|
|
293
|
+
if (!matched) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`resume: no pending waitpoint matches injection kind=${opts.injection.kind}, ` +
|
|
296
|
+
`key=${injectionKey(opts.injection)}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const journal = cloneJournal(opts.pause.journal);
|
|
301
|
+
journal.waitpointsResolved[matched.clientWaitpointId] = {
|
|
302
|
+
kind: matched.kind,
|
|
303
|
+
resolvedAt: now(),
|
|
304
|
+
payload: opts.injection.payload,
|
|
305
|
+
source: "live",
|
|
306
|
+
matchedEventId: opts.injection.kind === "EVENT" ? `evt_live_${opts.injection.eventType}` : undefined,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const events: TestResult<TOut>["events"] = [
|
|
310
|
+
{
|
|
311
|
+
type: `waitpoint.resolved:${matched.kind}`,
|
|
312
|
+
at: now(),
|
|
313
|
+
data: opts.injection.payload ?? null,
|
|
314
|
+
},
|
|
315
|
+
];
|
|
316
|
+
const metadata: Record<string, MetadataValue> = {};
|
|
317
|
+
const streams: Record<string, StreamChunk[]> = {};
|
|
318
|
+
const cursors: FixtureCursors = {
|
|
319
|
+
event: new Map(Object.entries(opts.pause.fixtureCursors.event)),
|
|
320
|
+
signal: new Map(Object.entries(opts.pause.fixtureCursors.signal)),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const environment: EnvironmentContext = {
|
|
324
|
+
name: opts.environment?.name ?? "development",
|
|
325
|
+
git: opts.environment?.git,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
let invocationCount = opts.pause.invocationCount;
|
|
329
|
+
let metadataAppliedCount = opts.pause.metadataAppliedCount;
|
|
330
|
+
let last: ExecuteWorkflowStepResponse | undefined;
|
|
331
|
+
|
|
332
|
+
// Remaining pending waitpoints from the previous pause (still parked).
|
|
333
|
+
let carryover = opts.pause.pendingWaitpoints.filter((w) => w.clientWaitpointId !== matched.clientWaitpointId);
|
|
334
|
+
|
|
335
|
+
while (invocationCount < maxInvocations) {
|
|
336
|
+
invocationCount += 1;
|
|
337
|
+
|
|
338
|
+
const response = await executeWorkflowStep(def as unknown as WorkflowDefinition, {
|
|
339
|
+
runId: `run_test_${def.id}`,
|
|
340
|
+
workflowId: def.id,
|
|
341
|
+
workflowVersion: "test",
|
|
342
|
+
input,
|
|
343
|
+
journal,
|
|
344
|
+
invocationCount,
|
|
345
|
+
environment: {
|
|
346
|
+
run: {
|
|
347
|
+
id: `run_test_${def.id}`,
|
|
348
|
+
number: 1,
|
|
349
|
+
attempt: 1,
|
|
350
|
+
triggeredBy: { kind: "api" },
|
|
351
|
+
tags: [],
|
|
352
|
+
startedAt: opts.pause.startedAt,
|
|
353
|
+
},
|
|
354
|
+
workflow: { id: def.id, version: "test" },
|
|
355
|
+
environment,
|
|
356
|
+
project: { id: "prj_test", slug: "test" },
|
|
357
|
+
organization: { id: "org_test", slug: "test" },
|
|
358
|
+
},
|
|
359
|
+
triggeredBy: { kind: "api" },
|
|
360
|
+
runStartedAt: opts.pause.startedAt,
|
|
361
|
+
tags: [],
|
|
362
|
+
stepRunner: createStepRunner(opts, events, now),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
last = response;
|
|
366
|
+
|
|
367
|
+
const newMutations = response.metadataUpdates.slice(metadataAppliedCount);
|
|
368
|
+
applyMetadata(metadata, newMutations);
|
|
369
|
+
metadataAppliedCount = response.metadataUpdates.length;
|
|
370
|
+
|
|
371
|
+
for (const chunk of response.streamChunks) {
|
|
372
|
+
const bucket = streams[chunk.streamId] ??= [];
|
|
373
|
+
bucket.push(chunk);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (response.status === "completed") {
|
|
377
|
+
return {
|
|
378
|
+
status: "completed",
|
|
379
|
+
output: response.output as TOut,
|
|
380
|
+
steps: stepsFromJournal(journal),
|
|
381
|
+
events,
|
|
382
|
+
metadata,
|
|
383
|
+
compensations: [],
|
|
384
|
+
streams,
|
|
385
|
+
invocations: invocationCount,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (response.status === "failed") {
|
|
389
|
+
return {
|
|
390
|
+
status: "failed",
|
|
391
|
+
error: { category: response.error.category, code: response.error.code, message: response.error.message },
|
|
392
|
+
steps: stepsFromJournal(journal),
|
|
393
|
+
events,
|
|
394
|
+
metadata,
|
|
395
|
+
compensations: [],
|
|
396
|
+
streams,
|
|
397
|
+
invocations: invocationCount,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (response.status === "cancelled") {
|
|
401
|
+
return {
|
|
402
|
+
status: "cancelled",
|
|
403
|
+
steps: stepsFromJournal(journal),
|
|
404
|
+
events,
|
|
405
|
+
metadata,
|
|
406
|
+
compensations: response.compensations,
|
|
407
|
+
streams,
|
|
408
|
+
invocations: invocationCount,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (response.status === "compensated" || response.status === "compensation_failed") {
|
|
412
|
+
return {
|
|
413
|
+
status: response.status,
|
|
414
|
+
error: response.error
|
|
415
|
+
? { category: response.error.category, code: response.error.code, message: response.error.message }
|
|
416
|
+
: undefined,
|
|
417
|
+
steps: stepsFromJournal(journal),
|
|
418
|
+
events,
|
|
419
|
+
metadata,
|
|
420
|
+
compensations: response.compensations,
|
|
421
|
+
streams,
|
|
422
|
+
invocations: invocationCount,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const stillPending: WaitpointRegistration[] = [...carryover];
|
|
427
|
+
for (const wp of response.waitpoints) {
|
|
428
|
+
const resolved = await resolveWaitpoint(wp, opts, now, events, cursors);
|
|
429
|
+
if (!resolved) {
|
|
430
|
+
if (opts.pauseOnWait) {
|
|
431
|
+
stillPending.push(wp);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
throw new Error(
|
|
435
|
+
`resume: waitpoint ${wp.clientWaitpointId} (${wp.kind}) has no fixture resolution. ` +
|
|
436
|
+
`Provide one via TestOptions.waitForEvent / waitForSignal, or set pauseOnWait.`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
journal.waitpointsResolved[wp.clientWaitpointId] = resolved;
|
|
440
|
+
events.push({ type: `waitpoint.resolved:${wp.kind}`, at: resolved.resolvedAt, data: resolved.payload ?? null });
|
|
441
|
+
}
|
|
442
|
+
carryover = []; // consumed into stillPending
|
|
443
|
+
|
|
444
|
+
if (stillPending.length > 0) {
|
|
445
|
+
return {
|
|
446
|
+
status: "waiting",
|
|
447
|
+
steps: stepsFromJournal(journal),
|
|
448
|
+
events,
|
|
449
|
+
metadata,
|
|
450
|
+
compensations: [],
|
|
451
|
+
streams,
|
|
452
|
+
invocations: invocationCount,
|
|
453
|
+
pause: {
|
|
454
|
+
journal,
|
|
455
|
+
pendingWaitpoints: stillPending,
|
|
456
|
+
startedAt: opts.pause.startedAt,
|
|
457
|
+
invocationCount,
|
|
458
|
+
metadataAppliedCount,
|
|
459
|
+
fixtureCursors: {
|
|
460
|
+
event: Object.fromEntries(cursors.event.entries()),
|
|
461
|
+
signal: Object.fromEntries(cursors.signal.entries()),
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
throw new Error(
|
|
469
|
+
`resume: exceeded maxInvocations (${maxInvocations}). Last status: ${last?.status ?? "<none>"}.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function matchWaitpoint(
|
|
474
|
+
pending: readonly WaitpointRegistration[],
|
|
475
|
+
inj: WaitpointInjection,
|
|
476
|
+
): WaitpointRegistration | undefined {
|
|
477
|
+
for (const wp of pending) {
|
|
478
|
+
if (wp.kind !== inj.kind) continue;
|
|
479
|
+
if (inj.kind === "EVENT" && wp.meta.eventType === inj.eventType) return wp;
|
|
480
|
+
if (inj.kind === "SIGNAL" && wp.meta.signalName === inj.name) return wp;
|
|
481
|
+
if (inj.kind === "MANUAL" && wp.meta.tokenId === inj.tokenId) return wp;
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function injectionKey(inj: WaitpointInjection): string {
|
|
487
|
+
if (inj.kind === "EVENT") return inj.eventType;
|
|
488
|
+
if (inj.kind === "SIGNAL") return inj.name;
|
|
489
|
+
return inj.tokenId;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function cloneJournal(j: JournalSlice): JournalSlice {
|
|
493
|
+
return {
|
|
494
|
+
stepResults: { ...j.stepResults },
|
|
495
|
+
waitpointsResolved: { ...j.waitpointsResolved },
|
|
496
|
+
compensationsRun: { ...j.compensationsRun },
|
|
497
|
+
metadataState: { ...j.metadataState },
|
|
498
|
+
streamsCompleted: { ...j.streamsCompleted },
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function createStepRunner(
|
|
503
|
+
opts: TestOptions<unknown>,
|
|
504
|
+
events: TestResult<unknown>["events"],
|
|
505
|
+
now: () => number,
|
|
506
|
+
) {
|
|
507
|
+
return async (args: {
|
|
508
|
+
stepId: string;
|
|
509
|
+
attempt: number;
|
|
510
|
+
input: unknown;
|
|
511
|
+
fn: (stepCtx: StepContext) => Promise<unknown>;
|
|
512
|
+
stepCtx: StepContext;
|
|
513
|
+
}): Promise<StepJournalEntry> => {
|
|
514
|
+
const startedAt = now();
|
|
515
|
+
const mock = opts.steps?.[args.stepId];
|
|
516
|
+
try {
|
|
517
|
+
const output = mock ? await mock(args.stepCtx) : await args.fn(args.stepCtx);
|
|
518
|
+
const finishedAt = now();
|
|
519
|
+
events.push({ type: "step.ok", at: finishedAt, data: { stepId: args.stepId, output } });
|
|
520
|
+
return {
|
|
521
|
+
attempt: args.attempt,
|
|
522
|
+
status: "ok",
|
|
523
|
+
output,
|
|
524
|
+
startedAt,
|
|
525
|
+
finishedAt,
|
|
526
|
+
};
|
|
527
|
+
} catch (err) {
|
|
528
|
+
const finishedAt = now();
|
|
529
|
+
const e = err as Error;
|
|
530
|
+
const code = (err as { code?: string }).code ?? "UNKNOWN";
|
|
531
|
+
events.push({ type: "step.err", at: finishedAt, data: { stepId: args.stepId, message: e.message, code } });
|
|
532
|
+
const retryAfter = (err as { retryAfter?: unknown }).retryAfter;
|
|
533
|
+
return {
|
|
534
|
+
attempt: args.attempt,
|
|
535
|
+
status: "err",
|
|
536
|
+
error: {
|
|
537
|
+
category: "USER_ERROR",
|
|
538
|
+
code,
|
|
539
|
+
message: e.message,
|
|
540
|
+
name: e.name,
|
|
541
|
+
stack: e.stack,
|
|
542
|
+
data: retryAfter !== undefined ? { retryAfter } : undefined,
|
|
543
|
+
},
|
|
544
|
+
startedAt,
|
|
545
|
+
finishedAt,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function resolveWaitpoint(
|
|
552
|
+
wp: WaitpointRegistration,
|
|
553
|
+
opts: TestOptions<unknown>,
|
|
554
|
+
now: () => number,
|
|
555
|
+
parentEvents: TestResult<unknown>["events"],
|
|
556
|
+
cursors: FixtureCursors,
|
|
557
|
+
): Promise<WaitpointResolutionEntry | null> {
|
|
558
|
+
const at = now();
|
|
559
|
+
if (wp.kind === "DATETIME") {
|
|
560
|
+
return { kind: "DATETIME", resolvedAt: at, source: "replay" };
|
|
561
|
+
}
|
|
562
|
+
if (wp.kind === "EVENT") {
|
|
563
|
+
const eventType = wp.meta.eventType as string;
|
|
564
|
+
const isIter = wp.meta.iter === true;
|
|
565
|
+
const fixture = opts.waitForEvent?.[eventType];
|
|
566
|
+
if (fixture === undefined) return null;
|
|
567
|
+
if (isIter) {
|
|
568
|
+
return resolveIterableFixture(fixture, cursors.event, eventType, at, "EVENT", `evt_test_${eventType}`);
|
|
569
|
+
}
|
|
570
|
+
const payload = Array.isArray(fixture) ? fixture[0] : fixture;
|
|
571
|
+
return { kind: "EVENT", resolvedAt: at, matchedEventId: `evt_test_${eventType}`, payload, source: "live" };
|
|
572
|
+
}
|
|
573
|
+
if (wp.kind === "SIGNAL") {
|
|
574
|
+
const name = wp.meta.signalName as string;
|
|
575
|
+
const isIter = wp.meta.iter === true;
|
|
576
|
+
const fixture = opts.waitForSignal?.[name];
|
|
577
|
+
if (fixture === undefined) return null;
|
|
578
|
+
if (isIter) {
|
|
579
|
+
return resolveIterableFixture(fixture, cursors.signal, name, at, "SIGNAL");
|
|
580
|
+
}
|
|
581
|
+
return { kind: "SIGNAL", resolvedAt: at, payload: fixture, source: "live" };
|
|
582
|
+
}
|
|
583
|
+
if (wp.kind === "MANUAL") {
|
|
584
|
+
const tokenId = wp.meta.tokenId as string;
|
|
585
|
+
const fixture = opts.waitForToken?.[tokenId];
|
|
586
|
+
if (fixture === undefined) return null;
|
|
587
|
+
return { kind: "MANUAL", resolvedAt: at, payload: fixture, source: "live" };
|
|
588
|
+
}
|
|
589
|
+
if (wp.kind === "RUN") {
|
|
590
|
+
const childWorkflowId = wp.meta.childWorkflowId as string;
|
|
591
|
+
const childInput = wp.meta.childInput;
|
|
592
|
+
// Allow test-level override: `invoke: { [childId]: value | (input) => value }`.
|
|
593
|
+
const override = opts.invoke?.[childWorkflowId];
|
|
594
|
+
if (override !== undefined) {
|
|
595
|
+
const payload = typeof override === "function"
|
|
596
|
+
? await (override as (input: unknown) => unknown | Promise<unknown>)(childInput)
|
|
597
|
+
: override;
|
|
598
|
+
parentEvents.push({
|
|
599
|
+
type: "child.resolved-from-fixture",
|
|
600
|
+
at: now(),
|
|
601
|
+
data: { childWorkflowId, payload },
|
|
602
|
+
});
|
|
603
|
+
return { kind: "RUN", resolvedAt: now(), payload, source: "replay" };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Otherwise run the child workflow in-process.
|
|
607
|
+
const child = getWorkflow(childWorkflowId);
|
|
608
|
+
if (!child) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`test harness: ctx.invoke target "${childWorkflowId}" is not registered. ` +
|
|
611
|
+
`Import the child workflow module, or provide a stub via TestOptions.invoke.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
parentEvents.push({
|
|
615
|
+
type: "child.started",
|
|
616
|
+
at: now(),
|
|
617
|
+
data: { childWorkflowId, input: childInput },
|
|
618
|
+
});
|
|
619
|
+
const childResult = await runWorkflowForTest(
|
|
620
|
+
child as unknown as WorkflowHandle<unknown, unknown>,
|
|
621
|
+
childInput,
|
|
622
|
+
{
|
|
623
|
+
steps: opts.steps,
|
|
624
|
+
waitForEvent: opts.waitForEvent,
|
|
625
|
+
waitForSignal: opts.waitForSignal,
|
|
626
|
+
waitForToken: opts.waitForToken,
|
|
627
|
+
invoke: opts.invoke,
|
|
628
|
+
env: opts.env,
|
|
629
|
+
environment: opts.environment,
|
|
630
|
+
now: opts.now,
|
|
631
|
+
random: opts.random,
|
|
632
|
+
maxInvocations: opts.maxInvocations,
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
parentEvents.push({
|
|
636
|
+
type: "child.finished",
|
|
637
|
+
at: now(),
|
|
638
|
+
data: { childWorkflowId, status: childResult.status, output: childResult.output },
|
|
639
|
+
});
|
|
640
|
+
if (childResult.status === "completed") {
|
|
641
|
+
return { kind: "RUN", resolvedAt: now(), payload: childResult.output, source: "replay" };
|
|
642
|
+
}
|
|
643
|
+
// Child failed / cancelled / compensated with error / compensation_failed.
|
|
644
|
+
const err = childResult.error ?? {
|
|
645
|
+
category: "USER_ERROR" as const,
|
|
646
|
+
code: "CHILD_RUN_ENDED",
|
|
647
|
+
message: `child run ended with status ${childResult.status}`,
|
|
648
|
+
};
|
|
649
|
+
return {
|
|
650
|
+
kind: "RUN",
|
|
651
|
+
resolvedAt: now(),
|
|
652
|
+
source: "replay",
|
|
653
|
+
error: err,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export interface FixtureCursors {
|
|
660
|
+
event: Map<string, number>;
|
|
661
|
+
signal: Map<string, number>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const STREAM_END_MARKER = { __voyantStreamEnd: true } as const;
|
|
665
|
+
|
|
666
|
+
function resolveIterableFixture(
|
|
667
|
+
fixture: unknown,
|
|
668
|
+
cursors: Map<string, number>,
|
|
669
|
+
key: string,
|
|
670
|
+
at: number,
|
|
671
|
+
kind: "EVENT" | "SIGNAL",
|
|
672
|
+
matchedEventId?: string,
|
|
673
|
+
): WaitpointResolutionEntry {
|
|
674
|
+
const array = Array.isArray(fixture) ? fixture : [fixture];
|
|
675
|
+
const idx = cursors.get(key) ?? 0;
|
|
676
|
+
cursors.set(key, idx + 1);
|
|
677
|
+
if (idx >= array.length) {
|
|
678
|
+
// Stream is exhausted — signal the tenant-side iterator to terminate.
|
|
679
|
+
return {
|
|
680
|
+
kind,
|
|
681
|
+
resolvedAt: at,
|
|
682
|
+
payload: STREAM_END_MARKER,
|
|
683
|
+
source: "replay",
|
|
684
|
+
matchedEventId,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
kind,
|
|
689
|
+
resolvedAt: at,
|
|
690
|
+
payload: array[idx],
|
|
691
|
+
source: "live",
|
|
692
|
+
matchedEventId,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function applyMetadata(
|
|
697
|
+
state: Record<string, MetadataValue>,
|
|
698
|
+
updates: MetadataMutation[],
|
|
699
|
+
): void {
|
|
700
|
+
for (const u of updates) {
|
|
701
|
+
switch (u.op) {
|
|
702
|
+
case "set":
|
|
703
|
+
state[u.key] = u.value as MetadataValue;
|
|
704
|
+
break;
|
|
705
|
+
case "increment": {
|
|
706
|
+
const cur = typeof state[u.key] === "number" ? (state[u.key] as number) : 0;
|
|
707
|
+
state[u.key] = cur + ((u.value as number) ?? 1);
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
case "append": {
|
|
711
|
+
const cur = Array.isArray(state[u.key]) ? (state[u.key] as MetadataValue[]) : [];
|
|
712
|
+
state[u.key] = [...cur, u.value as MetadataValue];
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case "remove":
|
|
716
|
+
delete state[u.key];
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function stepsFromJournal(j: JournalSlice): TestResult<unknown>["steps"] {
|
|
723
|
+
return Object.entries(j.stepResults).map(([id, entry]) => ({
|
|
724
|
+
id,
|
|
725
|
+
status: entry.status,
|
|
726
|
+
duration: entry.finishedAt - entry.startedAt,
|
|
727
|
+
output: entry.output,
|
|
728
|
+
}));
|
|
729
|
+
}
|