@mokronos/wfkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/authoring.d.ts +7 -0
  4. package/dist/authoring.d.ts.map +1 -0
  5. package/dist/authoring.js +8 -0
  6. package/dist/authoring.js.map +1 -0
  7. package/dist/cli/integrations.d.ts +3 -0
  8. package/dist/cli/integrations.d.ts.map +1 -0
  9. package/dist/cli/integrations.js +40 -0
  10. package/dist/cli/integrations.js.map +1 -0
  11. package/dist/cli/main.d.ts +3 -0
  12. package/dist/cli/main.d.ts.map +1 -0
  13. package/dist/cli/main.js +591 -0
  14. package/dist/cli/main.js.map +1 -0
  15. package/dist/core.d.ts +198 -0
  16. package/dist/core.d.ts.map +1 -0
  17. package/dist/core.js +1061 -0
  18. package/dist/core.js.map +1 -0
  19. package/dist/errors.d.ts +3 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +13 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/events.d.ts +154 -0
  24. package/dist/events.d.ts.map +1 -0
  25. package/dist/events.js +24 -0
  26. package/dist/events.js.map +1 -0
  27. package/dist/index.d.ts +15 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +11 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/replay.d.ts +3 -0
  32. package/dist/replay.d.ts.map +1 -0
  33. package/dist/replay.js +28 -0
  34. package/dist/replay.js.map +1 -0
  35. package/dist/runtime.d.ts +70 -0
  36. package/dist/runtime.d.ts.map +1 -0
  37. package/dist/runtime.js +183 -0
  38. package/dist/runtime.js.map +1 -0
  39. package/dist/schema.d.ts +18 -0
  40. package/dist/schema.d.ts.map +1 -0
  41. package/dist/schema.js +19 -0
  42. package/dist/schema.js.map +1 -0
  43. package/dist/schemas.d.ts +1198 -0
  44. package/dist/schemas.d.ts.map +1 -0
  45. package/dist/schemas.js +383 -0
  46. package/dist/schemas.js.map +1 -0
  47. package/dist/sdk/artifact.d.ts +41 -0
  48. package/dist/sdk/artifact.d.ts.map +1 -0
  49. package/dist/sdk/artifact.js +94 -0
  50. package/dist/sdk/artifact.js.map +1 -0
  51. package/dist/sdk/graph.d.ts +15 -0
  52. package/dist/sdk/graph.d.ts.map +1 -0
  53. package/dist/sdk/graph.js +388 -0
  54. package/dist/sdk/graph.js.map +1 -0
  55. package/dist/sdk/index.d.ts +14 -0
  56. package/dist/sdk/index.d.ts.map +1 -0
  57. package/dist/sdk/index.js +8 -0
  58. package/dist/sdk/index.js.map +1 -0
  59. package/dist/sdk/integrations.d.ts +18 -0
  60. package/dist/sdk/integrations.d.ts.map +1 -0
  61. package/dist/sdk/integrations.js +20 -0
  62. package/dist/sdk/integrations.js.map +1 -0
  63. package/dist/sdk/json.d.ts +4 -0
  64. package/dist/sdk/json.d.ts.map +1 -0
  65. package/dist/sdk/json.js +31 -0
  66. package/dist/sdk/json.js.map +1 -0
  67. package/dist/sdk/loader.d.ts +10 -0
  68. package/dist/sdk/loader.d.ts.map +1 -0
  69. package/dist/sdk/loader.js +75 -0
  70. package/dist/sdk/loader.js.map +1 -0
  71. package/dist/sdk/sdk.d.ts +102 -0
  72. package/dist/sdk/sdk.d.ts.map +1 -0
  73. package/dist/sdk/sdk.js +669 -0
  74. package/dist/sdk/sdk.js.map +1 -0
  75. package/dist/sdk/sqlite.d.ts +8 -0
  76. package/dist/sdk/sqlite.d.ts.map +1 -0
  77. package/dist/sdk/sqlite.js +288 -0
  78. package/dist/sdk/sqlite.js.map +1 -0
  79. package/dist/signal.d.ts +17 -0
  80. package/dist/signal.d.ts.map +1 -0
  81. package/dist/signal.js +78 -0
  82. package/dist/signal.js.map +1 -0
  83. package/dist/testing/index.d.ts +35 -0
  84. package/dist/testing/index.d.ts.map +1 -0
  85. package/dist/testing/index.js +244 -0
  86. package/dist/testing/index.js.map +1 -0
  87. package/package.json +67 -0
  88. package/src/authoring.ts +32 -0
  89. package/src/cli/integrations.ts +75 -0
  90. package/src/cli/main.ts +793 -0
  91. package/src/core.ts +1515 -0
  92. package/src/errors.ts +19 -0
  93. package/src/events.ts +37 -0
  94. package/src/index.ts +109 -0
  95. package/src/replay.ts +29 -0
  96. package/src/runtime.ts +314 -0
  97. package/src/schema.ts +34 -0
  98. package/src/schemas.ts +473 -0
  99. package/src/sdk/artifact.ts +176 -0
  100. package/src/sdk/graph.ts +486 -0
  101. package/src/sdk/index.ts +64 -0
  102. package/src/sdk/integrations.ts +49 -0
  103. package/src/sdk/json.ts +40 -0
  104. package/src/sdk/loader.ts +99 -0
  105. package/src/sdk/sdk.ts +911 -0
  106. package/src/sdk/sqlite.ts +405 -0
  107. package/src/signal.ts +116 -0
  108. package/src/testing/index.ts +341 -0
