@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.
Files changed (61) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +52 -0
  3. package/README.md +76 -0
  4. package/dist/abort-registry.d.ts +6 -0
  5. package/dist/abort-registry.d.ts.map +1 -0
  6. package/dist/abort-registry.js +37 -0
  7. package/dist/concurrency.d.ts +31 -0
  8. package/dist/concurrency.d.ts.map +1 -0
  9. package/dist/concurrency.js +145 -0
  10. package/dist/drive.d.ts +67 -0
  11. package/dist/drive.d.ts.map +1 -0
  12. package/dist/drive.js +373 -0
  13. package/dist/driver-inmemory.d.ts +30 -0
  14. package/dist/driver-inmemory.d.ts.map +1 -0
  15. package/dist/driver-inmemory.js +394 -0
  16. package/dist/event-router.d.ts +51 -0
  17. package/dist/event-router.d.ts.map +1 -0
  18. package/dist/event-router.js +68 -0
  19. package/dist/http-step-handler.d.ts +25 -0
  20. package/dist/http-step-handler.d.ts.map +1 -0
  21. package/dist/http-step-handler.js +78 -0
  22. package/dist/in-memory-store.d.ts +5 -0
  23. package/dist/in-memory-store.d.ts.map +1 -0
  24. package/dist/in-memory-store.js +41 -0
  25. package/dist/index.d.ts +13 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +22 -0
  28. package/dist/journal-helpers.d.ts +3 -0
  29. package/dist/journal-helpers.d.ts.map +1 -0
  30. package/dist/journal-helpers.js +9 -0
  31. package/dist/orchestrator.d.ts +116 -0
  32. package/dist/orchestrator.d.ts.map +1 -0
  33. package/dist/orchestrator.js +411 -0
  34. package/dist/resume-run.d.ts +40 -0
  35. package/dist/resume-run.d.ts.map +1 -0
  36. package/dist/resume-run.js +119 -0
  37. package/dist/schedule.d.ts +51 -0
  38. package/dist/schedule.d.ts.map +1 -0
  39. package/dist/schedule.js +243 -0
  40. package/dist/testing/driver-compliance.d.ts +58 -0
  41. package/dist/testing/driver-compliance.d.ts.map +1 -0
  42. package/dist/testing/driver-compliance.js +667 -0
  43. package/dist/types.d.ts +182 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +4 -0
  46. package/package.json +51 -0
  47. package/src/__tests__/orchestrator-test-support.ts +18 -0
  48. package/src/abort-registry.ts +41 -0
  49. package/src/concurrency.ts +217 -0
  50. package/src/drive.ts +477 -0
  51. package/src/driver-inmemory.ts +511 -0
  52. package/src/event-router.ts +120 -0
  53. package/src/http-step-handler.ts +112 -0
  54. package/src/in-memory-store.ts +44 -0
  55. package/src/index.ts +73 -0
  56. package/src/journal-helpers.ts +11 -0
  57. package/src/orchestrator.ts +527 -0
  58. package/src/resume-run.ts +162 -0
  59. package/src/schedule.ts +310 -0
  60. package/src/testing/driver-compliance.ts +800 -0
  61. package/src/types.ts +201 -0
