@voyant-travel/workflows 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 +79 -0
- package/dist/auth/index.d.ts +125 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +352 -0
- package/dist/bindings.d.ts +119 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +19 -0
- package/dist/client.d.ts +135 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +305 -0
- package/dist/conditions.d.ts +29 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/conditions.js +5 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +7 -0
- package/dist/driver.d.ts +237 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +53 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +76 -0
- package/dist/events/compile.d.ts +34 -0
- package/dist/events/compile.d.ts.map +1 -0
- package/dist/events/compile.js +204 -0
- package/dist/events/index.d.ts +8 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +11 -0
- package/dist/events/input-mapper.d.ts +24 -0
- package/dist/events/input-mapper.d.ts.map +1 -0
- package/dist/events/input-mapper.js +169 -0
- package/dist/events/manifest-builder.d.ts +42 -0
- package/dist/events/manifest-builder.d.ts.map +1 -0
- package/dist/events/manifest-builder.js +313 -0
- package/dist/events/payload-hash.d.ts +46 -0
- package/dist/events/payload-hash.d.ts.map +1 -0
- package/dist/events/payload-hash.js +98 -0
- package/dist/events/predicate.d.ts +77 -0
- package/dist/events/predicate.d.ts.map +1 -0
- package/dist/events/predicate.js +347 -0
- package/dist/events/registry.d.ts +37 -0
- package/dist/events/registry.d.ts.map +1 -0
- package/dist/events/registry.js +47 -0
- package/dist/handler/index.d.ts +114 -0
- package/dist/handler/index.d.ts.map +1 -0
- package/dist/handler/index.js +267 -0
- package/dist/handler/resume.d.ts +41 -0
- package/dist/handler/resume.d.ts.map +1 -0
- package/dist/handler/resume.js +44 -0
- package/dist/http-ingest.d.ts +54 -0
- package/dist/http-ingest.d.ts.map +1 -0
- package/dist/http-ingest.js +214 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/protocol/index.d.ts +345 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +110 -0
- package/dist/rate-limit/index.d.ts +40 -0
- package/dist/rate-limit/index.d.ts.map +1 -0
- package/dist/rate-limit/index.js +139 -0
- package/dist/runtime/ctx.d.ts +111 -0
- package/dist/runtime/ctx.d.ts.map +1 -0
- package/dist/runtime/ctx.js +624 -0
- package/dist/runtime/determinism.d.ts +19 -0
- package/dist/runtime/determinism.d.ts.map +1 -0
- package/dist/runtime/determinism.js +61 -0
- package/dist/runtime/errors.d.ts +21 -0
- package/dist/runtime/errors.d.ts.map +1 -0
- package/dist/runtime/errors.js +45 -0
- package/dist/runtime/executor.d.ts +166 -0
- package/dist/runtime/executor.d.ts.map +1 -0
- package/dist/runtime/executor.js +226 -0
- package/dist/runtime/journal.d.ts +56 -0
- package/dist/runtime/journal.d.ts.map +1 -0
- package/dist/runtime/journal.js +28 -0
- package/dist/testing/index.d.ts +117 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +599 -0
- package/dist/trigger.d.ts +37 -0
- package/dist/trigger.d.ts.map +1 -0
- package/dist/trigger.js +11 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/workflow.d.ts +222 -0
- package/dist/workflow.d.ts.map +1 -0
- package/dist/workflow.js +55 -0
- package/package.json +120 -0
- package/src/auth/index.ts +398 -0
- package/src/bindings.ts +135 -0
- package/src/client.ts +498 -0
- package/src/conditions.ts +43 -0
- package/src/config.ts +114 -0
- package/src/driver.ts +277 -0
- package/src/errors.ts +109 -0
- package/src/events/compile.ts +268 -0
- package/src/events/index.ts +42 -0
- package/src/events/input-mapper.ts +201 -0
- package/src/events/manifest-builder.ts +372 -0
- package/src/events/payload-hash.ts +110 -0
- package/src/events/predicate.ts +390 -0
- package/src/events/registry.ts +86 -0
- package/src/handler/index.ts +413 -0
- package/src/handler/resume.ts +100 -0
- package/src/http-ingest.ts +299 -0
- package/src/index.ts +18 -0
- package/src/protocol/index.ts +483 -0
- package/src/rate-limit/index.ts +181 -0
- package/src/runtime/ctx.ts +876 -0
- package/src/runtime/determinism.ts +75 -0
- package/src/runtime/errors.ts +58 -0
- package/src/runtime/executor.ts +442 -0
- package/src/runtime/journal.ts +80 -0
- package/src/testing/index.ts +796 -0
- package/src/trigger.ts +63 -0
- package/src/types.ts +80 -0
- package/src/workflow.ts +328 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// @voyant-travel/workflows/protocol
|
|
2
|
+
//
|
|
3
|
+
// Wire-protocol types shared with the orchestrator. Full contract
|
|
4
|
+
// in docs/runtime-protocol.md; types here are exported so callers
|
|
5
|
+
// (test harness, adapters, dashboards) can build and inspect wire
|
|
6
|
+
// payloads without reaching into runtime internals.
|
|
7
|
+
|
|
8
|
+
import type { JournalSlice, WaitpointResolutionEntry } from "../runtime/journal.js"
|
|
9
|
+
|
|
10
|
+
export type ProtocolVersion = 1
|
|
11
|
+
export const PROTOCOL_VERSION: ProtocolVersion = 1
|
|
12
|
+
|
|
13
|
+
// Journal types: shape of the tenant-side view of a run's state.
|
|
14
|
+
// Re-exported so orchestrators and tools can build/inspect journals
|
|
15
|
+
// without reaching into the runtime subpath.
|
|
16
|
+
export type {
|
|
17
|
+
CompensationJournalEntry,
|
|
18
|
+
JournalSlice,
|
|
19
|
+
StepJournalEntry,
|
|
20
|
+
WaitpointResolutionEntry,
|
|
21
|
+
} from "../runtime/journal.js"
|
|
22
|
+
|
|
23
|
+
export type ExecutionStatus =
|
|
24
|
+
| "CREATED"
|
|
25
|
+
| "QUEUED"
|
|
26
|
+
| "EXECUTING"
|
|
27
|
+
| "EXECUTING_WITH_WAITPOINTS"
|
|
28
|
+
| "SUSPENDED"
|
|
29
|
+
| "PENDING_CANCEL"
|
|
30
|
+
| "FINISHED"
|
|
31
|
+
|
|
32
|
+
export type WaitpointKind = "DATETIME" | "EVENT" | "SIGNAL" | "RUN" | "MANUAL"
|
|
33
|
+
|
|
34
|
+
export interface SerializedError {
|
|
35
|
+
category: "USER_ERROR" | "RUNTIME_ERROR"
|
|
36
|
+
code: string
|
|
37
|
+
message: string
|
|
38
|
+
name?: string
|
|
39
|
+
stack?: string
|
|
40
|
+
cause?: SerializedError
|
|
41
|
+
data?: Record<string, unknown>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type PayloadLocation = "INLINE" | "EXTERNAL"
|
|
45
|
+
|
|
46
|
+
export interface WorkflowManifest {
|
|
47
|
+
schemaVersion: 1
|
|
48
|
+
projectId: string
|
|
49
|
+
versionId: string
|
|
50
|
+
builtAt: number
|
|
51
|
+
builderVersion: string
|
|
52
|
+
capabilities: WorkflowReleaseCapabilities
|
|
53
|
+
workflows: WorkflowManifestEntry[]
|
|
54
|
+
eventFilters: EventFilterManifestEntry[]
|
|
55
|
+
diagnostics: WorkflowManifestDiagnostic[]
|
|
56
|
+
bundle?: WorkflowManifestBundle
|
|
57
|
+
bindings: Record<string, { type: "d1" | "r2" | "kv" | "queue"; name: string }>
|
|
58
|
+
environments: Record<string, { customDomain?: string }>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WorkflowManifestEntry {
|
|
62
|
+
id: string
|
|
63
|
+
displayName?: string
|
|
64
|
+
description?: string
|
|
65
|
+
capabilities: WorkflowDefinitionCapabilities
|
|
66
|
+
version: string
|
|
67
|
+
inputSchema?: unknown
|
|
68
|
+
outputSchema?: unknown
|
|
69
|
+
concurrency?: ManifestConcurrencyPolicy
|
|
70
|
+
steps: ManifestStep[]
|
|
71
|
+
schedules: ManifestSchedule[]
|
|
72
|
+
defaultRuntime: "edge" | "node"
|
|
73
|
+
hasCompensation: boolean
|
|
74
|
+
sourceLocation: { file: string; line: number }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WorkflowReleaseCapabilities {
|
|
78
|
+
trigger: boolean
|
|
79
|
+
events: boolean
|
|
80
|
+
schedules: boolean
|
|
81
|
+
rerun: boolean
|
|
82
|
+
resume: boolean
|
|
83
|
+
cancel: boolean
|
|
84
|
+
humanApproval: boolean
|
|
85
|
+
stepRerun: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface WorkflowDefinitionCapabilities {
|
|
89
|
+
canTrigger: boolean
|
|
90
|
+
canRerun: boolean
|
|
91
|
+
canResume: boolean
|
|
92
|
+
canCancel: boolean
|
|
93
|
+
hasSchedules: boolean
|
|
94
|
+
supportsEvents: boolean
|
|
95
|
+
supportsHumanApproval: boolean
|
|
96
|
+
supportsStepRerun: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface WorkflowManifestBundle {
|
|
100
|
+
artifactName?: string
|
|
101
|
+
sizeBytes?: number
|
|
102
|
+
hash?: string
|
|
103
|
+
hashAlgorithm?: "sha256" | "sha512" | (string & {})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface WorkflowBundleReference {
|
|
107
|
+
key?: string
|
|
108
|
+
url?: string
|
|
109
|
+
signedUrl?: string
|
|
110
|
+
hash?: string
|
|
111
|
+
hashAlgorithm?: "sha256" | "sha512" | (string & {})
|
|
112
|
+
sizeBytes?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface WorkflowPayloadReference {
|
|
116
|
+
location: PayloadLocation
|
|
117
|
+
key?: string
|
|
118
|
+
url?: string
|
|
119
|
+
hash?: string
|
|
120
|
+
hashAlgorithm?: "sha256" | "sha512" | (string & {})
|
|
121
|
+
sizeBytes?: number
|
|
122
|
+
contentType?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface WorkflowJournalReference {
|
|
126
|
+
location: PayloadLocation
|
|
127
|
+
key?: string
|
|
128
|
+
url?: string
|
|
129
|
+
hash?: string
|
|
130
|
+
hashAlgorithm?: "sha256" | "sha512" | (string & {})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface WorkflowManifestDiagnostic {
|
|
134
|
+
code: string
|
|
135
|
+
severity: "info" | "warning" | "error"
|
|
136
|
+
message: string
|
|
137
|
+
sourceLocation?: { file: string; line?: number; column?: number }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ManifestConcurrencyPolicy {
|
|
141
|
+
key?: string
|
|
142
|
+
limit?: number
|
|
143
|
+
strategy?: "queue" | "cancel-in-progress" | "cancel-newest" | "round-robin"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ManifestStep {
|
|
147
|
+
id: string
|
|
148
|
+
runtime: "edge" | "node"
|
|
149
|
+
hasCompensation: boolean
|
|
150
|
+
sourceLocation: { file: string; line: number }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ManifestSchedule {
|
|
154
|
+
cron?: string
|
|
155
|
+
every?: string | number
|
|
156
|
+
at?: string
|
|
157
|
+
timezone?: string
|
|
158
|
+
input?: unknown
|
|
159
|
+
enabled?: boolean
|
|
160
|
+
overlap?: "skip" | "queue" | "allow"
|
|
161
|
+
environments?: ("production" | "preview" | "development")[]
|
|
162
|
+
name?: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface EventFilterManifestEntry {
|
|
166
|
+
/** Stable id derived from `payloadHash` of the canonicalized declaration. */
|
|
167
|
+
id: string
|
|
168
|
+
/** Event name the filter targets — matches `EventEnvelope.name`. */
|
|
169
|
+
eventType: string
|
|
170
|
+
/**
|
|
171
|
+
* Optional structured `where` predicate. When absent, every event of the
|
|
172
|
+
* matching `eventType` fires the target workflow. Concrete shape lives in
|
|
173
|
+
* `@voyant-travel/workflows/events` (`PredicateExpr`); the protocol declares
|
|
174
|
+
* it as an opaque object so old orchestrators that don't understand the
|
|
175
|
+
* shape don't have to evaluate it.
|
|
176
|
+
*/
|
|
177
|
+
where?: unknown
|
|
178
|
+
/**
|
|
179
|
+
* Optional input mapper. When absent, the workflow input = `envelope.data`.
|
|
180
|
+
* Concrete shape lives in `@voyant-travel/workflows/events` (`InputMapper`).
|
|
181
|
+
*/
|
|
182
|
+
input?: unknown
|
|
183
|
+
/** Content-derived hash of the canonicalized declaration. */
|
|
184
|
+
payloadHash: string
|
|
185
|
+
/** Workflow id this filter triggers. */
|
|
186
|
+
targetWorkflowId: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface WorkflowWaitpointSource {
|
|
190
|
+
clientWaitpointId: string
|
|
191
|
+
kind: WaitpointKind
|
|
192
|
+
meta: Record<string, unknown>
|
|
193
|
+
timeoutMs?: number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface WorkflowWaitpointSnapshot {
|
|
197
|
+
/** Framework-stable waitpoint id, usually the runtime client waitpoint id. */
|
|
198
|
+
id: string
|
|
199
|
+
/** Stable key Cloud can store and later address without re-deriving metadata. */
|
|
200
|
+
key: string
|
|
201
|
+
kind: WaitpointKind
|
|
202
|
+
eventName?: string
|
|
203
|
+
signalName?: string
|
|
204
|
+
tokenId?: string
|
|
205
|
+
expiresAt?: number
|
|
206
|
+
timeoutMs?: number
|
|
207
|
+
metadata: Record<string, unknown>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type WorkflowWaitpointResumeTarget = WorkflowWaitpointSource | WorkflowWaitpointSnapshot
|
|
211
|
+
|
|
212
|
+
export interface WorkflowActivationFreshness {
|
|
213
|
+
dispatchedAt: number
|
|
214
|
+
expiresAt?: number
|
|
215
|
+
attempt?: number
|
|
216
|
+
leaseId?: string
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type WorkflowActivationMetadata =
|
|
220
|
+
| {
|
|
221
|
+
kind: "initial"
|
|
222
|
+
workflowReleaseId?: string
|
|
223
|
+
releaseId?: string
|
|
224
|
+
bundle?: WorkflowBundleReference
|
|
225
|
+
freshness?: WorkflowActivationFreshness
|
|
226
|
+
}
|
|
227
|
+
| WorkflowResumeActivationMetadata
|
|
228
|
+
|
|
229
|
+
export interface WorkflowResumeActivationMetadata {
|
|
230
|
+
kind: "resume"
|
|
231
|
+
workflowReleaseId?: string
|
|
232
|
+
releaseId?: string
|
|
233
|
+
bundle?: WorkflowBundleReference
|
|
234
|
+
journalRef?: WorkflowJournalReference
|
|
235
|
+
waitpoint: WorkflowWaitpointSnapshot
|
|
236
|
+
resumePayloadRef?: WorkflowPayloadReference
|
|
237
|
+
freshness?: WorkflowActivationFreshness
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface ApplyWorkflowResumeInput {
|
|
241
|
+
journal: JournalSlice
|
|
242
|
+
waitpoints: readonly WorkflowWaitpointResumeTarget[]
|
|
243
|
+
waitpointId?: string
|
|
244
|
+
waitpointKey?: string
|
|
245
|
+
parkedAt?: number
|
|
246
|
+
payload?: unknown
|
|
247
|
+
payloadRef?: WorkflowPayloadReference
|
|
248
|
+
resolvedAt?: number
|
|
249
|
+
matchedEventId?: string
|
|
250
|
+
source?: WaitpointResolutionEntry["source"]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type ApplyWorkflowResumeResult =
|
|
254
|
+
| {
|
|
255
|
+
ok: true
|
|
256
|
+
journal: JournalSlice
|
|
257
|
+
waitpoint: WorkflowWaitpointSnapshot
|
|
258
|
+
resolution: WaitpointResolutionEntry
|
|
259
|
+
}
|
|
260
|
+
| {
|
|
261
|
+
ok: false
|
|
262
|
+
code: "missing_waitpoint_selector" | "waitpoint_not_found"
|
|
263
|
+
message: string
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function workflowWaitpointKey(waitpoint: WorkflowWaitpointResumeTarget): string {
|
|
267
|
+
if (isWorkflowWaitpointSnapshot(waitpoint)) return waitpoint.key
|
|
268
|
+
return `${waitpoint.kind}:${waitpoint.clientWaitpointId}`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function snapshotWorkflowWaitpoint(
|
|
272
|
+
waitpoint: WorkflowWaitpointResumeTarget,
|
|
273
|
+
parkedAt = Date.now(),
|
|
274
|
+
): WorkflowWaitpointSnapshot {
|
|
275
|
+
const metadata = {
|
|
276
|
+
...(isWorkflowWaitpointSnapshot(waitpoint) ? waitpoint.metadata : waitpoint.meta),
|
|
277
|
+
}
|
|
278
|
+
const timeoutMs = waitpoint.timeoutMs
|
|
279
|
+
const wakeAt = typeof metadata.wakeAt === "number" ? metadata.wakeAt : undefined
|
|
280
|
+
const expiresAt = isWorkflowWaitpointSnapshot(waitpoint)
|
|
281
|
+
? waitpoint.expiresAt
|
|
282
|
+
: (wakeAt ??
|
|
283
|
+
(typeof timeoutMs === "number" && timeoutMs > 0 ? parkedAt + timeoutMs : undefined))
|
|
284
|
+
|
|
285
|
+
const snapshot: WorkflowWaitpointSnapshot = {
|
|
286
|
+
id: workflowWaitpointId(waitpoint),
|
|
287
|
+
key: workflowWaitpointKey(waitpoint),
|
|
288
|
+
kind: waitpoint.kind,
|
|
289
|
+
metadata,
|
|
290
|
+
}
|
|
291
|
+
if (typeof timeoutMs === "number") snapshot.timeoutMs = timeoutMs
|
|
292
|
+
if (typeof expiresAt === "number") snapshot.expiresAt = expiresAt
|
|
293
|
+
if (waitpoint.kind === "EVENT") {
|
|
294
|
+
const eventName = isWorkflowWaitpointSnapshot(waitpoint)
|
|
295
|
+
? waitpoint.eventName
|
|
296
|
+
: typeof metadata.eventType === "string"
|
|
297
|
+
? metadata.eventType
|
|
298
|
+
: undefined
|
|
299
|
+
if (eventName) snapshot.eventName = eventName
|
|
300
|
+
}
|
|
301
|
+
if (waitpoint.kind === "SIGNAL") {
|
|
302
|
+
const signalName = isWorkflowWaitpointSnapshot(waitpoint)
|
|
303
|
+
? waitpoint.signalName
|
|
304
|
+
: typeof metadata.signalName === "string"
|
|
305
|
+
? metadata.signalName
|
|
306
|
+
: undefined
|
|
307
|
+
if (signalName) snapshot.signalName = signalName
|
|
308
|
+
}
|
|
309
|
+
if (waitpoint.kind === "MANUAL") {
|
|
310
|
+
const tokenId = isWorkflowWaitpointSnapshot(waitpoint)
|
|
311
|
+
? waitpoint.tokenId
|
|
312
|
+
: typeof metadata.tokenId === "string"
|
|
313
|
+
? metadata.tokenId
|
|
314
|
+
: undefined
|
|
315
|
+
if (tokenId) snapshot.tokenId = tokenId
|
|
316
|
+
}
|
|
317
|
+
return snapshot
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function applyWorkflowResumeToJournal(
|
|
321
|
+
input: ApplyWorkflowResumeInput,
|
|
322
|
+
): ApplyWorkflowResumeResult {
|
|
323
|
+
if (!input.waitpointId && !input.waitpointKey) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
code: "missing_waitpoint_selector",
|
|
327
|
+
message: "resume requires waitpointId or waitpointKey",
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const matched = input.waitpoints.find((waitpoint) => {
|
|
332
|
+
if (input.waitpointId && workflowWaitpointId(waitpoint) === input.waitpointId) return true
|
|
333
|
+
return (
|
|
334
|
+
input.waitpointKey !== undefined && workflowWaitpointKey(waitpoint) === input.waitpointKey
|
|
335
|
+
)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
if (!matched) {
|
|
339
|
+
const selector = input.waitpointId
|
|
340
|
+
? `waitpointId=${input.waitpointId}`
|
|
341
|
+
: `waitpointKey=${input.waitpointKey}`
|
|
342
|
+
return {
|
|
343
|
+
ok: false,
|
|
344
|
+
code: "waitpoint_not_found",
|
|
345
|
+
message: `no pending waitpoint matches ${selector}`,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const journal = structuredClone(input.journal) as JournalSlice
|
|
350
|
+
const resolution: WaitpointResolutionEntry = {
|
|
351
|
+
kind: matched.kind,
|
|
352
|
+
resolvedAt: input.resolvedAt ?? Date.now(),
|
|
353
|
+
source: input.source ?? "live",
|
|
354
|
+
}
|
|
355
|
+
if ("payload" in input) resolution.payload = input.payload
|
|
356
|
+
if (input.payloadRef) resolution.payloadRef = input.payloadRef
|
|
357
|
+
if (input.matchedEventId) resolution.matchedEventId = input.matchedEventId
|
|
358
|
+
journal.waitpointsResolved[workflowWaitpointId(matched)] = resolution
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
ok: true,
|
|
362
|
+
journal,
|
|
363
|
+
waitpoint: snapshotWorkflowWaitpoint(matched, input.parkedAt ?? resolution.resolvedAt),
|
|
364
|
+
resolution,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function workflowWaitpointId(waitpoint: WorkflowWaitpointResumeTarget): string {
|
|
369
|
+
return isWorkflowWaitpointSnapshot(waitpoint) ? waitpoint.id : waitpoint.clientWaitpointId
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isWorkflowWaitpointSnapshot(
|
|
373
|
+
waitpoint: WorkflowWaitpointResumeTarget,
|
|
374
|
+
): waitpoint is WorkflowWaitpointSnapshot {
|
|
375
|
+
return "id" in waitpoint
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// WebSocket stream events — full union in docs/runtime-protocol.md §6.2.
|
|
379
|
+
export type StreamEvent =
|
|
380
|
+
| {
|
|
381
|
+
kind: "step.started"
|
|
382
|
+
eventId: string
|
|
383
|
+
at: number
|
|
384
|
+
stepId: string
|
|
385
|
+
runtime: "edge" | "node"
|
|
386
|
+
machine?: string
|
|
387
|
+
}
|
|
388
|
+
| {
|
|
389
|
+
kind: "step.ok"
|
|
390
|
+
eventId: string
|
|
391
|
+
at: number
|
|
392
|
+
stepId: string
|
|
393
|
+
attempt: number
|
|
394
|
+
durationMs: number
|
|
395
|
+
output?: unknown
|
|
396
|
+
}
|
|
397
|
+
| {
|
|
398
|
+
kind: "step.err"
|
|
399
|
+
eventId: string
|
|
400
|
+
at: number
|
|
401
|
+
stepId: string
|
|
402
|
+
attempt: number
|
|
403
|
+
error: SerializedError
|
|
404
|
+
}
|
|
405
|
+
| { kind: "step.skipped"; eventId: string; at: number; stepId: string; reason: string }
|
|
406
|
+
| {
|
|
407
|
+
kind: "step.compensated"
|
|
408
|
+
eventId: string
|
|
409
|
+
at: number
|
|
410
|
+
stepId: string
|
|
411
|
+
status: "ok" | "err"
|
|
412
|
+
error?: SerializedError
|
|
413
|
+
}
|
|
414
|
+
| {
|
|
415
|
+
kind: "waitpoint.registered"
|
|
416
|
+
eventId: string
|
|
417
|
+
at: number
|
|
418
|
+
waitpointId: string
|
|
419
|
+
waitpointKind: WaitpointKind
|
|
420
|
+
meta: Record<string, unknown>
|
|
421
|
+
}
|
|
422
|
+
| {
|
|
423
|
+
kind: "waitpoint.resolved"
|
|
424
|
+
eventId: string
|
|
425
|
+
at: number
|
|
426
|
+
waitpointId: string
|
|
427
|
+
payload?: unknown
|
|
428
|
+
source: "live" | "inbox" | "replay"
|
|
429
|
+
}
|
|
430
|
+
| { kind: "metadata.changed"; eventId: string; at: number; metadata: Record<string, unknown> }
|
|
431
|
+
| {
|
|
432
|
+
kind: "stream.chunk"
|
|
433
|
+
eventId: string
|
|
434
|
+
at: number
|
|
435
|
+
streamId: string
|
|
436
|
+
chunk: unknown
|
|
437
|
+
encoding: "json" | "text" | "base64"
|
|
438
|
+
final: boolean
|
|
439
|
+
}
|
|
440
|
+
| {
|
|
441
|
+
kind: "log"
|
|
442
|
+
eventId: string
|
|
443
|
+
at: number
|
|
444
|
+
level: "info" | "warn" | "error"
|
|
445
|
+
message: string
|
|
446
|
+
stepId?: string
|
|
447
|
+
data?: object
|
|
448
|
+
}
|
|
449
|
+
| { kind: "version.rebased"; eventId: string; at: number; fromVersion: string; toVersion: string }
|
|
450
|
+
| { kind: "run.cancelled"; eventId: string; at: number; reason?: string }
|
|
451
|
+
| {
|
|
452
|
+
kind: "run.finished"
|
|
453
|
+
eventId: string
|
|
454
|
+
at: number
|
|
455
|
+
status: string
|
|
456
|
+
output?: unknown
|
|
457
|
+
error?: SerializedError
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Shared envelope for journal events written by the orchestrator,
|
|
461
|
+
// the tenant worker, or a node-runtime container. Concrete `kind`
|
|
462
|
+
// discriminants are owned by the emitting layer.
|
|
463
|
+
export interface JournalEventEnvelope<TKind extends string = string, TData = unknown> {
|
|
464
|
+
eventId: string
|
|
465
|
+
runId: string
|
|
466
|
+
createdAt: number
|
|
467
|
+
kind: TKind
|
|
468
|
+
data: TData
|
|
469
|
+
snapshotId?: string
|
|
470
|
+
writtenBy: "orchestrator" | "tenant" | "node"
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface PublicAccessTokenClaims {
|
|
474
|
+
sub: "pat"
|
|
475
|
+
tenantId: string
|
|
476
|
+
environment: "production" | "preview" | "development"
|
|
477
|
+
scope: ("read" | "trigger" | "cancel")[]
|
|
478
|
+
target:
|
|
479
|
+
| { kind: "run"; runId: string }
|
|
480
|
+
| { kind: "workflow"; workflowId: string }
|
|
481
|
+
| { kind: "tag"; tag: string }
|
|
482
|
+
exp: number
|
|
483
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @voyant-travel/workflows/rate-limit
|
|
2
|
+
//
|
|
3
|
+
// Reference rate limiter used by `ctx.step({ rateLimit: ... })`.
|
|
4
|
+
//
|
|
5
|
+
// A RateLimiter is a small interface: `acquire()` blocks (or throws,
|
|
6
|
+
// depending on `onLimit`) until `units` are available under `key` for
|
|
7
|
+
// a sliding window of `windowMs`. One shared limiter instance lives
|
|
8
|
+
// per tenant Worker process — callers wire it into `createStepHandler`
|
|
9
|
+
// via `{ rateLimiter: createInMemoryRateLimiter() }`.
|
|
10
|
+
//
|
|
11
|
+
// The in-memory impl is a token bucket: `capacity = limit`, refill
|
|
12
|
+
// rate = `limit / windowMs`. It's suitable for local dev and
|
|
13
|
+
// single-process deployments. Multi-region / sharded deployments
|
|
14
|
+
// should swap in a Durable-Object or Redis-backed implementation that
|
|
15
|
+
// shares state across isolates.
|
|
16
|
+
|
|
17
|
+
import type { Duration } from "../types.js"
|
|
18
|
+
|
|
19
|
+
export interface AcquireArgs {
|
|
20
|
+
/** Bucket key — usually a tenant id, a url host, a user id, etc. */
|
|
21
|
+
key: string
|
|
22
|
+
/** Maximum units the bucket can hold. */
|
|
23
|
+
limit: number
|
|
24
|
+
/** Units the current call consumes. */
|
|
25
|
+
units: number
|
|
26
|
+
/** Refill window in ms. `limit` tokens per `windowMs`. */
|
|
27
|
+
windowMs: number
|
|
28
|
+
/** `queue` → wait until capacity; `fail` → throw immediately. */
|
|
29
|
+
onLimit: "queue" | "fail"
|
|
30
|
+
/** Forwarded from the run; limiter observes aborts during queue waits. */
|
|
31
|
+
signal?: AbortSignal
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RateLimiter {
|
|
35
|
+
acquire(args: AcquireArgs): Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Error thrown when `onLimit === "fail"` and the bucket is empty. */
|
|
39
|
+
export class RateLimitExceededError extends Error {
|
|
40
|
+
readonly code = "RATE_LIMITED"
|
|
41
|
+
readonly retryAfterMs: number
|
|
42
|
+
constructor(key: string, retryAfterMs: number) {
|
|
43
|
+
super(`rate limit exceeded for key "${key}" (retry after ${retryAfterMs}ms)`)
|
|
44
|
+
this.name = "RateLimitExceededError"
|
|
45
|
+
this.retryAfterMs = retryAfterMs
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Bucket {
|
|
50
|
+
tokens: number
|
|
51
|
+
capacity: number
|
|
52
|
+
refillPerMs: number
|
|
53
|
+
lastRefillAt: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface InMemoryLimiterOptions {
|
|
57
|
+
/** Injectable clock, ms since epoch. Defaults to Date.now. */
|
|
58
|
+
now?: () => number
|
|
59
|
+
/** Injectable delay; defaults to setTimeout. Tests override this. */
|
|
60
|
+
delay?: (ms: number, signal?: AbortSignal) => Promise<void>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Token-bucket rate limiter held in-process. Independent buckets per
|
|
65
|
+
* `key`; bucket parameters (`capacity`, `refillPerMs`) come from the
|
|
66
|
+
* `limit` / `windowMs` of the first `acquire` call and are updated on
|
|
67
|
+
* subsequent calls that change them.
|
|
68
|
+
*/
|
|
69
|
+
export function createInMemoryRateLimiter(opts: InMemoryLimiterOptions = {}): RateLimiter {
|
|
70
|
+
const now = opts.now ?? (() => Date.now())
|
|
71
|
+
const delay = opts.delay ?? defaultDelay
|
|
72
|
+
const buckets = new Map<string, Bucket>()
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
async acquire(args) {
|
|
76
|
+
if (args.units <= 0) return
|
|
77
|
+
if (args.limit <= 0) {
|
|
78
|
+
throw new Error(`rate-limit: "limit" must be > 0 (got ${args.limit}) for key "${args.key}"`)
|
|
79
|
+
}
|
|
80
|
+
if (args.windowMs <= 0) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`rate-limit: "windowMs" must be > 0 (got ${args.windowMs}) for key "${args.key}"`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
if (args.units > args.limit) {
|
|
86
|
+
// The step will never be admissible — short-circuit regardless
|
|
87
|
+
// of onLimit to avoid hanging indefinitely under queue mode.
|
|
88
|
+
throw new Error(
|
|
89
|
+
`rate-limit: units (${args.units}) > limit (${args.limit}) for key "${args.key}" — step can never be admitted`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
while (true) {
|
|
94
|
+
const bucket = refill(buckets, args, now())
|
|
95
|
+
if (bucket.tokens >= args.units) {
|
|
96
|
+
bucket.tokens -= args.units
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const missing = args.units - bucket.tokens
|
|
100
|
+
const waitMs = Math.ceil(missing / bucket.refillPerMs)
|
|
101
|
+
if (args.onLimit === "fail") {
|
|
102
|
+
throw new RateLimitExceededError(args.key, waitMs)
|
|
103
|
+
}
|
|
104
|
+
await delay(waitMs, args.signal)
|
|
105
|
+
if (args.signal?.aborted) {
|
|
106
|
+
throw args.signal.reason ?? new Error("rate-limit: aborted while queued")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function refill(buckets: Map<string, Bucket>, args: AcquireArgs, nowMs: number): Bucket {
|
|
114
|
+
const refillPerMs = args.limit / args.windowMs
|
|
115
|
+
let b = buckets.get(args.key)
|
|
116
|
+
if (!b) {
|
|
117
|
+
b = {
|
|
118
|
+
tokens: args.limit,
|
|
119
|
+
capacity: args.limit,
|
|
120
|
+
refillPerMs,
|
|
121
|
+
lastRefillAt: nowMs,
|
|
122
|
+
}
|
|
123
|
+
buckets.set(args.key, b)
|
|
124
|
+
return b
|
|
125
|
+
}
|
|
126
|
+
// Re-parameterize if the caller changed limit / windowMs. Clamp
|
|
127
|
+
// tokens to the new capacity so a shrink doesn't leave stale excess.
|
|
128
|
+
if (b.capacity !== args.limit || b.refillPerMs !== refillPerMs) {
|
|
129
|
+
b.capacity = args.limit
|
|
130
|
+
b.refillPerMs = refillPerMs
|
|
131
|
+
if (b.tokens > b.capacity) b.tokens = b.capacity
|
|
132
|
+
}
|
|
133
|
+
const elapsed = Math.max(0, nowMs - b.lastRefillAt)
|
|
134
|
+
if (elapsed > 0) {
|
|
135
|
+
b.tokens = Math.min(b.capacity, b.tokens + elapsed * b.refillPerMs)
|
|
136
|
+
b.lastRefillAt = nowMs
|
|
137
|
+
}
|
|
138
|
+
return b
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
if (signal?.aborted) {
|
|
144
|
+
reject(signal.reason ?? new Error("aborted"))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
signal?.removeEventListener("abort", onAbort)
|
|
149
|
+
resolve()
|
|
150
|
+
}, ms)
|
|
151
|
+
const onAbort = () => {
|
|
152
|
+
clearTimeout(timer)
|
|
153
|
+
reject(signal?.reason ?? new Error("aborted"))
|
|
154
|
+
}
|
|
155
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Normalize a Duration to milliseconds. Same units as `toMs` in ctx.ts. */
|
|
160
|
+
export function durationToMs(d: Duration): number {
|
|
161
|
+
if (typeof d === "number") return d
|
|
162
|
+
const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(d)
|
|
163
|
+
if (!m) throw new Error(`rate-limit: invalid duration "${d}"`)
|
|
164
|
+
const n = Number(m[1])
|
|
165
|
+
switch (m[2]) {
|
|
166
|
+
case "ms":
|
|
167
|
+
return n
|
|
168
|
+
case "s":
|
|
169
|
+
return n * 1_000
|
|
170
|
+
case "m":
|
|
171
|
+
return n * 60_000
|
|
172
|
+
case "h":
|
|
173
|
+
return n * 3_600_000
|
|
174
|
+
case "d":
|
|
175
|
+
return n * 86_400_000
|
|
176
|
+
case "w":
|
|
177
|
+
return n * 604_800_000
|
|
178
|
+
default:
|
|
179
|
+
throw new Error(`rate-limit: invalid duration "${d}"`)
|
|
180
|
+
}
|
|
181
|
+
}
|