package/src/sdk/sdk.ts ADDED
@@ -0,0 +1,911 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { mkdirSync } from "node:fs"
3
+ import path from "node:path"
4
+ import { Schema } from "effect"
5
+ import type { DefinedWorkflow } from "../core.ts"
6
+ import { Cancelled, cancellationDeferredName, createInMemoryDeterminismState } from "../core.ts"
7
+ import type { WorkflowEvent } from "../events.ts"
8
+ import { ExecutionId, WorkflowHistoryEvent as WorkflowHistoryEventSchema } from "../schemas.ts"
9
+ import type { JsonSchema, WorkflowHistoryEvent } from "../schemas.ts"
10
+ import { createWorkflowRuntime, executeWorkflow } from "../runtime.ts"
11
+ import type { ExecuteWorkflowOptions } from "../runtime.ts"
12
+ import type { WorkflowRuntime } from "../runtime.ts"
13
+ import { cancelSignalWaits, decodeSignal, deliverSignal, getSignalSchema } from "../signal.ts"
14
+ import type {
15
+ WorkflowArtifact,
16
+ WorkflowRunEventRecord,
17
+ WorkflowRunRecord,
18
+ WorkflowRunStore,
19
+ WorkflowStore
20
+ } from "./artifact.ts"
21
+ import { createFileWorkflowStore } from "./artifact.ts"
22
+ import { parseJsonText, toJsonText } from "./json.ts"
23
+ import { loadWorkflowArtifact } from "./loader.ts"
24
+ import { replayDedupeKey } from "../replay.ts"
25
+
26
+ export type WorkflowExecutionStatus =
27
+ | "running"
28
+ | "suspended"
29
+ | "completed"
30
+ | "failed"
31
+ | "compensating"
32
+
33
+ export interface WorkflowExecutionHandle {
34
+ readonly executionId: string
35
+ readonly version: number
36
+ }
37
+
38
+ export type WorkflowResult =
39
+ | { readonly type: "completed"; readonly value: unknown }
40
+ | { readonly type: "failed"; readonly error: unknown }
41
+
42
+ export type { WorkflowHistoryEvent }
43
+
44
+ export interface WorkflowHistoryRecord {
45
+ readonly sequence: number
46
+ readonly createdAt: string
47
+ readonly event: WorkflowHistoryEvent
48
+ }
49
+
50
+ export interface PendingSignal {
51
+ readonly name: string
52
+ readonly invocation: number
53
+ readonly activityName: string
54
+ readonly timeout?: unknown
55
+ /** JSON Schema of the payload the wait expects. */
56
+ readonly payloadSchema?: JsonSchema
57
+ }
58
+
59
+ export interface WorkflowListResult {
60
+ readonly executions: ReadonlyArray<{
61
+ readonly executionId: string
62
+ readonly workflowName: string
63
+ readonly version: number
64
+ readonly status: WorkflowExecutionStatus
65
+ readonly startedAt: string
66
+ readonly finishedAt?: string
67
+ }>
68
+ readonly cursor?: string
69
+ }
70
+
71
+ export interface WorkflowClient {
72
+ start<I, O, E>(
73
+ workflow: DefinedWorkflow<I, O, E>,
74
+ payload: I,
75
+ opts?: { readonly idempotencyKey?: string; readonly actor?: string; readonly version?: number }
76
+ ): Promise<WorkflowExecutionHandle>
77
+ signal(
78
+ executionId: string,
79
+ name: string,
80
+ payload: unknown,
81
+ opts?: { readonly actor?: string }
82
+ ): Promise<void>
83
+ result(executionId: string): Promise<WorkflowResult>
84
+ status(executionId: string): Promise<WorkflowExecutionStatus>
85
+ list<I, O, E>(
86
+ workflow: DefinedWorkflow<I, O, E>,
87
+ opts?: {
88
+ readonly status?: WorkflowExecutionStatus
89
+ readonly version?: number
90
+ readonly limit?: number
91
+ readonly cursor?: string
92
+ }
93
+ ): Promise<WorkflowListResult>
94
+ history(executionId: string): Promise<ReadonlyArray<WorkflowHistoryRecord>>
95
+ pendingSignals(executionId: string): Promise<ReadonlyArray<PendingSignal>>
96
+ cancel(
97
+ executionId: string,
98
+ opts?: { readonly compensate?: boolean; readonly actor?: string }
99
+ ): Promise<void>
100
+ }
101
+
102
+ export { Cancelled } from "../core.ts"
103
+
104
+ export class MissingWorkflowVersionError extends Error {
105
+ readonly _tag = "MissingWorkflowVersionError"
106
+ readonly workflowName: string
107
+ readonly version: number
108
+
109
+ constructor(options: { readonly workflowName: string; readonly version: number }) {
110
+ super(
111
+ `Workflow ${options.workflowName}@v${options.version} is not registered in this runtime; re-register that exact version before resuming this execution`
112
+ )
113
+ this.name = "MissingWorkflowVersionError"
114
+ this.workflowName = options.workflowName
115
+ this.version = options.version
116
+ }
117
+ }
118
+
119
+ interface ExecutionRecord {
120
+ readonly executionId: string
121
+ readonly workflow: DefinedWorkflow<any, any, any>
122
+ readonly payload: unknown
123
+ status: WorkflowExecutionStatus
124
+ result?: WorkflowResult
125
+ readonly startedAt: string
126
+ finishedAt?: string
127
+ readonly history: WorkflowHistoryRecord[]
128
+ readonly resultPromise: Promise<WorkflowResult>
129
+ readonly resolveResult: (result: WorkflowResult) => void
130
+ }
131
+
132
+ const executionId = () => crypto.randomUUID()
133
+ const nowIso = () => new Date().toISOString()
134
+ const encodeStoredValue = (value: unknown): string => JSON.stringify({ value })
135
+ const decodeStoredValue = (json: string): unknown => (JSON.parse(json) as { value: unknown }).value
136
+ const optionalActor = (actor: string | undefined): { readonly actor?: string } =>
137
+ actor === undefined ? {} : { actor }
138
+ const optionalFinishedAt = (finishedAt: string | undefined): { readonly finishedAt?: string } =>
139
+ finishedAt === undefined ? {} : { finishedAt }
140
+ const optionalCursor = (cursor: string | undefined): { readonly cursor?: string } =>
141
+ cursor === undefined ? {} : { cursor }
142
+ const signalKey = (event: { readonly name: string; readonly invocation: number }) =>
143
+ `${event.name}:${event.invocation}`
144
+ const optionalTimeout = (timeout: unknown): { readonly timeout?: unknown } =>
145
+ timeout === undefined ? {} : { timeout }
146
+
147
+ export const pendingSignalsFromHistory = (
148
+ history: ReadonlyArray<WorkflowHistoryRecord>
149
+ ): ReadonlyArray<PendingSignal> => {
150
+ const consumed = new Set<string>()
151
+ for (const record of history) {
152
+ const event = record.event
153
+ if (event.type === "signal.received" || event.type === "signal.timeout") {
154
+ consumed.add(signalKey(event))
155
+ }
156
+ }
157
+
158
+ return history.flatMap((record) => {
159
+ const event = record.event
160
+ if (event.type !== "signal.waiting" || consumed.has(signalKey(event))) {
161
+ return []
162
+ }
163
+ return [{
164
+ name: event.name,
165
+ invocation: event.invocation,
166
+ activityName: event.activityName,
167
+ ...optionalTimeout(event.timeout),
168
+ ...(event.payloadSchema === undefined ? {} : { payloadSchema: event.payloadSchema })
169
+ }]
170
+ })
171
+ }
172
+
173
+ export const createWorkflowClient = (
174
+ runtime: WorkflowRuntime = createWorkflowRuntime({ backend: "memory" })
175
+ ): WorkflowClient =>
176
+ runtime.backend === "sqlite" ? createDurableWorkflowClient(runtime) : createMemoryWorkflowClient(runtime)
177
+
178
+ const createMemoryWorkflowClient = (runtime?: WorkflowRuntime): WorkflowClient => {
179
+ const executions = new Map<string, ExecutionRecord>()
180
+ const idempotencyKeys = new Map<string, string>()
181
+
182
+ const appendHistory = (record: ExecutionRecord, event: WorkflowHistoryEvent) => {
183
+ record.history.push({
184
+ sequence: record.history.length + 1,
185
+ createdAt: nowIso(),
186
+ event
187
+ })
188
+ }
189
+
190
+ const requireExecution = (id: string): ExecutionRecord => {
191
+ const execution = executions.get(id)
192
+ if (execution === undefined) {
193
+ throw new Error(`Unknown workflow execution: ${id}`)
194
+ }
195
+ return execution
196
+ }
197
+
198
+ const statusFromEvent = (event: WorkflowEvent): WorkflowExecutionStatus | undefined => {
199
+ switch (event.type) {
200
+ case "sleep.started":
201
+ case "signal.waiting":
202
+ return "suspended"
203
+ case "sleep.completed":
204
+ case "signal.received":
205
+ case "signal.timeout":
206
+ case "step.started":
207
+ case "step.completed":
208
+ return "running"
209
+ default:
210
+ return undefined
211
+ }
212
+ }
213
+
214
+ return {
215
+ async start(workflow, payload, opts = {}) {
216
+ const workflowKey = `${workflow.name}@v${workflow.version}`
217
+ if (opts.idempotencyKey !== undefined) {
218
+ const existingId = idempotencyKeys.get(`${workflowKey}:${opts.idempotencyKey}`)
219
+ if (existingId !== undefined) {
220
+ return { executionId: existingId, version: workflow.version }
221
+ }
222
+ }
223
+
224
+ const id = executionId()
225
+ let resolveResult!: (result: WorkflowResult) => void
226
+ const resultPromise = new Promise<WorkflowResult>((resolve) => {
227
+ resolveResult = resolve
228
+ })
229
+ const execution: ExecutionRecord = {
230
+ executionId: id,
231
+ workflow,
232
+ payload,
233
+ status: "running",
234
+ startedAt: nowIso(),
235
+ history: [],
236
+ resultPromise,
237
+ resolveResult
238
+ }
239
+ executions.set(id, execution)
240
+ if (opts.idempotencyKey !== undefined) {
241
+ idempotencyKeys.set(`${workflowKey}:${opts.idempotencyKey}`, id)
242
+ }
243
+
244
+ appendHistory(execution, {
245
+ type: "execution.started",
246
+ executionId: ExecutionId.make(id),
247
+ workflowName: workflow.name,
248
+ version: workflow.version,
249
+ payload,
250
+ ...optionalActor(opts.actor)
251
+ })
252
+
253
+ void workflow
254
+ .executeInMemory(payload, {
255
+ executionId: id,
256
+ determinism: createInMemoryDeterminismState(),
257
+ ...(runtime?.secrets === undefined ? {} : { secrets: runtime.secrets }),
258
+ onEvent: async (event) => {
259
+ appendHistory(execution, event)
260
+ const nextStatus = statusFromEvent(event)
261
+ if (nextStatus !== undefined && execution.status !== "failed") {
262
+ execution.status = nextStatus
263
+ }
264
+ if (event.type === "sleep.started") {
265
+ await new Promise((resolve) => setTimeout(resolve, 10))
266
+ }
267
+ }
268
+ })
269
+ .then(
270
+ (value) => {
271
+ execution.status = "completed"
272
+ execution.finishedAt = nowIso()
273
+ execution.result = { type: "completed", value }
274
+ execution.resolveResult(execution.result)
275
+ },
276
+ (error) => {
277
+ execution.status = "failed"
278
+ execution.finishedAt = nowIso()
279
+ execution.result = { type: "failed", error }
280
+ execution.resolveResult(execution.result)
281
+ }
282
+ )
283
+
284
+ return { executionId: id, version: workflow.version }
285
+ },
286
+
287
+ async signal(id, name, payload, opts = {}) {
288
+ const execution = requireExecution(id)
289
+ await deliverSignal(id, name, payload)
290
+ appendHistory(execution, {
291
+ type: "signal.delivered",
292
+ executionId: ExecutionId.make(id),
293
+ name,
294
+ payload,
295
+ ...optionalActor(opts.actor)
296
+ })
297
+ },
298
+
299
+ result(id) {
300
+ return requireExecution(id).resultPromise
301
+ },
302
+
303
+ async status(id) {
304
+ return requireExecution(id).status
305
+ },
306
+
307
+ async list(workflow, opts = {}) {
308
+ const all = Array.from(executions.values())
309
+ .filter((execution) => execution.workflow.name === workflow.name)
310
+ .filter((execution) => opts.version === undefined || execution.workflow.version === opts.version)
311
+ .filter((execution) => opts.status === undefined || execution.status === opts.status)
312
+ .sort((left, right) => left.startedAt.localeCompare(right.startedAt))
313
+ const start = opts.cursor === undefined ? 0 : Number.parseInt(opts.cursor, 10)
314
+ const limit = opts.limit ?? all.length
315
+ const page = all.slice(start, start + limit)
316
+ const next = start + limit < all.length ? String(start + limit) : undefined
317
+ return {
318
+ executions: page.map((execution) => ({
319
+ executionId: execution.executionId,
320
+ workflowName: execution.workflow.name,
321
+ version: execution.workflow.version,
322
+ status: execution.status,
323
+ startedAt: execution.startedAt,
324
+ ...optionalFinishedAt(execution.finishedAt)
325
+ })),
326
+ ...optionalCursor(next)
327
+ }
328
+ },
329
+
330
+ async history(id) {
331
+ return requireExecution(id).history
332
+ },
333
+
334
+ async pendingSignals(id) {
335
+ return pendingSignalsFromHistory(requireExecution(id).history)
336
+ },
337
+
338
+ async cancel(id, opts = {}) {
339
+ const execution = requireExecution(id)
340
+ const compensate = opts.compensate ?? true
341
+ appendHistory(execution, {
342
+ type: "execution.cancelled",
343
+ executionId: ExecutionId.make(id),
344
+ compensate,
345
+ ...optionalActor(opts.actor)
346
+ })
347
+ if (compensate) {
348
+ execution.status = "compensating"
349
+ }
350
+ cancelSignalWaits(id, new Cancelled({ compensate }))
351
+ const result = await execution.resultPromise
352
+ if (result.type === "failed") {
353
+ execution.status = "failed"
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ interface DurableExecutionRow {
360
+ readonly id: string
361
+ readonly workflow_name: string
362
+ readonly workflow_version: number
363
+ readonly status: WorkflowExecutionStatus
364
+ readonly payload_json: string
365
+ readonly idempotency_key: string | null
366
+ readonly actor: string | null
367
+ readonly result_json: string | null
368
+ readonly error_json: string | null
369
+ readonly started_at: string
370
+ readonly finished_at: string | null
371
+ }
372
+
373
+ interface DurableWorkflowCatalogRow {
374
+ readonly workflow_name: string
375
+ readonly workflow_version: number
376
+ readonly source_hash: string
377
+ readonly registered_at: string
378
+ }
379
+
380
+ interface DurableHistoryRow {
381
+ readonly sequence: number
382
+ readonly event_json: string
383
+ readonly created_at: string
384
+ }
385
+
386
+ const migrateClientDb = (db: Database) => {
387
+ db.exec(`
388
+ CREATE TABLE IF NOT EXISTS wf_client_executions (
389
+ id TEXT PRIMARY KEY,
390
+ workflow_name TEXT NOT NULL,
391
+ workflow_version INTEGER NOT NULL,
392
+ status TEXT NOT NULL,
393
+ payload_json TEXT NOT NULL,
394
+ idempotency_key TEXT,
395
+ actor TEXT,
396
+ result_json TEXT,
397
+ error_json TEXT,
398
+ started_at TEXT NOT NULL,
399
+ finished_at TEXT
400
+ );
401
+
402
+ CREATE UNIQUE INDEX IF NOT EXISTS wf_client_executions_idempotency_idx
403
+ ON wf_client_executions(workflow_name, workflow_version, idempotency_key)
404
+ WHERE idempotency_key IS NOT NULL;
405
+
406
+ CREATE TABLE IF NOT EXISTS wf_client_workflows (
407
+ workflow_name TEXT NOT NULL,
408
+ workflow_version INTEGER NOT NULL,
409
+ source_hash TEXT NOT NULL,
410
+ registered_at TEXT NOT NULL,
411
+ PRIMARY KEY (workflow_name, workflow_version)
412
+ );
413
+
414
+ CREATE TABLE IF NOT EXISTS wf_client_history (
415
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
416
+ execution_id TEXT NOT NULL,
417
+ sequence INTEGER NOT NULL,
418
+ event_json TEXT NOT NULL,
419
+ created_at TEXT NOT NULL,
420
+ dedupe_key TEXT,
421
+ UNIQUE(execution_id, sequence)
422
+ );
423
+ `)
424
+
425
+ const columns = db.query<{ name: string }, []>("PRAGMA table_info(wf_client_history)").all()
426
+ if (!columns.some((column) => column.name === "dedupe_key")) {
427
+ db.exec("ALTER TABLE wf_client_history ADD COLUMN dedupe_key TEXT")
428
+ }
429
+ db.exec(`
430
+ CREATE INDEX IF NOT EXISTS wf_client_history_dedupe_idx
431
+ ON wf_client_history(execution_id, dedupe_key)
432
+ WHERE dedupe_key IS NOT NULL
433
+ `)
434
+ }
435
+
436
+ const createDurableWorkflowClient = (runtime: WorkflowRuntime): WorkflowClient => {
437
+ const databasePath = runtime.databasePath
438
+ if (databasePath === undefined) {
439
+ throw new Error("SQLite workflow client requires runtime.databasePath")
440
+ }
441
+ mkdirSync(path.dirname(path.resolve(databasePath)), { recursive: true })
442
+ const db = new Database(databasePath, { create: true, readwrite: true })
443
+ migrateClientDb(db)
444
+ const runPromises = new Map<string, Promise<WorkflowResult>>()
445
+
446
+ const registerCatalog = (workflow: DefinedWorkflow) => {
447
+ const existing = db.query<DurableWorkflowCatalogRow, [string, number]>(`
448
+ SELECT workflow_name, workflow_version, source_hash, registered_at
449
+ FROM wf_client_workflows
450
+ WHERE workflow_name = ?
451
+ AND workflow_version = ?
452
+ `).get(workflow.name, workflow.version)
453
+
454
+ if (existing !== null) {
455
+ if (existing.source_hash !== workflow.sourceHash) {
456
+ throw new Error(
457
+ `Workflow ${workflow.name}@v${workflow.version} is already cataloged with different source; register a new version instead`
458
+ )
459
+ }
460
+ return
461
+ }
462
+
463
+ db.query<unknown, [string, number, string, string]>(`
464
+ INSERT INTO wf_client_workflows (workflow_name, workflow_version, source_hash, registered_at)
465
+ VALUES (?, ?, ?, ?)
466
+ `).run(workflow.name, workflow.version, workflow.sourceHash, nowIso())
467
+ }
468
+
469
+ const registerCatalogFromRuntime = (name: string) => {
470
+ for (const workflow of runtime.listWorkflows(name)) {
471
+ registerCatalog(workflow)
472
+ }
473
+ }
474
+
475
+ // Returns false when the event is a replay re-emission that is already
476
+ // recorded (the engine replays workflow code whenever a suspended run
477
+ // resumes, possibly in another process).
478
+ const appendHistory = (executionId: string, event: WorkflowHistoryEvent): boolean => {
479
+ const dedupeKey = replayDedupeKey(event) ?? null
480
+ if (dedupeKey !== null) {
481
+ const existing = db.query<{ id: number }, [string, string]>(`
482
+ SELECT id
483
+ FROM wf_client_history
484
+ WHERE execution_id = ?
485
+ AND dedupe_key = ?
486
+ `).get(executionId, dedupeKey)
487
+ if (existing !== null) {
488
+ return false
489
+ }
490
+ }
491
+ const sequence = db.query<{ sequence: number }, [string]>(`
492
+ SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence
493
+ FROM wf_client_history
494
+ WHERE execution_id = ?
495
+ `).get(executionId)?.sequence ?? 1
496
+ db.query<unknown, [string, number, string, string, string | null]>(`
497
+ INSERT INTO wf_client_history (execution_id, sequence, event_json, created_at, dedupe_key)
498
+ VALUES (?, ?, ?, ?, ?)
499
+ `).run(executionId, sequence, toJsonText(event), nowIso(), dedupeKey)
500
+ return true
501
+ }
502
+
503
+ const updateStatus = (executionId: string, status: WorkflowExecutionStatus) => {
504
+ db.query<unknown, [WorkflowExecutionStatus, string]>(`
505
+ UPDATE wf_client_executions
506
+ SET status = ?
507
+ WHERE id = ?
508
+ `).run(status, executionId)
509
+ }
510
+
511
+ const getRow = (executionId: string): DurableExecutionRow => {
512
+ const row = db.query<DurableExecutionRow, [string]>(`
513
+ SELECT *
514
+ FROM wf_client_executions
515
+ WHERE id = ?
516
+ `).get(executionId)
517
+ if (row === null) {
518
+ throw new Error(`Unknown workflow execution: ${executionId}`)
519
+ }
520
+ return row
521
+ }
522
+
523
+ const workflowFor = (row: DurableExecutionRow): DefinedWorkflow => {
524
+ const workflow = runtime.getWorkflow(row.workflow_name, row.workflow_version)
525
+ if (workflow === undefined) {
526
+ throw new MissingWorkflowVersionError({
527
+ workflowName: row.workflow_name,
528
+ version: row.workflow_version
529
+ })
530
+ }
531
+ return workflow
532
+ }
533
+
534
+ const workflowForStart = (workflow: DefinedWorkflow, version?: number): DefinedWorkflow => {
535
+ runtime.register([workflow])
536
+ registerCatalogFromRuntime(workflow.name)
537
+
538
+ const selected = version === undefined
539
+ ? runtime.getLatestWorkflow(workflow.name)
540
+ : runtime.getWorkflow(workflow.name, version)
541
+
542
+ if (selected === undefined) {
543
+ throw new MissingWorkflowVersionError({
544
+ workflowName: workflow.name,
545
+ version: version ?? workflow.version
546
+ })
547
+ }
548
+
549
+ registerCatalog(selected)
550
+ return selected
551
+ }
552
+
553
+ const statusFromEvent = (event: WorkflowEvent): WorkflowExecutionStatus | undefined => {
554
+ switch (event.type) {
555
+ case "sleep.started":
556
+ case "signal.waiting":
557
+ return "suspended"
558
+ case "sleep.completed":
559
+ case "signal.received":
560
+ case "signal.timeout":
561
+ case "step.started":
562
+ case "step.completed":
563
+ return "running"
564
+ default:
565
+ return undefined
566
+ }
567
+ }
568
+
569
+ const makeEventSink = (executionId: string) => async (event: WorkflowEvent) => {
570
+ if (!appendHistory(executionId, event)) {
571
+ // Replay re-emission: the status transition already happened when the
572
+ // event fired for real, so don't let the replay flap it.
573
+ return
574
+ }
575
+ const status = statusFromEvent(event)
576
+ if (status !== undefined) {
577
+ updateStatus(executionId, status)
578
+ }
579
+ }
580
+
581
+ const readHistory = (executionId: string): ReadonlyArray<WorkflowHistoryRecord> => {
582
+ getRow(executionId)
583
+ return db.query<DurableHistoryRow, [string]>(`
584
+ SELECT sequence, event_json, created_at
585
+ FROM wf_client_history
586
+ WHERE execution_id = ?
587
+ ORDER BY sequence
588
+ `).all(executionId).map((row) => ({
589
+ sequence: row.sequence,
590
+ createdAt: row.created_at,
591
+ event: Schema.decodeUnknownSync(WorkflowHistoryEventSchema)(JSON.parse(row.event_json))
592
+ }))
593
+ }
594
+
595
+ const runToTerminal = async (row: DurableExecutionRow): Promise<WorkflowResult> => {
596
+ if (row.status === "completed") {
597
+ return { type: "completed", value: parseJsonText(row.result_json) }
598
+ }
599
+ if (row.status === "failed") {
600
+ return { type: "failed", error: parseJsonText(row.error_json) }
601
+ }
602
+
603
+ const existing = runPromises.get(row.id)
604
+ if (existing !== undefined) {
605
+ return existing
606
+ }
607
+
608
+ const promise: Promise<WorkflowResult> = (async (): Promise<WorkflowResult> => {
609
+ const workflow = workflowFor(row)
610
+ try {
611
+ const value = await runtime.execute({
612
+ workflow,
613
+ payload: decodeStoredValue(row.payload_json),
614
+ executionId: row.id,
615
+ onEvent: makeEventSink(row.id)
616
+ })
617
+ db.query<unknown, [string, string, string]>(`
618
+ UPDATE wf_client_executions
619
+ SET status = 'completed',
620
+ result_json = ?,
621
+ finished_at = ?
622
+ WHERE id = ?
623
+ `).run(toJsonText(value), nowIso(), row.id)
624
+ return { type: "completed", value }
625
+ } catch (error) {
626
+ db.query<unknown, [string, string, string]>(`
627
+ UPDATE wf_client_executions
628
+ SET status = 'failed',
629
+ error_json = ?,
630
+ finished_at = ?
631
+ WHERE id = ?
632
+ `).run(toJsonText(error), nowIso(), row.id)
633
+ return { type: "failed", error }
634
+ }
635
+ })()
636
+ runPromises.set(row.id, promise)
637
+ try {
638
+ return await promise
639
+ } finally {
640
+ if (runPromises.get(row.id) === promise) {
641
+ runPromises.delete(row.id)
642
+ }
643
+ }
644
+ }
645
+
646
+ return {
647
+ async start(workflow, payload, opts = {}) {
648
+ const selectedWorkflow = workflowForStart(workflow, opts.version)
649
+ if (opts.idempotencyKey !== undefined) {
650
+ const existing = db.query<{ id: string }, [string, number, string]>(`
651
+ SELECT id
652
+ FROM wf_client_executions
653
+ WHERE workflow_name = ?
654
+ AND workflow_version = ?
655
+ AND idempotency_key = ?
656
+ `).get(selectedWorkflow.name, selectedWorkflow.version, opts.idempotencyKey)
657
+ if (existing !== null) {
658
+ return { executionId: existing.id, version: selectedWorkflow.version }
659
+ }
660
+ }
661
+
662
+ const id = executionId()
663
+ db.query<unknown, [string, string, number, string, string | null, string | null, string]>(`
664
+ INSERT INTO wf_client_executions (
665
+ id,
666
+ workflow_name,
667
+ workflow_version,
668
+ status,
669
+ payload_json,
670
+ idempotency_key,
671
+ actor,
672
+ started_at
673
+ )
674
+ VALUES (?, ?, ?, 'running', ?, ?, ?, ?)
675
+ `).run(
676
+ id,
677
+ selectedWorkflow.name,
678
+ selectedWorkflow.version,
679
+ encodeStoredValue(payload),
680
+ opts.idempotencyKey ?? null,
681
+ opts.actor ?? null,
682
+ nowIso()
683
+ )
684
+ appendHistory(id, {
685
+ type: "execution.started",
686
+ executionId: ExecutionId.make(id),
687
+ workflowName: selectedWorkflow.name,
688
+ version: selectedWorkflow.version,
689
+ payload,
690
+ ...optionalActor(opts.actor)
691
+ })
692
+
693
+ void runToTerminal(getRow(id))
694
+ return { executionId: id, version: selectedWorkflow.version }
695
+ },
696
+
697
+ async signal(executionId, name, payload, opts = {}) {
698
+ const row = getRow(executionId)
699
+ const workflow = workflowFor(row)
700
+ const waiting = pendingSignalsFromHistory(readHistory(executionId))
701
+ .filter((signal) => signal.name === name)
702
+ .at(-1)
703
+ if (waiting === undefined) {
704
+ throw new Error(`Execution ${executionId} is not waiting for signal ${name}`)
705
+ }
706
+ // Validate the payload against the schema of the wait the run is parked
707
+ // at BEFORE completing the durable deferred — a bad value persisted into
708
+ // the deferred would otherwise fail the run at replay. In a fresh
709
+ // process the schema registry is empty until the run replays, so nudge
710
+ // a resume and wait for the workflow to park again.
711
+ let schema = getSignalSchema(executionId, name)
712
+ if (schema === undefined) {
713
+ await runtime.resume({ workflow, executionId })
714
+ for (let attempt = 0; attempt < 50 && schema === undefined; attempt++) {
715
+ await new Promise((resolve) => setTimeout(resolve, 50))
716
+ schema = getSignalSchema(executionId, name)
717
+ }
718
+ }
719
+ if (schema !== undefined) {
720
+ decodeSignal(schema, payload)
721
+ }
722
+ await runtime.deliverSignal({
723
+ workflow,
724
+ executionId,
725
+ deferredName: `signal:${waiting.activityName}`,
726
+ payload,
727
+ onEvent: makeEventSink(executionId)
728
+ })
729
+ appendHistory(executionId, {
730
+ type: "signal.delivered",
731
+ executionId: ExecutionId.make(executionId),
732
+ name,
733
+ payload,
734
+ ...optionalActor(opts.actor)
735
+ })
736
+ updateStatus(executionId, "running")
737
+ },
738
+
739
+ result(executionId) {
740
+ return runToTerminal(getRow(executionId))
741
+ },
742
+
743
+ async status(executionId) {
744
+ return getRow(executionId).status
745
+ },
746
+
747
+ async list(workflow, opts = {}) {
748
+ const rows = db.query<DurableExecutionRow, [string]>(`
749
+ SELECT *
750
+ FROM wf_client_executions
751
+ WHERE workflow_name = ?
752
+ ORDER BY started_at
753
+ `).all(workflow.name)
754
+ .filter((row) => opts.version === undefined || row.workflow_version === opts.version)
755
+ .filter((row) => opts.status === undefined || row.status === opts.status)
756
+ const start = opts.cursor === undefined ? 0 : Number.parseInt(opts.cursor, 10)
757
+ const limit = opts.limit ?? rows.length
758
+ const page = rows.slice(start, start + limit)
759
+ const next = start + limit < rows.length ? String(start + limit) : undefined
760
+ return {
761
+ executions: page.map((row) => ({
762
+ executionId: row.id,
763
+ workflowName: row.workflow_name,
764
+ version: row.workflow_version,
765
+ status: row.status,
766
+ startedAt: row.started_at,
767
+ ...optionalFinishedAt(row.finished_at ?? undefined)
768
+ })),
769
+ ...optionalCursor(next)
770
+ }
771
+ },
772
+
773
+ async history(executionId) {
774
+ return readHistory(executionId)
775
+ },
776
+
777
+ async pendingSignals(executionId) {
778
+ return pendingSignalsFromHistory(readHistory(executionId))
779
+ },
780
+
781
+ async cancel(executionId, opts = {}) {
782
+ const row = getRow(executionId)
783
+ const workflow = workflowFor(row)
784
+ const compensate = opts.compensate ?? true
785
+ appendHistory(executionId, {
786
+ type: "execution.cancelled",
787
+ executionId: ExecutionId.make(executionId),
788
+ compensate,
789
+ ...optionalActor(opts.actor)
790
+ })
791
+ if (compensate) {
792
+ // Complete the reserved cancellation deferred: the execution wakes at
793
+ // its current suspension point, fails with Cancelled, and unwinds the
794
+ // compensation stack before being recorded as failed.
795
+ updateStatus(executionId, "compensating")
796
+ await runtime.deliverSignal({
797
+ workflow,
798
+ executionId,
799
+ deferredName: cancellationDeferredName,
800
+ payload: { compensate: true, ...(opts.actor === undefined ? {} : { actor: opts.actor }) },
801
+ onEvent: makeEventSink(executionId)
802
+ })
803
+ db.query<unknown, [string, string, string]>(`
804
+ UPDATE wf_client_executions
805
+ SET status = 'failed',
806
+ error_json = ?,
807
+ finished_at = ?
808
+ WHERE id = ?
809
+ `).run(toJsonText(new Cancelled({ compensate: true })), nowIso(), executionId)
810
+ } else {
811
+ // Hard kill: engine-level interrupt, no unwind.
812
+ updateStatus(executionId, "failed")
813
+ await runtime.interrupt({ workflow, executionId })
814
+ }
815
+ }
816
+ }
817
+ }
818
+
819
+ export interface RunWorkflowOptions {
820
+ readonly id: string
821
+ readonly input: unknown
822
+ readonly onEvent?: ExecuteWorkflowOptions["onEvent"]
823
+ }
824
+
825
+ export interface WorkflowRunResult {
826
+ readonly artifact: WorkflowArtifact
827
+ readonly exportName: string
828
+ readonly runId?: string
829
+ readonly result: unknown
830
+ }
831
+
832
+ export interface WorkflowSdk {
833
+ listWorkflows(): Promise<ReadonlyArray<WorkflowArtifact>>
834
+ getWorkflow(id: string): Promise<WorkflowArtifact | undefined>
835
+ listRuns(): Promise<ReadonlyArray<WorkflowRunRecord>>
836
+ listRunEvents(runId: string): Promise<ReadonlyArray<WorkflowRunEventRecord>>
837
+ runWorkflow(options: RunWorkflowOptions): Promise<WorkflowRunResult>
838
+ }
839
+
840
+ export interface WorkflowSdkOptions {
841
+ readonly store?: WorkflowStore
842
+ readonly runStore?: WorkflowRunStore
843
+ }
844
+
845
+ export const createWorkflowSdk = (options: WorkflowSdkOptions = {}): WorkflowSdk => {
846
+ const store = options.store ?? createFileWorkflowStore()
847
+ const runStore = options.runStore
848
+
849
+ return {
850
+ listWorkflows() {
851
+ return store.list()
852
+ },
853
+
854
+ getWorkflow(id) {
855
+ return store.get(id)
856
+ },
857
+
858
+ listRuns() {
859
+ return runStore?.listRuns() ?? Promise.resolve([])
860
+ },
861
+
862
+ listRunEvents(runId) {
863
+ return runStore?.listRunEvents(runId) ?? Promise.resolve([])
864
+ },
865
+
866
+ async runWorkflow({ id, input, onEvent }) {
867
+ const artifact = await store.get(id)
868
+
869
+ if (artifact === undefined) {
870
+ throw new Error(`Unknown workflow id: ${id}`)
871
+ }
872
+
873
+ const runId = `${artifact.id}:${artifact.version}:${executionId()}`
874
+ await runStore?.startRun({ id: runId, workflow: artifact, input })
875
+
876
+ const loaded = await loadWorkflowArtifact(artifact)
877
+
878
+ const onRuntimeEvent: ExecuteWorkflowOptions["onEvent"] = async (event) => {
879
+ await runStore?.appendRunEvent({ runId, type: event.type, event })
880
+ await onEvent?.(event)
881
+ }
882
+
883
+ try {
884
+ const result = await executeWorkflow(loaded.workflow, input, { onEvent: onRuntimeEvent })
885
+ await runStore?.completeRun({ runId, result })
886
+
887
+ return {
888
+ artifact: loaded.artifact,
889
+ exportName: loaded.exportName,
890
+ runId,
891
+ result
892
+ }
893
+ } catch (error) {
894
+ await runStore?.failRun({ runId, error: serializeError(error) })
895
+ throw error
896
+ }
897
+ }
898
+ }
899
+ }
900
+
901
+ const serializeError = (error: unknown): unknown => {
902
+ if (error instanceof Error) {
903
+ return {
904
+ name: error.name,
905
+ message: error.message,
906
+ stack: error.stack
907
+ }
908
+ }
909
+
910
+ return JSON.parse(toJsonText(error))
911
+ }