@@ -0,0 +1,182 @@
1
+ import type { RunTrigger, WaitpointKind } from "@voyant-travel/workflows";
2
+ import type { StepHandlerError, WorkflowStepRequest, WorkflowStepResponse } from "@voyant-travel/workflows/handler";
3
+ import type { CompensationJournalEntry, JournalSlice, StepJournalEntry, WaitpointResolutionEntry } from "@voyant-travel/workflows/protocol";
4
+ export type { CompensationJournalEntry, JournalSlice, RunTrigger, StepJournalEntry, WaitpointKind, WaitpointResolutionEntry, WorkflowStepRequest, WorkflowStepResponse, };
5
+ /**
6
+ * Terminal and non-terminal run statuses. Mirrors the wire statuses
7
+ * from docs/runtime-protocol.md §5 but only the states the orchestrator
8
+ * itself cares about.
9
+ */
10
+ export type OrchestratorRunStatus = "running" | "waiting" | "completed" | "failed" | "cancelled" | "compensated" | "compensation_failed";
11
+ export interface PendingWaitpoint {
12
+ clientWaitpointId: string;
13
+ kind: WaitpointKind;
14
+ meta: Record<string, unknown>;
15
+ timeoutMs?: number;
16
+ }
17
+ export interface StreamChunk {
18
+ streamId: string;
19
+ seq: number;
20
+ encoding: "text" | "json" | "base64";
21
+ chunk: unknown;
22
+ final: boolean;
23
+ at: number;
24
+ }
25
+ export interface RunRecord {
26
+ id: string;
27
+ workflowId: string;
28
+ workflowVersion: string;
29
+ status: OrchestratorRunStatus;
30
+ input: unknown;
31
+ /** Output on "completed" runs. */
32
+ output?: unknown;
33
+ /** SerializedError for failed / compensation_failed. */
34
+ error?: {
35
+ category: string;
36
+ code: string;
37
+ message: string;
38
+ };
39
+ /** Journal accumulated across every tenant invocation. */
40
+ journal: JournalSlice;
41
+ /** Number of tenant invocations performed so far. */
42
+ invocationCount: number;
43
+ /**
44
+ * Positional dedup cursor for `metadataUpdates`. Each executor
45
+ * response re-emits every metadata mutation the body made (including
46
+ * those from prior invocations, since the body replays from the
47
+ * start). The orchestrator applies only the delta beyond what it's
48
+ * seen to avoid double-counting increments / duplicate appends.
49
+ */
50
+ metadataAppliedCount: number;
51
+ /**
52
+ * Cumulative ms spent inside tenant invocations, excluding parked
53
+ * time. Used to enforce workflow-level timeouts — a run that
54
+ * parks for a week on a waitpoint should not count that week
55
+ * against its compute budget.
56
+ */
57
+ computeTimeMs: number;
58
+ /** ms budget from WorkflowConfig.timeout. Zero / undefined = no limit. */
59
+ timeoutMs?: number;
60
+ /**
61
+ * Trigger-time scheduling priority. Higher numbers are claimed first
62
+ * by store-backed time wheels when multiple runs are due.
63
+ */
64
+ priority?: number;
65
+ /**
66
+ * Cross-run lineage. Present on child runs created by
67
+ * `ctx.invoke`; set by the orchestrator when the child parks so
68
+ * its eventual terminal transition can cascade-resume the parent.
69
+ */
70
+ parent?: {
71
+ runId: string;
72
+ waitpointId: string;
73
+ };
74
+ /** Pending waitpoints, only populated when status === "waiting". */
75
+ pendingWaitpoints: PendingWaitpoint[];
76
+ /** Stream chunks accumulated across every tenant invocation, keyed by streamId. */
77
+ streams: Record<string, StreamChunk[]>;
78
+ /** Wall-clock fields, all ms-since-epoch. */
79
+ startedAt: number;
80
+ completedAt?: number;
81
+ /** Trigger metadata. */
82
+ triggeredBy: RunTrigger;
83
+ tags: string[];
84
+ /** Optional environment metadata. */
85
+ environment: "production" | "preview" | "development";
86
+ /** Tenant identity flows through every step request. */
87
+ tenantMeta: {
88
+ tenantId: string;
89
+ projectId: string;
90
+ organizationId: string;
91
+ projectSlug?: string;
92
+ organizationSlug?: string;
93
+ /**
94
+ * Identifier the runtime adapter uses to locate the tenant's
95
+ * code. On Cloudflare this is the dispatch-namespace script name;
96
+ * on other targets it may be a container image tag or Node script
97
+ * path. Optional so local/in-process drivers aren't forced to set it.
98
+ */
99
+ tenantScript?: string;
100
+ };
101
+ runMeta: {
102
+ number: number;
103
+ attempt: number;
104
+ };
105
+ /**
106
+ * Caller-supplied idempotency token, mirrored from `TriggerArgs.idempotencyKey`.
107
+ * Persistent stores use this column to enforce dedup natively (e.g. unique
108
+ * partial index in `voyant_snapshot_runs`); in-process stores ignore it.
109
+ */
110
+ idempotencyKey?: string;
111
+ }
112
+ export interface RunRecordStore {
113
+ get(id: string): Promise<RunRecord | undefined>;
114
+ save(record: RunRecord): Promise<RunRecord>;
115
+ /**
116
+ * Atomically insert a new run record, OR return the existing one if
117
+ * a record with the same `id` already exists. Used by `trigger()` to
118
+ * close the get-then-save race window when an idempotency-derived
119
+ * runId could collide across concurrent callers.
120
+ *
121
+ * Stores must implement this with a single atomic operation:
122
+ * - Postgres: `INSERT … ON CONFLICT (id) DO NOTHING RETURNING …`,
123
+ * with a fallback SELECT when no row is returned.
124
+ * - InMemory / FS: check-then-set in a single microtask.
125
+ * - DO storage: get-then-save is naturally atomic per DO instance.
126
+ *
127
+ * The returned `created: true` means this caller's record was the
128
+ * winner — drive it. `created: false` means another caller raced in
129
+ * first; return the existing record without re-driving (per
130
+ * architecture doc §15.2).
131
+ */
132
+ tryInsert(record: RunRecord): Promise<{
133
+ record: RunRecord;
134
+ created: boolean;
135
+ }>;
136
+ list(filter?: {
137
+ workflowId?: string;
138
+ status?: OrchestratorRunStatus;
139
+ limit?: number;
140
+ }): Promise<RunRecord[]>;
141
+ }
142
+ /**
143
+ * The tenant-side step handler. In-process for tests / local dev via
144
+ * `handleStepRequest` from @voyant-travel/workflows/handler; HTTP in
145
+ * production (via a fetch to the tenant Worker). The optional
146
+ * per-invocation `signal` aborts in-flight step bodies when the run
147
+ * is cancelled mid-execution.
148
+ */
149
+ export type StepHandler = (req: WorkflowStepRequest, opts?: {
150
+ signal?: AbortSignal;
151
+ /**
152
+ * Fires synchronously from `ctx.stream.*` as each chunk is
153
+ * produced. In-process only; HTTP transport drops it (chunks
154
+ * still arrive in the response body). Used by orchestrators to
155
+ * broadcast chunks to dashboards before the invocation returns.
156
+ */
157
+ onStreamChunk?: (chunk: StreamChunk) => void;
158
+ }) => Promise<{
159
+ status: number;
160
+ body: WorkflowStepResponse;
161
+ } | {
162
+ status: number;
163
+ body: StepHandlerError;
164
+ }>;
165
+ /**
166
+ * Injection delivered by an external signal (dashboard, API, inbox).
167
+ * Same shape used by the dashboard inject endpoints.
168
+ */
169
+ export type WaitpointInjection = {
170
+ kind: "EVENT";
171
+ eventType: string;
172
+ payload?: unknown;
173
+ } | {
174
+ kind: "SIGNAL";
175
+ name: string;
176
+ payload?: unknown;
177
+ } | {
178
+ kind: "MANUAL";
179
+ tokenId: string;
180
+ payload?: unknown;
181
+ };
182
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ // Types the orchestrator core uses. Decoupled from any transport:
2
+ // the same RunRecord/Store shapes underpin the in-memory test store,
3
+ // the Durable-Object-backed production store, and any future adapters.
4
+ export {};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@voyant-travel/workflows-orchestrator",
3
+ "version": "0.107.10",
4
+ "description": "Reference orchestrator core for Voyant Workflows — drives runs through the tenant step handler over the v1 wire protocol.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/voyant-travel/voyant.git",
9
+ "directory": "packages/workflows-orchestrator"
10
+ },
11
+ "homepage": "https://voyant.cloud/workflows",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./testing": {
19
+ "types": "./dist/testing/driver-compliance.d.ts",
20
+ "import": "./dist/testing/driver-compliance.js"
21
+ }
22
+ },
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "!**/*.test.*",
29
+ "!**/*.spec.*",
30
+ "NOTICE"
31
+ ],
32
+ "dependencies": {
33
+ "@voyant-travel/workflows": "^0.107.10"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^20.12.0",
37
+ "typescript": "^5.9.2",
38
+ "vitest": "^3.2.6",
39
+ "@voyant-travel/voyant-typescript-config": "^0.1.0"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "check-types": "tsc --noEmit",
47
+ "dev": "tsc -w -p tsconfig.json",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest"
50
+ }
51
+ }
@@ -0,0 +1,18 @@
1
+ import { __resetRegistry } from "@voyant-travel/workflows"
2
+ import { handleStepRequest, type WorkflowStepRequest } from "@voyant-travel/workflows/handler"
3
+ import { beforeEach } from "vitest"
4
+ import type { StepHandler } from "../index.js"
5
+
6
+ export const handler: StepHandler = async (req: WorkflowStepRequest, opts) => {
7
+ return await handleStepRequest(req, {}, opts)
8
+ }
9
+
10
+ export const tenantMeta = {
11
+ tenantId: "tnt_test",
12
+ projectId: "prj_test",
13
+ organizationId: "org_test",
14
+ }
15
+
16
+ beforeEach(() => {
17
+ __resetRegistry()
18
+ })
@@ -0,0 +1,41 @@
1
+ // Process-local map of runId → AbortController. The orchestrator
2
+ // registers a controller when it starts driving a run and aborts it
3
+ // when `cancel(runId)` lands mid-flight. Step bodies that honor
4
+ // `ctx.signal` (fetches, delays, custom logic) observe the abort and
5
+ // can clean up deterministically.
6
+ //
7
+ // Single-process scope by design: each Durable Object / Node serve
8
+ // has its own registry. Cross-process cancellation rides through
9
+ // the run store (see `beforeInvocation` in orchestrator.ts) and
10
+ // catches up at the next invocation boundary.
11
+
12
+ const controllers = new Map<string, AbortController>()
13
+
14
+ export function registerRunAbort(runId: string): AbortController {
15
+ // If a controller already exists, reuse it (e.g., resume after
16
+ // park — the same run id).
17
+ let ctrl = controllers.get(runId)
18
+ if (!ctrl) {
19
+ ctrl = new AbortController()
20
+ controllers.set(runId, ctrl)
21
+ }
22
+ return ctrl
23
+ }
24
+
25
+ export function signalRunAbort(runId: string, reason?: string): boolean {
26
+ const ctrl = controllers.get(runId)
27
+ if (!ctrl) return false
28
+ // `AbortController.abort(reason)` is standard across modern runtimes;
29
+ // the reason surfaces to observers via `signal.reason`.
30
+ ctrl.abort(reason ?? "cancelled")
31
+ return true
32
+ }
33
+
34
+ export function unregisterRunAbort(runId: string): void {
35
+ controllers.delete(runId)
36
+ }
37
+
38
+ /** For tests. */
39
+ export function __clearAbortRegistry(): void {
40
+ controllers.clear()
41
+ }
@@ -0,0 +1,217 @@
1
+ import type { ConcurrencyPolicy } from "@voyant-travel/workflows"
2
+
3
+ import type { RunRecord } from "./types.js"
4
+
5
+ export type RuntimeConcurrencyPolicy =
6
+ | ConcurrencyPolicy<unknown>
7
+ | {
8
+ key?: string
9
+ limit?: number
10
+ strategy?: "queue" | "cancel-in-progress" | "cancel-newest" | "round-robin"
11
+ }
12
+
13
+ export interface ConcurrencyRunHooks {
14
+ onRunRecordCreated(record: RunRecord): void
15
+ }
16
+
17
+ export class WorkflowConcurrencyRejectedError extends Error {
18
+ readonly code = "WORKFLOW_CONCURRENCY_REJECTED"
19
+
20
+ constructor(readonly concurrencyKey: string) {
21
+ super(`workflow concurrency limit reached for key "${concurrencyKey}"`)
22
+ this.name = "WorkflowConcurrencyRejectedError"
23
+ }
24
+ }
25
+
26
+ export interface InProcessConcurrencyCoordinator {
27
+ run(
28
+ args: {
29
+ workflowId: string
30
+ input: unknown
31
+ policy?: RuntimeConcurrencyPolicy
32
+ holderId?: string
33
+ },
34
+ operation: (hooks: ConcurrencyRunHooks) => Promise<RunRecord>,
35
+ ): Promise<RunRecord>
36
+ releaseRun(recordOrRunId: RunRecord | string): void
37
+ }
38
+
39
+ interface Group {
40
+ active: Set<string>
41
+ waiters: Waiter[]
42
+ }
43
+
44
+ interface Slot {
45
+ key: string
46
+ holderId: string
47
+ }
48
+
49
+ interface Waiter {
50
+ holderId: string
51
+ resolve(slot: Slot): void
52
+ }
53
+
54
+ interface CreateInProcessConcurrencyCoordinatorOptions {
55
+ cancelRun?: (runId: string, reason: string) => Promise<unknown>
56
+ }
57
+
58
+ const TOKEN_PREFIX = "concurrency-slot:"
59
+
60
+ export function createInProcessConcurrencyCoordinator(
61
+ opts: CreateInProcessConcurrencyCoordinatorOptions = {},
62
+ ): InProcessConcurrencyCoordinator {
63
+ const groups = new Map<string, Group>()
64
+ const runKeys = new Map<string, string>()
65
+ let nextToken = 0
66
+
67
+ function getGroup(key: string): Group {
68
+ let group = groups.get(key)
69
+ if (!group) {
70
+ group = { active: new Set(), waiters: [] }
71
+ groups.set(key, group)
72
+ }
73
+ return group
74
+ }
75
+
76
+ function createToken(): string {
77
+ nextToken += 1
78
+ return `${TOKEN_PREFIX}${nextToken}`
79
+ }
80
+
81
+ async function acquire(
82
+ key: string,
83
+ limit: number,
84
+ strategy: NonNullable<RuntimeConcurrencyPolicy["strategy"]>,
85
+ preferredHolderId?: string,
86
+ ): Promise<Slot> {
87
+ const group = getGroup(key)
88
+ if (preferredHolderId && group.active.has(preferredHolderId)) {
89
+ return { key, holderId: preferredHolderId }
90
+ }
91
+
92
+ const holderId = preferredHolderId ?? createToken()
93
+ if (group.active.size < limit) {
94
+ group.active.add(holderId)
95
+ return { key, holderId }
96
+ }
97
+
98
+ if (strategy === "cancel-newest") {
99
+ throw new WorkflowConcurrencyRejectedError(key)
100
+ }
101
+
102
+ if (strategy === "cancel-in-progress") {
103
+ const holders = [...group.active]
104
+ for (const holder of holders) {
105
+ group.active.delete(holder)
106
+ runKeys.delete(holder)
107
+ if (!holder.startsWith(TOKEN_PREFIX)) {
108
+ await opts.cancelRun?.(holder, "cancelled by workflow concurrency policy")
109
+ }
110
+ }
111
+ group.active.add(holderId)
112
+ return { key, holderId }
113
+ }
114
+
115
+ return new Promise((resolve) => {
116
+ group.waiters.push({
117
+ holderId,
118
+ resolve(slot) {
119
+ resolve(slot)
120
+ },
121
+ })
122
+ })
123
+ }
124
+
125
+ function assignRunId(slot: Slot, record: RunRecord): Slot {
126
+ const group = getGroup(slot.key)
127
+ if (group.active.delete(slot.holderId)) {
128
+ group.active.add(record.id)
129
+ }
130
+ runKeys.set(record.id, slot.key)
131
+ return { key: slot.key, holderId: record.id }
132
+ }
133
+
134
+ function releaseSlot(slot: Slot): void {
135
+ const group = groups.get(slot.key)
136
+ if (!group) return
137
+ group.active.delete(slot.holderId)
138
+ runKeys.delete(slot.holderId)
139
+ drain(slot.key, group)
140
+ if (group.active.size === 0 && group.waiters.length === 0) {
141
+ groups.delete(slot.key)
142
+ }
143
+ }
144
+
145
+ function drain(key: string, group: Group): void {
146
+ while (group.waiters.length > 0) {
147
+ const waiter = group.waiters.shift()
148
+ if (!waiter) return
149
+ group.active.add(waiter.holderId)
150
+ waiter.resolve({ key, holderId: waiter.holderId })
151
+ if (group.active.size > 0) return
152
+ }
153
+ }
154
+
155
+ return {
156
+ async run(args, operation) {
157
+ const policy = args.policy
158
+ if (!policy) {
159
+ return operation({ onRunRecordCreated() {} })
160
+ }
161
+
162
+ const key = resolveConcurrencyKey(args.workflowId, args.input, policy)
163
+ const limit = normalizeLimit(policy.limit)
164
+ const strategy = policy.strategy ?? "queue"
165
+ let slot = await acquire(key, limit, strategy, args.holderId)
166
+ let record: RunRecord | undefined
167
+
168
+ try {
169
+ record = await operation({
170
+ onRunRecordCreated(created) {
171
+ slot = assignRunId(slot, created)
172
+ },
173
+ })
174
+ if (!runKeys.has(record.id)) {
175
+ slot = assignRunId(slot, record)
176
+ }
177
+ return record
178
+ } finally {
179
+ if (!record || isTerminal(record.status)) {
180
+ releaseSlot(record ? { key: slot.key, holderId: record.id } : slot)
181
+ }
182
+ }
183
+ },
184
+
185
+ releaseRun(recordOrRunId) {
186
+ const runId = typeof recordOrRunId === "string" ? recordOrRunId : recordOrRunId.id
187
+ const key = runKeys.get(runId)
188
+ if (!key) return
189
+ releaseSlot({ key, holderId: runId })
190
+ },
191
+ }
192
+ }
193
+
194
+ export function resolveConcurrencyKey(
195
+ workflowId: string,
196
+ input: unknown,
197
+ policy: RuntimeConcurrencyPolicy,
198
+ ): string {
199
+ const rawKey = typeof policy.key === "function" ? policy.key(input) : policy.key
200
+ return `${workflowId}:${rawKey ?? "default"}`
201
+ }
202
+
203
+ function normalizeLimit(limit: number | undefined): number {
204
+ if (limit === undefined) return 1
205
+ if (!Number.isFinite(limit)) return 1
206
+ return Math.max(1, Math.floor(limit))
207
+ }
208
+
209
+ function isTerminal(status: RunRecord["status"]): boolean {
210
+ return (
211
+ status === "completed" ||
212
+ status === "failed" ||
213
+ status === "cancelled" ||
214
+ status === "compensated" ||
215
+ status === "compensation_failed"
216
+ )
217
+ }