@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,310 @@
1
+ import type { Duration, EnvironmentName, ScheduleDeclaration } from "@voyant-travel/workflows"
2
+ import type { ManifestSchedule, WorkflowManifest } from "@voyant-travel/workflows/protocol"
3
+
4
+ export type SchedulableDeclaration = ScheduleDeclaration | ManifestSchedule
5
+
6
+ export interface ScheduleSource {
7
+ id?: string
8
+ workflowId: string
9
+ decl: SchedulableDeclaration
10
+ }
11
+
12
+ export interface SchedulerDeps {
13
+ sources: readonly ScheduleSource[]
14
+ onFire: (args: {
15
+ workflowId: string
16
+ input: unknown
17
+ scheduleId: string
18
+ scheduleName?: string
19
+ fireAt: number
20
+ }) => Promise<void>
21
+ now?: () => number
22
+ environment?: EnvironmentName
23
+ tickMs?: number
24
+ setInterval?: typeof setInterval
25
+ clearInterval?: typeof clearInterval
26
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
27
+ }
28
+
29
+ export interface SchedulerHandle {
30
+ start: () => void
31
+ stop: () => void
32
+ tick: () => Promise<void>
33
+ nextFirings: () => {
34
+ workflowId: string
35
+ scheduleId: string
36
+ name?: string
37
+ nextAt: number
38
+ done: boolean
39
+ }[]
40
+ sourceCount: () => number
41
+ }
42
+
43
+ function unrefTimer(timer: unknown): void {
44
+ if (
45
+ typeof timer === "object" &&
46
+ timer !== null &&
47
+ "unref" in timer &&
48
+ typeof timer.unref === "function"
49
+ ) {
50
+ timer.unref()
51
+ }
52
+ }
53
+
54
+ interface SourceState {
55
+ source: ScheduleSource
56
+ scheduleId: string
57
+ nextAt: number
58
+ done: boolean
59
+ inFlight: boolean
60
+ queued: QueuedFire[]
61
+ }
62
+
63
+ interface QueuedFire {
64
+ fireAt: number
65
+ }
66
+
67
+ export function manifestScheduleSources(manifest: WorkflowManifest): ScheduleSource[] {
68
+ const sources: ScheduleSource[] = []
69
+ for (const workflow of manifest.workflows) {
70
+ workflow.schedules.forEach((decl, index) => {
71
+ sources.push({
72
+ id: `${manifest.versionId}:${workflow.id}:${decl.name ?? index}`,
73
+ workflowId: workflow.id,
74
+ decl,
75
+ })
76
+ })
77
+ }
78
+ return sources
79
+ }
80
+
81
+ export function createScheduler(deps: SchedulerDeps): SchedulerHandle {
82
+ const now = deps.now ?? (() => Date.now())
83
+ const tickMs = deps.tickMs ?? 1_000
84
+ const setInt = deps.setInterval ?? setInterval
85
+ const clearInt = deps.clearInterval ?? clearInterval
86
+ const env = deps.environment ?? "development"
87
+ const log = deps.logger ?? (() => {})
88
+
89
+ const states: SourceState[] = []
90
+ for (const [index, source] of deps.sources.entries()) {
91
+ if (source.decl.enabled === false) continue
92
+ if (source.decl.environments && !source.decl.environments.includes(env)) continue
93
+ let firstAt: number
94
+ try {
95
+ firstAt = computeNextFire(source.decl, now())
96
+ } catch (err) {
97
+ log("warn", `scheduler: skipping source for workflow "${source.workflowId}": ${String(err)}`)
98
+ continue
99
+ }
100
+ states.push({
101
+ source,
102
+ scheduleId: source.id ?? `${source.workflowId}:${source.decl.name ?? index}`,
103
+ nextAt: firstAt,
104
+ done: false,
105
+ inFlight: false,
106
+ queued: [],
107
+ })
108
+ }
109
+
110
+ let timer: ReturnType<typeof setInterval> | undefined
111
+
112
+ const advanceAfterFire = (state: SourceState, firedAt: number): void => {
113
+ if ("at" in state.source.decl) {
114
+ state.done = true
115
+ return
116
+ }
117
+ try {
118
+ state.nextAt = computeNextFire(state.source.decl, firedAt)
119
+ } catch (err) {
120
+ log(
121
+ "error",
122
+ `scheduler: cannot compute next fire for "${state.source.workflowId}": ${String(err)}`,
123
+ )
124
+ state.done = true
125
+ }
126
+ }
127
+
128
+ const fire = async (state: SourceState, fireAt: number): Promise<void> => {
129
+ try {
130
+ const input = await resolveInput(state.source.decl.input)
131
+ await deps.onFire({
132
+ workflowId: state.source.workflowId,
133
+ input,
134
+ scheduleId: state.scheduleId,
135
+ scheduleName: state.source.decl.name,
136
+ fireAt,
137
+ })
138
+ } catch (err) {
139
+ log("error", `scheduler: onFire threw for "${state.source.workflowId}": ${String(err)}`)
140
+ } finally {
141
+ const next = state.queued.shift()
142
+ if (next) {
143
+ void fire(state, next.fireAt)
144
+ } else {
145
+ state.inFlight = false
146
+ }
147
+ }
148
+ }
149
+
150
+ const doTick = async (): Promise<void> => {
151
+ const t = now()
152
+ const ready = states.filter((state) => !state.done && state.nextAt <= t)
153
+ for (const state of ready) {
154
+ const overlap = state.source.decl.overlap ?? "skip"
155
+ if (state.inFlight && overlap === "skip") continue
156
+ const fireAt = state.nextAt
157
+ if (state.inFlight && overlap === "queue") {
158
+ state.queued.push({ fireAt })
159
+ advanceAfterFire(state, t)
160
+ continue
161
+ }
162
+ state.inFlight = true
163
+ const firePromise = fire(state, fireAt)
164
+ advanceAfterFire(state, t)
165
+ if (overlap !== "allow") await firePromise
166
+ }
167
+ }
168
+
169
+ return {
170
+ start() {
171
+ if (timer) return
172
+ timer = setInt(() => {
173
+ doTick().catch(() => {})
174
+ }, tickMs)
175
+ unrefTimer(timer)
176
+ },
177
+ stop() {
178
+ if (!timer) return
179
+ clearInt(timer)
180
+ timer = undefined
181
+ },
182
+ tick: doTick,
183
+ nextFirings() {
184
+ return states.map((state) => ({
185
+ workflowId: state.source.workflowId,
186
+ scheduleId: state.scheduleId,
187
+ name: state.source.decl.name,
188
+ nextAt: state.nextAt,
189
+ done: state.done,
190
+ }))
191
+ },
192
+ sourceCount() {
193
+ return states.length
194
+ },
195
+ }
196
+ }
197
+
198
+ export function computeNextFire(decl: SchedulableDeclaration, fromMs: number): number {
199
+ if ("cron" in decl && decl.cron !== undefined) return nextCronFire(parseCron(decl.cron), fromMs)
200
+ if ("every" in decl && decl.every !== undefined) return fromMs + toMs(decl.every)
201
+ if ("at" in decl && decl.at !== undefined) {
202
+ const at = typeof decl.at === "string" ? Date.parse(decl.at) : decl.at.getTime()
203
+ if (!Number.isFinite(at)) throw new Error(`invalid "at" value: ${String(decl.at)}`)
204
+ return at < fromMs ? Number.POSITIVE_INFINITY : at
205
+ }
206
+ throw new Error(`schedule declaration missing one of cron/every/at`)
207
+ }
208
+
209
+ export interface CronSpec {
210
+ minute: number[]
211
+ hour: number[]
212
+ day: number[]
213
+ month: number[]
214
+ dow: number[]
215
+ }
216
+
217
+ export function parseCron(expr: string): CronSpec {
218
+ const parts = expr.trim().split(/\s+/)
219
+ if (parts.length !== 5) {
220
+ throw new Error(`invalid cron "${expr}" - expected 5 fields (minute hour day month dow)`)
221
+ }
222
+ return {
223
+ minute: parseField(parts[0]!, 0, 59, "minute"),
224
+ hour: parseField(parts[1]!, 0, 23, "hour"),
225
+ day: parseField(parts[2]!, 1, 31, "day"),
226
+ month: parseField(parts[3]!, 1, 12, "month"),
227
+ dow: parseField(parts[4]!, 0, 6, "dow"),
228
+ }
229
+ }
230
+
231
+ function parseField(f: string, min: number, max: number, label: string): number[] {
232
+ const out = new Set<number>()
233
+ for (const part of f.split(",")) {
234
+ const stepMatch = /^(.+)\/(\d+)$/.exec(part)
235
+ const body = stepMatch ? stepMatch[1]! : part
236
+ const step = stepMatch ? Number(stepMatch[2]) : 1
237
+ if (!(step >= 1)) throw new Error(`cron ${label} step must be >=1 in "${f}"`)
238
+ let lo: number
239
+ let hi: number
240
+ if (body === "*") {
241
+ lo = min
242
+ hi = max
243
+ } else if (body.includes("-")) {
244
+ const [a, b] = body.split("-")
245
+ lo = Number(a)
246
+ hi = Number(b)
247
+ } else {
248
+ lo = Number(body)
249
+ hi = lo
250
+ }
251
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo < min || hi > max || lo > hi) {
252
+ throw new Error(`cron ${label} out of range [${min}..${max}] in "${f}"`)
253
+ }
254
+ for (let i = lo; i <= hi; i += step) out.add(i)
255
+ }
256
+ return [...out].sort((a, b) => a - b)
257
+ }
258
+
259
+ export function nextCronFire(spec: CronSpec, fromMs: number): number {
260
+ const date = new Date(fromMs)
261
+ date.setUTCSeconds(0, 0)
262
+ date.setUTCMinutes(date.getUTCMinutes() + 1)
263
+
264
+ const maxIterations = 60 * 24 * 366 * 5
265
+ for (let i = 0; i < maxIterations; i++) {
266
+ if (
267
+ spec.minute.includes(date.getUTCMinutes()) &&
268
+ spec.hour.includes(date.getUTCHours()) &&
269
+ spec.day.includes(date.getUTCDate()) &&
270
+ spec.month.includes(date.getUTCMonth() + 1) &&
271
+ spec.dow.includes(date.getUTCDay())
272
+ ) {
273
+ return date.getTime()
274
+ }
275
+ date.setUTCMinutes(date.getUTCMinutes() + 1)
276
+ }
277
+ throw new Error("cron search exceeded 5 years without finding a match")
278
+ }
279
+
280
+ export function toMs(duration: Duration | string | number): number {
281
+ if (typeof duration === "number") return duration
282
+ const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(duration)
283
+ if (!m) throw new Error(`invalid duration "${duration}"`)
284
+ const n = Number(m[1])
285
+ switch (m[2]) {
286
+ case "ms":
287
+ return n
288
+ case "s":
289
+ return n * 1_000
290
+ case "m":
291
+ return n * 60_000
292
+ case "h":
293
+ return n * 3_600_000
294
+ case "d":
295
+ return n * 86_400_000
296
+ case "w":
297
+ return n * 604_800_000
298
+ default:
299
+ throw new Error(`invalid duration "${duration}"`)
300
+ }
301
+ }
302
+
303
+ async function resolveInput(
304
+ input: unknown | (() => unknown | Promise<unknown>) | undefined,
305
+ ): Promise<unknown> {
306
+ if (typeof input === "function") {
307
+ return await (input as () => unknown | Promise<unknown>)()
308
+ }
309
+ return input
310
+ }