@voyant-travel/workflows-orchestrator 0.107.10
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 +76 -0
- package/dist/abort-registry.d.ts +6 -0
- package/dist/abort-registry.d.ts.map +1 -0
- package/dist/abort-registry.js +37 -0
- package/dist/concurrency.d.ts +31 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +145 -0
- package/dist/drive.d.ts +67 -0
- package/dist/drive.d.ts.map +1 -0
- package/dist/drive.js +373 -0
- package/dist/driver-inmemory.d.ts +30 -0
- package/dist/driver-inmemory.d.ts.map +1 -0
- package/dist/driver-inmemory.js +394 -0
- package/dist/event-router.d.ts +51 -0
- package/dist/event-router.d.ts.map +1 -0
- package/dist/event-router.js +68 -0
- package/dist/http-step-handler.d.ts +25 -0
- package/dist/http-step-handler.d.ts.map +1 -0
- package/dist/http-step-handler.js +78 -0
- package/dist/in-memory-store.d.ts +5 -0
- package/dist/in-memory-store.d.ts.map +1 -0
- package/dist/in-memory-store.js +41 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/journal-helpers.d.ts +3 -0
- package/dist/journal-helpers.d.ts.map +1 -0
- package/dist/journal-helpers.js +9 -0
- package/dist/orchestrator.d.ts +116 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +411 -0
- package/dist/resume-run.d.ts +40 -0
- package/dist/resume-run.d.ts.map +1 -0
- package/dist/resume-run.js +119 -0
- package/dist/schedule.d.ts +51 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +243 -0
- package/dist/testing/driver-compliance.d.ts +58 -0
- package/dist/testing/driver-compliance.d.ts.map +1 -0
- package/dist/testing/driver-compliance.js +667 -0
- package/dist/types.d.ts +182 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +51 -0
- package/src/__tests__/orchestrator-test-support.ts +18 -0
- package/src/abort-registry.ts +41 -0
- package/src/concurrency.ts +217 -0
- package/src/drive.ts +477 -0
- package/src/driver-inmemory.ts +511 -0
- package/src/event-router.ts +120 -0
- package/src/http-step-handler.ts +112 -0
- package/src/in-memory-store.ts +44 -0
- package/src/index.ts +73 -0
- package/src/journal-helpers.ts +11 -0
- package/src/orchestrator.ts +527 -0
- package/src/resume-run.ts +162 -0
- package/src/schedule.ts +310 -0
- package/src/testing/driver-compliance.ts +800 -0
- package/src/types.ts +201 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// agent-quality: file-size exception -- owner: workflows-orchestrator; existing module stays co-located until a dedicated split preserves behavior and tests.
|
|
2
|
+
// In-memory WorkflowDriver — primarily for tests, also for short-lived
|
|
3
|
+
// scripts and the parameterized compliance suite.
|
|
4
|
+
//
|
|
5
|
+
// Wraps the existing pure orchestrator functions (trigger / resume / cancel)
|
|
6
|
+
// with `createInMemoryRunStore` for state and an in-process `StepHandler`
|
|
7
|
+
// glue (`handleStepRequest` from `@voyant-travel/workflows/handler`). Manifests
|
|
8
|
+
// live in a `Map<environment, { manifest, versionId }>`.
|
|
9
|
+
//
|
|
10
|
+
// State lives in the closure returned by `createInMemoryDriver` — every
|
|
11
|
+
// call to the factory yields a fresh, isolated driver. No global state.
|
|
12
|
+
//
|
|
13
|
+
// Authoritative architecture: docs/architecture/workflows-runtime-architecture.md §6.
|
|
14
|
+
import { ManifestNotRegisteredError, } from "@voyant-travel/workflows/driver";
|
|
15
|
+
import { deriveStableEventId } from "@voyant-travel/workflows/events";
|
|
16
|
+
import { handleStepRequest } from "@voyant-travel/workflows/handler";
|
|
17
|
+
import { createInProcessConcurrencyCoordinator, } from "./concurrency.js";
|
|
18
|
+
import { routeEvent } from "./event-router.js";
|
|
19
|
+
import { createInMemoryRunStore } from "./in-memory-store.js";
|
|
20
|
+
import { cancel as orchestratorCancel, trigger as orchestratorTrigger, resumeDueAlarms, } from "./orchestrator.js";
|
|
21
|
+
import { createScheduler, manifestScheduleSources } from "./schedule.js";
|
|
22
|
+
const DEFAULT_TENANT_META = {
|
|
23
|
+
tenantId: "default",
|
|
24
|
+
projectId: "default",
|
|
25
|
+
organizationId: "default",
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Build an in-memory driver factory. The factory closes over its
|
|
29
|
+
* options and returns a fresh `WorkflowDriver` when `createApp()`
|
|
30
|
+
* (or a test) calls it with `DriverFactoryDeps`.
|
|
31
|
+
*
|
|
32
|
+
* Usage in tests:
|
|
33
|
+
*
|
|
34
|
+
* const driver = createInMemoryDriver()(testFactoryDeps())
|
|
35
|
+
* await driver.registerManifest({ environment: "production", manifest })
|
|
36
|
+
* await driver.trigger(myWorkflow, { … }, { idempotencyKey: "abc" })
|
|
37
|
+
*/
|
|
38
|
+
export function createInMemoryDriver(opts = {}) {
|
|
39
|
+
return (deps) => {
|
|
40
|
+
const store = createInMemoryRunStore();
|
|
41
|
+
const manifests = new Map();
|
|
42
|
+
const scheduleRunners = new Map();
|
|
43
|
+
const now = opts.now ?? deps.now ?? (() => Date.now());
|
|
44
|
+
const tenantMeta = opts.tenantMeta ?? DEFAULT_TENANT_META;
|
|
45
|
+
const defaultEnv = opts.defaultEnvironment ?? "development";
|
|
46
|
+
// Wire the framework-supplied service container through to step bodies.
|
|
47
|
+
// The handler closes over `deps.services` so every step invocation
|
|
48
|
+
// surfaces it as `ctx.services` inside the workflow body.
|
|
49
|
+
const handler = opts.handler ??
|
|
50
|
+
(async (req, stepOpts) => handleStepRequest(req, { services: deps.services }, stepOpts));
|
|
51
|
+
let shuttingDown = false;
|
|
52
|
+
const concurrency = createInProcessConcurrencyCoordinator({
|
|
53
|
+
async cancelRun(runId, reason) {
|
|
54
|
+
const out = await orchestratorCancel({ runId, reason }, { store, handler, now });
|
|
55
|
+
if (out.ok && isTerminal(out.record.status)) {
|
|
56
|
+
concurrency.releaseRun(out.record);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
// ---- WorkflowDriver implementation ----
|
|
61
|
+
async function registerManifest(args) {
|
|
62
|
+
assertNotShutdown(shuttingDown);
|
|
63
|
+
manifests.set(args.environment, {
|
|
64
|
+
manifest: args.manifest,
|
|
65
|
+
versionId: args.manifest.versionId,
|
|
66
|
+
});
|
|
67
|
+
startScheduleRunner(args.environment, args.manifest);
|
|
68
|
+
return { versionId: args.manifest.versionId };
|
|
69
|
+
}
|
|
70
|
+
async function getManifest(args) {
|
|
71
|
+
return manifests.get(args.environment)?.manifest ?? null;
|
|
72
|
+
}
|
|
73
|
+
async function trigger(workflow, input, triggerOpts) {
|
|
74
|
+
assertNotShutdown(shuttingDown);
|
|
75
|
+
const workflowId = typeof workflow === "string" ? workflow : workflow.id;
|
|
76
|
+
const env = triggerOpts?.environment ?? defaultEnv;
|
|
77
|
+
const policy = resolveConcurrencyPolicy(workflow, workflowId, env, manifests);
|
|
78
|
+
// The orchestrator core handles idempotencyKey natively (deterministic
|
|
79
|
+
// runId derivation from `(workflowId, idempotencyKey)`); the driver just
|
|
80
|
+
// forwards the field. Persistent stores like `voyant_snapshot_runs`
|
|
81
|
+
// additionally read `RunRecord.idempotencyKey` to populate their own
|
|
82
|
+
// dedup column.
|
|
83
|
+
const record = await triggerRecord({
|
|
84
|
+
workflowId,
|
|
85
|
+
workflowVersion: triggerOpts?.lockToVersion ?? "v1",
|
|
86
|
+
input: input,
|
|
87
|
+
tenantMeta,
|
|
88
|
+
environment: env,
|
|
89
|
+
tags: triggerOpts?.tags,
|
|
90
|
+
idempotencyKey: triggerOpts?.idempotencyKey,
|
|
91
|
+
delay: triggerOpts?.delay,
|
|
92
|
+
priority: triggerOpts?.priority,
|
|
93
|
+
}, policy);
|
|
94
|
+
scheduleDelayedRun(record);
|
|
95
|
+
return runRecordToRun(record);
|
|
96
|
+
}
|
|
97
|
+
async function ingestEvent(args) {
|
|
98
|
+
assertNotShutdown(shuttingDown);
|
|
99
|
+
const stored = manifests.get(args.environment);
|
|
100
|
+
if (!stored) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: "manifest_not_registered",
|
|
104
|
+
message: new ManifestNotRegisteredError(args.environment).message,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const eventId = await ensureEventId(args.envelope);
|
|
108
|
+
const routed = routeEvent({
|
|
109
|
+
manifest: stored.manifest,
|
|
110
|
+
envelope: args.envelope,
|
|
111
|
+
eventId,
|
|
112
|
+
idempotencyOverride: args.idempotencyKey,
|
|
113
|
+
});
|
|
114
|
+
const matches = [];
|
|
115
|
+
let anyTriggered = false;
|
|
116
|
+
let anyFailed = false;
|
|
117
|
+
for (const entry of routed) {
|
|
118
|
+
if (entry.status === "skipped") {
|
|
119
|
+
matches.push({
|
|
120
|
+
filterId: entry.filterId,
|
|
121
|
+
status: "skipped",
|
|
122
|
+
reason: entry.reason,
|
|
123
|
+
details: entry.details,
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const record = await triggerRecord({
|
|
129
|
+
workflowId: entry.targetWorkflowId,
|
|
130
|
+
workflowVersion: "v1",
|
|
131
|
+
input: entry.input,
|
|
132
|
+
tenantMeta,
|
|
133
|
+
environment: args.environment,
|
|
134
|
+
idempotencyKey: entry.idempotencyKey,
|
|
135
|
+
triggeredBy: {
|
|
136
|
+
kind: "event",
|
|
137
|
+
eventId,
|
|
138
|
+
eventType: args.envelope.name,
|
|
139
|
+
filterId: entry.filterId,
|
|
140
|
+
},
|
|
141
|
+
}, resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, manifests));
|
|
142
|
+
scheduleDelayedRun(record);
|
|
143
|
+
matches.push({
|
|
144
|
+
filterId: entry.filterId,
|
|
145
|
+
targetWorkflowId: entry.targetWorkflowId,
|
|
146
|
+
runId: record.id,
|
|
147
|
+
idempotencyKey: entry.idempotencyKey,
|
|
148
|
+
status: "queued",
|
|
149
|
+
});
|
|
150
|
+
anyTriggered = true;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
matches.push({
|
|
154
|
+
filterId: entry.filterId,
|
|
155
|
+
targetWorkflowId: entry.targetWorkflowId,
|
|
156
|
+
status: "error",
|
|
157
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
158
|
+
});
|
|
159
|
+
anyFailed = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Drivers return ok:true if at least one match queued; ok:false only if
|
|
163
|
+
// every match errored (per architecture doc §15.5).
|
|
164
|
+
if (matches.length > 0 && !anyTriggered && anyFailed) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: "trigger_failed_for_all_matches",
|
|
168
|
+
message: "every matched filter failed to trigger",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, eventId, matches };
|
|
172
|
+
}
|
|
173
|
+
async function shutdown() {
|
|
174
|
+
shuttingDown = true;
|
|
175
|
+
for (const runner of scheduleRunners.values()) {
|
|
176
|
+
runner.stop();
|
|
177
|
+
}
|
|
178
|
+
scheduleRunners.clear();
|
|
179
|
+
}
|
|
180
|
+
function startScheduleRunner(environment, manifest) {
|
|
181
|
+
const existing = scheduleRunners.get(environment);
|
|
182
|
+
existing?.stop();
|
|
183
|
+
scheduleRunners.delete(environment);
|
|
184
|
+
if (opts.disableScheduleRunner)
|
|
185
|
+
return;
|
|
186
|
+
const sources = manifestScheduleSources(manifest);
|
|
187
|
+
if (sources.length === 0)
|
|
188
|
+
return;
|
|
189
|
+
const runner = createScheduler({
|
|
190
|
+
sources,
|
|
191
|
+
environment,
|
|
192
|
+
now,
|
|
193
|
+
tickMs: opts.schedulePollIntervalMs,
|
|
194
|
+
onFire: async ({ workflowId, input, scheduleId, fireAt }) => {
|
|
195
|
+
assertNotShutdown(shuttingDown);
|
|
196
|
+
const record = await triggerRecord({
|
|
197
|
+
workflowId,
|
|
198
|
+
workflowVersion: "v1",
|
|
199
|
+
input,
|
|
200
|
+
tenantMeta,
|
|
201
|
+
environment,
|
|
202
|
+
idempotencyKey: `${scheduleId}:${fireAt}`,
|
|
203
|
+
triggeredBy: { kind: "schedule", scheduleId },
|
|
204
|
+
}, resolveConcurrencyPolicy(workflowId, workflowId, environment, manifests));
|
|
205
|
+
scheduleDelayedRun(record);
|
|
206
|
+
},
|
|
207
|
+
logger: (level, msg, data) => deps.logger(level, msg, data),
|
|
208
|
+
});
|
|
209
|
+
if (runner.sourceCount() === 0)
|
|
210
|
+
return;
|
|
211
|
+
runner.start();
|
|
212
|
+
scheduleRunners.set(environment, runner);
|
|
213
|
+
}
|
|
214
|
+
function scheduleDelayedRun(record) {
|
|
215
|
+
if (record.status !== "waiting")
|
|
216
|
+
return;
|
|
217
|
+
const wakeAt = earliestWakeAt(record);
|
|
218
|
+
if (wakeAt === undefined)
|
|
219
|
+
return;
|
|
220
|
+
const delayMs = Math.max(0, wakeAt - now());
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
void resumeDueAlarms({ runId: record.id }, { store, handler, now }).then(async (resumed) => {
|
|
223
|
+
if (!resumed) {
|
|
224
|
+
const latest = await store.get(record.id);
|
|
225
|
+
if (latest)
|
|
226
|
+
scheduleDelayedRun(latest);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (isTerminal(resumed.status)) {
|
|
230
|
+
concurrency.releaseRun(resumed);
|
|
231
|
+
}
|
|
232
|
+
scheduleDelayedRun(resumed);
|
|
233
|
+
});
|
|
234
|
+
}, delayMs);
|
|
235
|
+
timer.unref?.();
|
|
236
|
+
}
|
|
237
|
+
async function triggerRecord(args, policy) {
|
|
238
|
+
return concurrency.run({
|
|
239
|
+
workflowId: args.workflowId,
|
|
240
|
+
input: args.input,
|
|
241
|
+
policy,
|
|
242
|
+
holderId: concurrencyHolderId(args),
|
|
243
|
+
}, (hooks) => orchestratorTrigger({
|
|
244
|
+
...args,
|
|
245
|
+
onRunRecordCreated: hooks.onRunRecordCreated,
|
|
246
|
+
}, { store, handler, now }));
|
|
247
|
+
}
|
|
248
|
+
// ---- WorkflowAdmin (partial; sufficient for compliance tests) ----
|
|
249
|
+
const admin = {
|
|
250
|
+
async listRuns(listOpts) {
|
|
251
|
+
const filterEnv = listOpts?.environment;
|
|
252
|
+
const filterStatus = normalizeStatusFilter(listOpts?.status);
|
|
253
|
+
const filterWorkflow = listOpts?.workflowId;
|
|
254
|
+
const filterTag = listOpts?.tag;
|
|
255
|
+
const filterSince = toEpoch(listOpts?.since);
|
|
256
|
+
const filterUntil = toEpoch(listOpts?.until);
|
|
257
|
+
const limit = listOpts?.limit ?? 100;
|
|
258
|
+
const results = [];
|
|
259
|
+
for (const rec of await store.list({})) {
|
|
260
|
+
if (filterEnv && rec.environment !== filterEnv)
|
|
261
|
+
continue;
|
|
262
|
+
if (filterStatus && !filterStatus.includes(rec.status))
|
|
263
|
+
continue;
|
|
264
|
+
if (filterWorkflow && rec.workflowId !== filterWorkflow)
|
|
265
|
+
continue;
|
|
266
|
+
if (filterTag && !rec.tags.includes(filterTag))
|
|
267
|
+
continue;
|
|
268
|
+
if (filterSince !== undefined && rec.startedAt < filterSince)
|
|
269
|
+
continue;
|
|
270
|
+
if (filterUntil !== undefined && rec.startedAt > filterUntil)
|
|
271
|
+
continue;
|
|
272
|
+
results.push(runRecordToSummary(rec));
|
|
273
|
+
}
|
|
274
|
+
results.sort((a, b) => b.startedAt - a.startedAt);
|
|
275
|
+
const page = results.slice(0, limit);
|
|
276
|
+
const nextCursor = results.length > limit ? String(limit) : undefined;
|
|
277
|
+
return { runs: page, nextCursor };
|
|
278
|
+
},
|
|
279
|
+
async getRun(runId) {
|
|
280
|
+
const rec = await store.get(runId);
|
|
281
|
+
return rec ? runRecordToDetail(rec) : null;
|
|
282
|
+
},
|
|
283
|
+
async cancelRun(runId, cancelOpts) {
|
|
284
|
+
// The orchestrator core's cancel() does NOT run compensations by default
|
|
285
|
+
// (per workflows-runtime-architecture.md §21.21). The `compensate` flag
|
|
286
|
+
// is accepted but no-ops in v1; honoring it would require an engine
|
|
287
|
+
// behavior change tracked separately.
|
|
288
|
+
void cancelOpts?.compensate;
|
|
289
|
+
const out = await orchestratorCancel({ runId, reason: cancelOpts?.reason }, { store, handler, now });
|
|
290
|
+
if (out.ok && isTerminal(out.record.status)) {
|
|
291
|
+
concurrency.releaseRun(out.record);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
// streamRun is not implemented for InMemory in PR1. Dashboards that
|
|
295
|
+
// probe `driver.admin.streamRun` get `undefined` and fall back to
|
|
296
|
+
// their non-streaming view. Mode 2 + Mode 1 implement this in later
|
|
297
|
+
// PRs against their respective journal sources.
|
|
298
|
+
};
|
|
299
|
+
return {
|
|
300
|
+
registerManifest,
|
|
301
|
+
trigger,
|
|
302
|
+
ingestEvent,
|
|
303
|
+
getManifest,
|
|
304
|
+
shutdown,
|
|
305
|
+
admin,
|
|
306
|
+
};
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function concurrencyHolderId(args) {
|
|
310
|
+
if (args.runId !== undefined)
|
|
311
|
+
return args.runId;
|
|
312
|
+
if (args.idempotencyKey !== undefined)
|
|
313
|
+
return `idem-${args.workflowId}-${args.idempotencyKey}`;
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
function earliestWakeAt(record) {
|
|
317
|
+
let earliest;
|
|
318
|
+
for (const waitpoint of record.pendingWaitpoints) {
|
|
319
|
+
if (waitpoint.kind !== "DATETIME")
|
|
320
|
+
continue;
|
|
321
|
+
const wakeAt = typeof waitpoint.meta.wakeAt === "number" ? waitpoint.meta.wakeAt : undefined;
|
|
322
|
+
if (wakeAt === undefined)
|
|
323
|
+
continue;
|
|
324
|
+
earliest = earliest === undefined ? wakeAt : Math.min(earliest, wakeAt);
|
|
325
|
+
}
|
|
326
|
+
return earliest;
|
|
327
|
+
}
|
|
328
|
+
// ---- Helpers ----
|
|
329
|
+
function assertNotShutdown(shuttingDown) {
|
|
330
|
+
if (shuttingDown) {
|
|
331
|
+
throw new Error("InMemoryDriver: shutdown() has been called; new operations are refused.");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function resolveConcurrencyPolicy(workflow, workflowId, environment, manifests) {
|
|
335
|
+
if (typeof workflow !== "string" && workflow.config?.concurrency) {
|
|
336
|
+
return workflow.config.concurrency;
|
|
337
|
+
}
|
|
338
|
+
const manifest = manifests.get(environment)?.manifest;
|
|
339
|
+
return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency;
|
|
340
|
+
}
|
|
341
|
+
function isTerminal(status) {
|
|
342
|
+
return (status === "completed" ||
|
|
343
|
+
status === "failed" ||
|
|
344
|
+
status === "cancelled" ||
|
|
345
|
+
status === "compensated" ||
|
|
346
|
+
status === "compensation_failed");
|
|
347
|
+
}
|
|
348
|
+
async function ensureEventId(envelope) {
|
|
349
|
+
if (envelope.metadata?.eventId)
|
|
350
|
+
return envelope.metadata.eventId;
|
|
351
|
+
// Content-derived fallback per architecture doc §15.2. Two ingests
|
|
352
|
+
// with byte-equal envelopes produce the same id, so retries dedupe
|
|
353
|
+
// through the driver's `${filterId}:${eventId}` idempotency key.
|
|
354
|
+
return deriveStableEventId(envelope);
|
|
355
|
+
}
|
|
356
|
+
function runRecordToRun(rec) {
|
|
357
|
+
return {
|
|
358
|
+
id: rec.id,
|
|
359
|
+
workflowId: rec.workflowId,
|
|
360
|
+
status: rec.status,
|
|
361
|
+
startedAt: rec.startedAt,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function runRecordToSummary(rec) {
|
|
365
|
+
return {
|
|
366
|
+
id: rec.id,
|
|
367
|
+
workflowId: rec.workflowId,
|
|
368
|
+
status: rec.status,
|
|
369
|
+
startedAt: rec.startedAt,
|
|
370
|
+
completedAt: rec.completedAt,
|
|
371
|
+
tags: [...rec.tags],
|
|
372
|
+
environment: rec.environment,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function runRecordToDetail(rec) {
|
|
376
|
+
return {
|
|
377
|
+
...runRecordToSummary(rec),
|
|
378
|
+
version: rec.workflowVersion,
|
|
379
|
+
input: rec.input,
|
|
380
|
+
output: rec.output,
|
|
381
|
+
error: rec.error,
|
|
382
|
+
durationMs: rec.completedAt !== undefined ? Math.max(0, rec.completedAt - rec.startedAt) : undefined,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function normalizeStatusFilter(s) {
|
|
386
|
+
if (s === undefined)
|
|
387
|
+
return undefined;
|
|
388
|
+
return Array.isArray(s) ? s : [s];
|
|
389
|
+
}
|
|
390
|
+
function toEpoch(v) {
|
|
391
|
+
if (v === undefined)
|
|
392
|
+
return undefined;
|
|
393
|
+
return typeof v === "number" ? v : v.getTime();
|
|
394
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type PredicateEnvelope } from "@voyant-travel/workflows/events";
|
|
2
|
+
import type { WorkflowManifest } from "@voyant-travel/workflows/protocol";
|
|
3
|
+
/**
|
|
4
|
+
* Outcome of routing a single envelope through every filter in a manifest.
|
|
5
|
+
* Each match describes exactly enough for a driver to call `trigger()`:
|
|
6
|
+
* - `targetWorkflowId` and `input` are the trigger args.
|
|
7
|
+
* - `idempotencyKey` derives from `${filterId}:${eventId}` so retries of
|
|
8
|
+
* the same envelope produce a stable run regardless of which driver
|
|
9
|
+
* applies the trigger.
|
|
10
|
+
*/
|
|
11
|
+
export type RouterMatch = {
|
|
12
|
+
filterId: string;
|
|
13
|
+
targetWorkflowId: string;
|
|
14
|
+
input: unknown;
|
|
15
|
+
idempotencyKey: string;
|
|
16
|
+
status: "matched";
|
|
17
|
+
} | {
|
|
18
|
+
filterId: string;
|
|
19
|
+
status: "skipped";
|
|
20
|
+
reason: "where_eval_error" | "input_projection_error";
|
|
21
|
+
details?: string;
|
|
22
|
+
};
|
|
23
|
+
export interface RouteEventArgs {
|
|
24
|
+
manifest: WorkflowManifest;
|
|
25
|
+
envelope: PredicateEnvelope;
|
|
26
|
+
/**
|
|
27
|
+
* Stable id for the envelope. Drivers derive this from
|
|
28
|
+
* `metadata.eventId` (when set) or fall back to a content hash; passing
|
|
29
|
+
* it in keeps the router pure.
|
|
30
|
+
*/
|
|
31
|
+
eventId: string;
|
|
32
|
+
/**
|
|
33
|
+
* Optional caller-supplied idempotency override (per
|
|
34
|
+
* `IngestEventArgs.idempotencyKey`). When set, the per-match key
|
|
35
|
+
* becomes `${filterId}:${suppliedKey}` instead of
|
|
36
|
+
* `${filterId}:${eventId}`.
|
|
37
|
+
*/
|
|
38
|
+
idempotencyOverride?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Route an envelope through a manifest. Pure: no IO, no side effects.
|
|
42
|
+
* Returns one entry per filter that targets the envelope's eventType, in
|
|
43
|
+
* the order they appear in `manifest.eventFilters` (which is itself
|
|
44
|
+
* id-sorted from `buildManifest`).
|
|
45
|
+
*
|
|
46
|
+
* `where` evaluation errors and `input` projection errors are isolated to
|
|
47
|
+
* the offending filter — other filters still produce matches. Drivers
|
|
48
|
+
* surface skips as `IngestMatch.status === "skipped"` in the response.
|
|
49
|
+
*/
|
|
50
|
+
export declare function routeEvent(args: RouteEventArgs): RouterMatch[];
|
|
51
|
+
//# sourceMappingURL=event-router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-router.d.ts","sourceRoot":"","sources":["../src/event-router.ts"],"names":[],"mappings":"AAUA,OAAO,EAGL,KAAK,iBAAiB,EAGvB,MAAM,iCAAiC,CAAA;AACxC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAA;AAIzE;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GACnB;IACE,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,MAAM,CAAA;IACxB,KAAK,EAAE,OAAO,CAAA;IACd,cAAc,EAAE,MAAM,CAAA;IACtB,MAAM,EAAE,SAAS,CAAA;CAClB,GACD;IACE,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,SAAS,CAAA;IACjB,MAAM,EAAE,kBAAkB,GAAG,wBAAwB,CAAA;IACrD,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAEL,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,QAAQ,EAAE,iBAAiB,CAAA;IAC3B;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAID;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,WAAW,EAAE,CA6C9D"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Pure event router — given a manifest and an envelope, decide which
|
|
2
|
+
// filters match and produce the per-match descriptors drivers feed into
|
|
3
|
+
// `trigger()`.
|
|
4
|
+
//
|
|
5
|
+
// Drivers (InMemory, Mode 2 / Postgres, Mode 1 / CF edge) wrap this in
|
|
6
|
+
// their own `ingestEvent` impl: they fetch the manifest from their store,
|
|
7
|
+
// call `routeEvent(...)`, then invoke `trigger()` per match.
|
|
8
|
+
//
|
|
9
|
+
// Architecture: docs/architecture/workflows-runtime-architecture.md §15.
|
|
10
|
+
import { evaluatePredicate, projectInput, } from "@voyant-travel/workflows/events";
|
|
11
|
+
// ---- Public API ----
|
|
12
|
+
/**
|
|
13
|
+
* Route an envelope through a manifest. Pure: no IO, no side effects.
|
|
14
|
+
* Returns one entry per filter that targets the envelope's eventType, in
|
|
15
|
+
* the order they appear in `manifest.eventFilters` (which is itself
|
|
16
|
+
* id-sorted from `buildManifest`).
|
|
17
|
+
*
|
|
18
|
+
* `where` evaluation errors and `input` projection errors are isolated to
|
|
19
|
+
* the offending filter — other filters still produce matches. Drivers
|
|
20
|
+
* surface skips as `IngestMatch.status === "skipped"` in the response.
|
|
21
|
+
*/
|
|
22
|
+
export function routeEvent(args) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const filter of args.manifest.eventFilters) {
|
|
25
|
+
if (filter.eventType !== args.envelope.name)
|
|
26
|
+
continue;
|
|
27
|
+
// Predicate gate.
|
|
28
|
+
if (filter.where !== undefined) {
|
|
29
|
+
try {
|
|
30
|
+
const matched = evaluatePredicate(filter.where, args.envelope);
|
|
31
|
+
if (!matched)
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
out.push({
|
|
36
|
+
filterId: filter.id,
|
|
37
|
+
status: "skipped",
|
|
38
|
+
reason: "where_eval_error",
|
|
39
|
+
details: err instanceof Error ? err.message : String(err),
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Input projection.
|
|
45
|
+
let input;
|
|
46
|
+
try {
|
|
47
|
+
input = projectInput(filter.input, args.envelope);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
out.push({
|
|
51
|
+
filterId: filter.id,
|
|
52
|
+
status: "skipped",
|
|
53
|
+
reason: "input_projection_error",
|
|
54
|
+
details: err instanceof Error ? err.message : String(err),
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const baseKey = args.idempotencyOverride ?? args.eventId;
|
|
59
|
+
out.push({
|
|
60
|
+
filterId: filter.id,
|
|
61
|
+
targetWorkflowId: filter.targetWorkflowId,
|
|
62
|
+
input,
|
|
63
|
+
idempotencyKey: `${filter.id}:${baseKey}`,
|
|
64
|
+
status: "matched",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { StepHandler, WorkflowStepRequest } from "./types.js";
|
|
2
|
+
export interface HttpStepTarget {
|
|
3
|
+
url: string;
|
|
4
|
+
fetch(request: Request): Promise<Response>;
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface HttpStepHandlerDeps {
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the transport target for this workflow-step invocation.
|
|
10
|
+
* Adapters can map `tenantMeta.tenantScript` to a dispatch-namespace
|
|
11
|
+
* binding, a service URL, or a local proxy.
|
|
12
|
+
*/
|
|
13
|
+
resolveTarget: (req: WorkflowStepRequest) => HttpStepTarget | Promise<HttpStepTarget>;
|
|
14
|
+
/** Optional HMAC signer for the X-Voyant-Dispatch-Auth header. */
|
|
15
|
+
sign?: (body: string, req: WorkflowStepRequest) => Promise<string> | string;
|
|
16
|
+
/** Optional logger for step-level observability. */
|
|
17
|
+
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build a StepHandler that serializes WorkflowStepRequest over HTTP.
|
|
21
|
+
* The concrete transport target is adapter-specific; the request/response
|
|
22
|
+
* mapping is shared across Cloudflare, Node, and future adapters.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createHttpStepHandler(deps: HttpStepHandlerDeps): StepHandler;
|
|
25
|
+
//# sourceMappingURL=http-step-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-step-handler.d.ts","sourceRoot":"","sources":["../src/http-step-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAwB,MAAM,YAAY,CAAA;AAQxF,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAA;IACrF,kEAAkE;IAClE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,mBAAmB,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAA;IAC3E,oDAAoD;IACpD,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChF;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,mBAAmB,GAAG,WAAW,CA+D5E"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "@voyant-travel/workflows/protocol";
|
|
2
|
+
/**
|
|
3
|
+
* Build a StepHandler that serializes WorkflowStepRequest over HTTP.
|
|
4
|
+
* The concrete transport target is adapter-specific; the request/response
|
|
5
|
+
* mapping is shared across Cloudflare, Node, and future adapters.
|
|
6
|
+
*/
|
|
7
|
+
export function createHttpStepHandler(deps) {
|
|
8
|
+
return async (req, stepOpts) => {
|
|
9
|
+
const body = JSON.stringify(req);
|
|
10
|
+
const headers = {
|
|
11
|
+
"content-type": "application/json; charset=utf-8",
|
|
12
|
+
"x-voyant-protocol": String(PROTOCOL_VERSION),
|
|
13
|
+
};
|
|
14
|
+
if (deps.sign) {
|
|
15
|
+
headers["x-voyant-dispatch-auth"] = await deps.sign(body, req);
|
|
16
|
+
}
|
|
17
|
+
const target = await deps.resolveTarget(req);
|
|
18
|
+
deps.logger?.("info", "http-step: invoking tenant step", {
|
|
19
|
+
target: target.label ?? target.url,
|
|
20
|
+
runId: req.runId,
|
|
21
|
+
workflowId: req.workflowId,
|
|
22
|
+
invocation: req.invocationCount,
|
|
23
|
+
});
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await target.fetch(new Request(target.url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers,
|
|
29
|
+
body,
|
|
30
|
+
signal: stepOpts?.signal,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
deps.logger?.("error", "http-step: tenant fetch threw", {
|
|
35
|
+
target: target.label ?? target.url,
|
|
36
|
+
runId: req.runId,
|
|
37
|
+
error: err instanceof Error ? err.message : String(err),
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
status: 502,
|
|
41
|
+
body: {
|
|
42
|
+
error: "tenant_unreachable",
|
|
43
|
+
message: err instanceof Error ? err.message : String(err),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return {
|
|
54
|
+
status: 502,
|
|
55
|
+
body: {
|
|
56
|
+
error: "tenant_invalid_response",
|
|
57
|
+
message: `tenant returned non-JSON body (HTTP ${response.status})`,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (response.status !== 200) {
|
|
62
|
+
return { status: response.status, body: toErrorBody(parsed, response.status) };
|
|
63
|
+
}
|
|
64
|
+
return { status: 200, body: parsed };
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function toErrorBody(parsed, fallbackStatus) {
|
|
68
|
+
if (parsed !== null &&
|
|
69
|
+
typeof parsed === "object" &&
|
|
70
|
+
typeof parsed.error === "string" &&
|
|
71
|
+
typeof parsed.message === "string") {
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
error: "tenant_error",
|
|
76
|
+
message: `tenant returned HTTP ${fallbackStatus}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OrchestratorRunStatus, RunRecordStore } from "./types.js";
|
|
2
|
+
export declare function createInMemoryRunStore(): RunRecordStore;
|
|
3
|
+
/** Keep the unused import happy — guards against accidental type-only drift. */
|
|
4
|
+
export type { OrchestratorRunStatus };
|
|
5
|
+
//# sourceMappingURL=in-memory-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-memory-store.d.ts","sourceRoot":"","sources":["../src/in-memory-store.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAa,cAAc,EAAE,MAAM,YAAY,CAAA;AAElF,wBAAgB,sBAAsB,IAAI,cAAc,CA8BvD;AAMD,gFAAgF;AAChF,YAAY,EAAE,qBAAqB,EAAE,CAAA"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A pure in-memory RunRecordStore. Useful for tests and local-only
|
|
2
|
+
// orchestrator harnesses. The production store is Postgres-backed
|
|
3
|
+
// and lives in voyant-cloud.
|
|
4
|
+
export function createInMemoryRunStore() {
|
|
5
|
+
const records = new Map();
|
|
6
|
+
return {
|
|
7
|
+
async get(id) {
|
|
8
|
+
const r = records.get(id);
|
|
9
|
+
return r ? clone(r) : undefined;
|
|
10
|
+
},
|
|
11
|
+
async save(record) {
|
|
12
|
+
records.set(record.id, clone(record));
|
|
13
|
+
return clone(record);
|
|
14
|
+
},
|
|
15
|
+
async tryInsert(record) {
|
|
16
|
+
// Atomic-by-construction: Map.has + Map.set inside a single
|
|
17
|
+
// microtask. Concurrent `tryInsert(idA)` calls all schedule on the
|
|
18
|
+
// same JS event loop and only the first one observes the slot
|
|
19
|
+
// empty — subsequent callers see the inserted record.
|
|
20
|
+
const existing = records.get(record.id);
|
|
21
|
+
if (existing)
|
|
22
|
+
return { record: clone(existing), created: false };
|
|
23
|
+
records.set(record.id, clone(record));
|
|
24
|
+
return { record: clone(record), created: true };
|
|
25
|
+
},
|
|
26
|
+
async list(filter = {}) {
|
|
27
|
+
let out = [...records.values()].map(clone);
|
|
28
|
+
if (filter.workflowId)
|
|
29
|
+
out = out.filter((r) => r.workflowId === filter.workflowId);
|
|
30
|
+
if (filter.status)
|
|
31
|
+
out = out.filter((r) => r.status === filter.status);
|
|
32
|
+
out.sort((a, b) => b.startedAt - a.startedAt);
|
|
33
|
+
if (filter.limit !== undefined)
|
|
34
|
+
out = out.slice(0, filter.limit);
|
|
35
|
+
return out;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function clone(value) {
|
|
40
|
+
return structuredClone(value);
|
|
41
|
+
}
|