@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
@@ -0,0 +1,793 @@
1
+ #!/usr/bin/env bun
2
+ import { readFile } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import {
5
+ createWorkflowClient,
6
+ createWorkflowRuntime,
7
+ createSqliteWorkflowRepository,
8
+ loadWorkflowArtifact,
9
+ sampleValueForJsonSchema,
10
+ toJsonText
11
+ } from "../index.ts"
12
+ import type {
13
+ PendingSignal,
14
+ WorkflowArtifact,
15
+ WorkflowClient,
16
+ WorkflowEvent,
17
+ WorkflowHistoryEvent,
18
+ WorkflowRepository,
19
+ WorkflowRunEventRecord,
20
+ WorkflowRunRecord
21
+ } from "../index.ts"
22
+
23
+ const help = `Usage:
24
+ wf create <workflow-id> [--name <workflow-name>] [--source <typescript>] [--file <path>] [--version <version>] [--force]
25
+ wf list
26
+ wf runs
27
+ wf history <execution-id>
28
+ wf run <workflow-id> [json-input]
29
+ wf signal <run-id> <signal-name> [json-payload] [--actor <actor>]
30
+
31
+ Examples:
32
+ wf create welcome-email
33
+ wf create email --file examples/email/email.ts
34
+ wf list
35
+ wf runs
36
+ wf run welcome-email '{"message":"hello"}'
37
+ wf signal 018f6c7c-4c3b-7f12-91c8-88560b4b21a9 approval '{"approved":true}'
38
+ `
39
+
40
+ const formatError = (error: unknown): string => {
41
+ if (error instanceof Error) {
42
+ return error.message
43
+ }
44
+ // Typed workflow errors are plain objects; String() would print
45
+ // "[object Object]".
46
+ return typeof error === "object" && error !== null ? toJsonText(error) : String(error)
47
+ }
48
+
49
+ const parseJsonInput = (input: string | undefined): unknown => {
50
+ if (input === undefined) {
51
+ return {}
52
+ }
53
+
54
+ try {
55
+ return JSON.parse(input)
56
+ } catch (error) {
57
+ throw new Error(`Invalid JSON input: ${formatError(error)}`)
58
+ }
59
+ }
60
+
61
+ interface SignalCommandOptions {
62
+ readonly runId: string
63
+ readonly signalName: string
64
+ readonly payload: unknown
65
+ readonly actor?: string
66
+ }
67
+
68
+ const parseSignalCommandOptions = (args: ReadonlyArray<string>): SignalCommandOptions => {
69
+ const [runId, signalName, ...rest] = args
70
+ if (runId === undefined || signalName === undefined) {
71
+ throw new Error("wf signal requires a run id and signal name")
72
+ }
73
+
74
+ let payloadText: string | undefined
75
+ let actor: string | undefined
76
+
77
+ for (let index = 0; index < rest.length; index++) {
78
+ const arg = rest[index]!
79
+
80
+ switch (arg) {
81
+ case "--actor":
82
+ actor = readFlagValue(rest, ++index, "--actor")
83
+ break
84
+
85
+ default:
86
+ if (arg.startsWith("--")) {
87
+ throw new Error(`Unknown wf signal option: ${arg}`)
88
+ }
89
+ if (payloadText !== undefined) {
90
+ throw new Error("wf signal accepts only one JSON payload")
91
+ }
92
+ payloadText = arg
93
+ break
94
+ }
95
+ }
96
+
97
+ return {
98
+ runId,
99
+ signalName,
100
+ payload: parseJsonInput(payloadText),
101
+ ...(actor === undefined ? {} : { actor })
102
+ }
103
+ }
104
+
105
+ interface CreateWorkflowOptions {
106
+ readonly id: string
107
+ readonly name: string
108
+ readonly nameProvided: boolean
109
+ readonly source: string
110
+ readonly version: string
111
+ readonly force: boolean
112
+ }
113
+
114
+ interface RawCreateWorkflowOptions {
115
+ readonly id: string
116
+ readonly name: string
117
+ readonly nameProvided: boolean
118
+ readonly source?: string
119
+ readonly sourceFile?: string
120
+ readonly version: string
121
+ readonly force: boolean
122
+ }
123
+
124
+ const parseCreateWorkflowOptions = (
125
+ id: string | undefined,
126
+ args: ReadonlyArray<string>
127
+ ): RawCreateWorkflowOptions => {
128
+ if (id === undefined) {
129
+ throw new Error("wf create requires a workflow id")
130
+ }
131
+
132
+ assertWorkflowId(id)
133
+
134
+ let name = `${toPascalCase(id)}Workflow`
135
+ let nameProvided = false
136
+ let source: string | undefined
137
+ let sourceFile: string | undefined
138
+ let version = "dev"
139
+ let force = false
140
+
141
+ for (let index = 0; index < args.length; index++) {
142
+ const arg = args[index]
143
+
144
+ switch (arg) {
145
+ case "--name":
146
+ name = readFlagValue(args, ++index, "--name")
147
+ assertWorkflowName(name)
148
+ nameProvided = true
149
+ break
150
+
151
+ case "--source":
152
+ source = readFlagValue(args, ++index, "--source")
153
+ break
154
+
155
+ case "--file":
156
+ sourceFile = readFlagValue(args, ++index, "--file")
157
+ break
158
+
159
+ case "--version":
160
+ version = readFlagValue(args, ++index, "--version")
161
+ break
162
+
163
+ case "--force":
164
+ force = true
165
+ break
166
+
167
+ default:
168
+ throw new Error(`Unknown wf create option: ${arg}`)
169
+ }
170
+ }
171
+
172
+ if (source !== undefined && sourceFile !== undefined) {
173
+ throw new Error("Use either --source or --file, not both")
174
+ }
175
+
176
+ return {
177
+ id,
178
+ name,
179
+ nameProvided,
180
+ ...(source === undefined ? {} : { source }),
181
+ ...(sourceFile === undefined ? {} : { sourceFile }),
182
+ version,
183
+ force
184
+ }
185
+ }
186
+
187
+ const parseCreateWorkflowOptionsWithSource = async (
188
+ id: string | undefined,
189
+ args: ReadonlyArray<string>
190
+ ): Promise<CreateWorkflowOptions> => {
191
+ const options = parseCreateWorkflowOptions(id, args)
192
+ const source = options.source ??
193
+ (options.sourceFile === undefined
194
+ ? workflowTemplate({ ...options, source: "" })
195
+ : await readFile(options.sourceFile, "utf8"))
196
+
197
+ return {
198
+ id: options.id,
199
+ name: options.name,
200
+ nameProvided: options.nameProvided,
201
+ source,
202
+ version: options.version,
203
+ force: options.force
204
+ }
205
+ }
206
+
207
+ const readFlagValue = (
208
+ args: ReadonlyArray<string>,
209
+ index: number,
210
+ flag: string
211
+ ): string => {
212
+ const value = args[index]
213
+ if (value === undefined || value.startsWith("--")) {
214
+ throw new Error(`${flag} requires a value`)
215
+ }
216
+ return value
217
+ }
218
+
219
+ const assertWorkflowId = (id: string) => {
220
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) {
221
+ throw new Error("Workflow id must start with a lowercase letter and contain only lowercase letters, numbers, and dashes")
222
+ }
223
+ }
224
+
225
+ const assertWorkflowName = (name: string) => {
226
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) {
227
+ throw new Error("Workflow name must be a valid PascalCase TypeScript identifier")
228
+ }
229
+ }
230
+
231
+ const toPascalCase = (value: string): string =>
232
+ value
233
+ .split("-")
234
+ .filter((part) => part.length > 0)
235
+ .map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`)
236
+ .join("")
237
+
238
+ const workflowTemplate = (options: CreateWorkflowOptions): string => `import { defineStep, defineWorkflow, t } from "@mokronos/wfkit"
239
+
240
+ const printMessage = defineStep({
241
+ name: "PrintMessage",
242
+ input: t.struct({ message: t.string }),
243
+ output: t.void,
244
+ execute: async (input) => {
245
+ console.log(input.message)
246
+ }
247
+ })
248
+
249
+ export const ${options.name} = defineWorkflow({
250
+ name: "${options.name}",
251
+ version: 1,
252
+ input: t.struct({
253
+ message: t.string
254
+ }),
255
+ output: t.void,
256
+ run: function* (input, ctx) {
257
+ yield* ctx.run(printMessage, {
258
+ message: input.message.trim()
259
+ })
260
+ }
261
+ })
262
+ `
263
+
264
+ const printWorkflows = (workflows: ReadonlyArray<WorkflowArtifact>) => {
265
+ if (workflows.length === 0) {
266
+ console.log("No workflows found.")
267
+ return
268
+ }
269
+
270
+ for (const workflow of workflows) {
271
+ const exported = workflow.exportName === undefined ? "" : `#${workflow.exportName}`
272
+ console.log(
273
+ `${workflow.id}\t${workflow.version}\t${workflow.name}${exported}\t${workflow.source.length} bytes`
274
+ )
275
+ }
276
+ }
277
+
278
+ const printCreatedWorkflow = (workflow: WorkflowArtifact) => {
279
+ const exported = workflow.exportName === undefined ? "" : `#${workflow.exportName}`
280
+ console.log(`Created ${workflow.id}\t${workflow.version}\t${workflow.name}${exported}\t${workflow.source.length} bytes`)
281
+ }
282
+
283
+ const printRuns = (runs: ReadonlyArray<WorkflowRunRecord>) => {
284
+ if (runs.length === 0) {
285
+ console.log("No workflow runs found.")
286
+ return
287
+ }
288
+
289
+ for (const run of runs) {
290
+ const finishedAt = run.finishedAt ?? "-"
291
+ console.log(
292
+ `${run.id}\t${run.status}\t${run.workflowId}@${run.workflowVersion}\t${run.startedAt}\t${finishedAt}`
293
+ )
294
+ }
295
+ }
296
+
297
+ const printRunEvents = (events: ReadonlyArray<WorkflowRunEventRecord>) => {
298
+ if (events.length === 0) {
299
+ console.log("No workflow run events found.")
300
+ return
301
+ }
302
+
303
+ for (const event of events) {
304
+ console.log(
305
+ `${event.sequence}\t${event.createdAt}\t${event.type}\t${stringifyEventValue(event.event)}`
306
+ )
307
+ }
308
+ }
309
+
310
+ const printRunResult = (result: unknown) => {
311
+ if (result === undefined) {
312
+ console.log("Workflow completed.")
313
+ return
314
+ }
315
+
316
+ console.log(JSON.stringify(result, null, 2))
317
+ }
318
+
319
+ const samplePayloadFor = (signal: PendingSignal): unknown =>
320
+ signal.payloadSchema === undefined ? {} : sampleValueForJsonSchema(signal.payloadSchema)
321
+
322
+ const describePendingSignal = (runId: string, signal: PendingSignal): string => {
323
+ const schemaLine = signal.payloadSchema === undefined
324
+ ? ""
325
+ : `\n expected payload schema: ${toJsonText(signal.payloadSchema)}`
326
+ return `Currently waiting for signal "${signal.name}".${schemaLine}\n deliver with: wf signal ${runId} ${signal.name} '${toJsonText(samplePayloadFor(signal))}'`
327
+ }
328
+
329
+ const printPendingSignalHint = (runId: string, pendingSignals: ReadonlyArray<PendingSignal>) => {
330
+ const names = pendingSignals
331
+ .map((signal) => signal.timeout === undefined
332
+ ? signal.name
333
+ : `${signal.name} timeout=${stringifyEventValue(signal.timeout)}`)
334
+ .join(", ")
335
+ console.error(`${eventTag("signal")} ${yellow("waiting")} for ${bold(names)}`)
336
+ const signal = pendingSignals[0]
337
+ if (signal !== undefined) {
338
+ if (signal.payloadSchema !== undefined) {
339
+ console.error(`${eventTag("signal")} ${bold(signal.name)} expects payload schema: ${dim(toJsonText(signal.payloadSchema))}`)
340
+ }
341
+ console.error(`${bold("Resume with:")} wf signal ${runId} ${signal.name} '${toJsonText(samplePayloadFor(signal))}'`)
342
+ }
343
+ }
344
+
345
+ const isPrintableWorkflowEvent = (event: WorkflowHistoryEvent): event is WorkflowEvent => {
346
+ switch (event.type) {
347
+ case "workflow.started":
348
+ case "workflow.completed":
349
+ case "workflow.failed":
350
+ case "step.started":
351
+ case "step.completed":
352
+ case "step.failed":
353
+ case "compensation.started":
354
+ case "compensation.completed":
355
+ case "compensation.failed":
356
+ case "sleep.started":
357
+ case "sleep.completed":
358
+ case "signal.waiting":
359
+ case "signal.received":
360
+ case "signal.timeout":
361
+ case "code.started":
362
+ case "code.completed":
363
+ case "code.failed":
364
+ case "all.started":
365
+ case "all.completed":
366
+ case "all.failed":
367
+ return true
368
+ default:
369
+ return false
370
+ }
371
+ }
372
+
373
+ const stringifyEventValue = (value: unknown): string => {
374
+ if (value === undefined) {
375
+ return "undefined"
376
+ }
377
+
378
+ if (typeof value === "string") {
379
+ return JSON.stringify(value)
380
+ }
381
+
382
+ try {
383
+ return toJsonText(value)
384
+ } catch {
385
+ return String(value)
386
+ }
387
+ }
388
+
389
+ // --- colored event output ---------------------------------------------------
390
+ // Category tints the [tag], the verb carries the outcome (green/red/yellow),
391
+ // detail keys are dimmed. Colors turn off for non-TTY stderr, NO_COLOR, or
392
+ // TERM=dumb, so piped output stays plain text.
393
+
394
+ const colorEnabled = process.stderr.isTTY === true &&
395
+ process.env["NO_COLOR"] === undefined &&
396
+ process.env["TERM"] !== "dumb"
397
+
398
+ const paint = (code: string) => (text: string): string =>
399
+ colorEnabled ? `\u001B[${code}m${text}\u001B[0m` : text
400
+
401
+ const dim = paint("2")
402
+ const bold = paint("1")
403
+ const red = paint("31")
404
+ const green = paint("32")
405
+ const yellow = paint("33")
406
+
407
+ type EventCategory = "run" | "workflow" | "step" | "code" | "sleep" | "signal" | "compensation" | "all"
408
+
409
+ const categoryPaint: Record<EventCategory, (text: string) => string> = {
410
+ run: paint("1;32"),
411
+ workflow: paint("1;35"),
412
+ step: paint("1;34"),
413
+ code: paint("1;36"),
414
+ sleep: paint("1;90"),
415
+ signal: paint("1;33"),
416
+ compensation: paint("1;31"),
417
+ all: paint("1;94")
418
+ }
419
+
420
+ const eventTag = (category: EventCategory): string => categoryPaint[category](`[${category}]`)
421
+
422
+ const paintVerb = (verb: string): string => {
423
+ switch (verb) {
424
+ case "completed":
425
+ case "received":
426
+ return green(verb)
427
+ case "failed":
428
+ case "timeout":
429
+ return red(verb)
430
+ case "waiting":
431
+ return yellow(verb)
432
+ default:
433
+ return verb
434
+ }
435
+ }
436
+
437
+ type EventDetail = readonly [key: string, value: string]
438
+
439
+ const printEventLine = (
440
+ category: EventCategory,
441
+ verb: string,
442
+ subject: string,
443
+ details: ReadonlyArray<EventDetail> = []
444
+ ) => {
445
+ const detailText = details.map(([key, value]) => ` ${dim(`${key}=`)}${value}`).join("")
446
+ console.error(`${eventTag(category)} ${paintVerb(verb)} ${bold(subject)}${detailText}`)
447
+ }
448
+
449
+ const errorDetail = (error: unknown): EventDetail => ["error", red(stringifyEventValue(error))]
450
+
451
+ const reasonDetails = (reason: string | undefined): ReadonlyArray<EventDetail> =>
452
+ reason === undefined ? [] : [["reason", stringifyEventValue(reason)]]
453
+
454
+ const printWorkflowEvent = (event: WorkflowEvent) => {
455
+ switch (event.type) {
456
+ case "workflow.started":
457
+ printEventLine("workflow", "started", event.workflowName, [
458
+ ["input", stringifyEventValue(event.payload)]
459
+ ])
460
+ return
461
+
462
+ case "workflow.completed":
463
+ printEventLine("workflow", "completed", event.workflowName, [
464
+ ["result", stringifyEventValue(event.result)]
465
+ ])
466
+ return
467
+
468
+ case "workflow.failed":
469
+ printEventLine("workflow", "failed", event.workflowName, [errorDetail(event.error)])
470
+ return
471
+
472
+ case "step.started":
473
+ printEventLine("step", "started", event.activityName, [["attempt", String(event.attempt)]])
474
+ return
475
+
476
+ case "step.completed":
477
+ printEventLine("step", "completed", event.activityName, [
478
+ ["attempt", String(event.attempt)],
479
+ ["result", stringifyEventValue(event.result)]
480
+ ])
481
+ return
482
+
483
+ case "step.failed":
484
+ printEventLine("step", "failed", event.activityName, [errorDetail(event.error)])
485
+ return
486
+
487
+ case "compensation.started":
488
+ printEventLine("compensation", "started", event.activityName, [
489
+ ["reason", stringifyEventValue(event.reason)]
490
+ ])
491
+ return
492
+
493
+ case "compensation.completed":
494
+ printEventLine("compensation", "completed", event.activityName)
495
+ return
496
+
497
+ case "compensation.failed":
498
+ printEventLine("compensation", "failed", event.activityName, [errorDetail(event.error)])
499
+ return
500
+
501
+ case "sleep.started":
502
+ printEventLine("sleep", "started", event.activityName, [
503
+ ["duration", stringifyEventValue(event.duration)]
504
+ ])
505
+ return
506
+
507
+ case "sleep.completed":
508
+ printEventLine("sleep", "completed", event.activityName)
509
+ return
510
+
511
+ case "signal.waiting":
512
+ printEventLine("signal", "waiting", event.activityName)
513
+ return
514
+
515
+ case "signal.received":
516
+ printEventLine("signal", "received", event.activityName, [
517
+ ["payload", stringifyEventValue(event.payload)]
518
+ ])
519
+ return
520
+
521
+ case "signal.timeout":
522
+ printEventLine("signal", "timeout", event.activityName, [
523
+ ["timeout", stringifyEventValue(event.timeout)]
524
+ ])
525
+ return
526
+
527
+ case "code.started":
528
+ printEventLine("code", "started", event.activityName, reasonDetails(event.reason))
529
+ return
530
+
531
+ case "code.completed":
532
+ printEventLine("code", "completed", event.activityName, [
533
+ ...reasonDetails(event.reason),
534
+ ["result", stringifyEventValue(event.result)]
535
+ ])
536
+ return
537
+
538
+ case "code.failed":
539
+ printEventLine("code", "failed", event.activityName, [
540
+ ...reasonDetails(event.reason),
541
+ errorDetail(event.error)
542
+ ])
543
+ return
544
+
545
+ case "all.started":
546
+ printEventLine("all", "started", event.activityName, [["branches", String(event.branches)]])
547
+ return
548
+
549
+ case "all.completed":
550
+ printEventLine("all", "completed", event.activityName, [["branches", String(event.branches)]])
551
+ return
552
+
553
+ case "all.failed":
554
+ printEventLine("all", "failed", event.activityName, [
555
+ ["branches", String(event.branches)],
556
+ errorDetail(event.error)
557
+ ])
558
+ return
559
+ }
560
+ }
561
+
562
+ type AwaitWorkflowOutcome =
563
+ | { readonly type: "terminal"; readonly result: Awaited<ReturnType<WorkflowClient["result"]>> }
564
+ | { readonly type: "signal-suspended"; readonly pendingSignals: ReadonlyArray<PendingSignal> }
565
+
566
+ const sleep = (milliseconds: number) =>
567
+ new Promise((resolve) => setTimeout(resolve, milliseconds))
568
+
569
+ const waitForSignalSuspension = async (
570
+ client: WorkflowClient,
571
+ executionId: string
572
+ ): Promise<ReadonlyArray<PendingSignal>> => {
573
+ while (true) {
574
+ const status = await client.status(executionId)
575
+ if (status === "suspended") {
576
+ const pendingSignals = await client.pendingSignals(executionId)
577
+ if (pendingSignals.length > 0) {
578
+ return pendingSignals
579
+ }
580
+ }
581
+ await sleep(100)
582
+ }
583
+ }
584
+
585
+ const awaitWorkflowOutcome = async (
586
+ client: WorkflowClient,
587
+ executionId: string
588
+ ): Promise<AwaitWorkflowOutcome> =>
589
+ Promise.race([
590
+ client.result(executionId).then((result): AwaitWorkflowOutcome => ({ type: "terminal", result })),
591
+ waitForSignalSuspension(client, executionId).then((pendingSignals): AwaitWorkflowOutcome => ({
592
+ type: "signal-suspended",
593
+ pendingSignals
594
+ }))
595
+ ])
596
+
597
+ const syncRunEvents = async (
598
+ repository: WorkflowRepository,
599
+ client: WorkflowClient,
600
+ runId: string
601
+ ) => {
602
+ const existingCount = (await repository.listRunEvents(runId)).length
603
+ const records = (await client.history(runId)).filter((record) => record.sequence > existingCount)
604
+
605
+ for (const record of records) {
606
+ await repository.appendRunEvent({
607
+ runId,
608
+ type: record.event.type,
609
+ event: record.event
610
+ })
611
+ if (isPrintableWorkflowEvent(record.event)) {
612
+ printWorkflowEvent(record.event)
613
+ }
614
+ }
615
+ }
616
+
617
+ const awaitSyncAndPersistRun = async (options: {
618
+ readonly repository: WorkflowRepository
619
+ readonly client: WorkflowClient
620
+ readonly runId: string
621
+ }) => {
622
+ const outcome = await awaitWorkflowOutcome(options.client, options.runId)
623
+ await syncRunEvents(options.repository, options.client, options.runId)
624
+
625
+ if (outcome.type === "signal-suspended") {
626
+ printPendingSignalHint(options.runId, outcome.pendingSignals)
627
+ return
628
+ }
629
+
630
+ if (outcome.result.type === "completed") {
631
+ await options.repository.completeRun({ runId: options.runId, result: outcome.result.value })
632
+ printRunResult(outcome.result.value)
633
+ return
634
+ }
635
+
636
+ await options.repository.failRun({ runId: options.runId, error: outcome.result.error })
637
+ throw outcome.result.error
638
+ }
639
+
640
+ const engineDatabasePath = (rootDir: string) => path.join(rootDir, ".wf", "engine.sqlite")
641
+
642
+ const createEngineBackedClient = (rootDir: string) => {
643
+ const runtime = createWorkflowRuntime({
644
+ backend: "sqlite",
645
+ databasePath: engineDatabasePath(rootDir)
646
+ })
647
+ const client = createWorkflowClient(runtime)
648
+ return { runtime, client }
649
+ }
650
+
651
+ const main = async () => {
652
+ const [command, ...args] = process.argv.slice(2)
653
+ const rootDir = process.cwd()
654
+ const repository = createSqliteWorkflowRepository({ rootDir })
655
+
656
+ switch (command) {
657
+ case "create": {
658
+ const [id, ...createArgs] = args
659
+ const options = await parseCreateWorkflowOptionsWithSource(id, createArgs)
660
+ const existingWorkflow = await repository.get(options.id)
661
+
662
+ if (existingWorkflow !== undefined && !options.force) {
663
+ throw new Error(`Workflow id already exists: ${options.id}. Use --force to update it.`)
664
+ }
665
+
666
+ // Resolve the actual workflow export from the source instead of
667
+ // assuming the id-derived name — imported files usually export a
668
+ // differently named workflow. --name still pins the export explicitly
669
+ // (needed when a file exports several workflows). This also validates
670
+ // the source at create time instead of on first run.
671
+ const loaded = await loadWorkflowArtifact({
672
+ id: options.id,
673
+ name: options.name,
674
+ version: options.version,
675
+ source: options.source,
676
+ ...(options.nameProvided ? { exportName: options.name } : {})
677
+ })
678
+
679
+ const workflow: WorkflowArtifact = {
680
+ id: options.id,
681
+ name: options.nameProvided ? options.name : loaded.workflow.name,
682
+ version: options.version,
683
+ source: options.source,
684
+ exportName: loaded.exportName,
685
+ createdAt: new Date().toISOString()
686
+ }
687
+ if (existingWorkflow !== undefined) {
688
+ // The catalog is keyed by (name, version); replacing an id whose
689
+ // detected name changed must not leave the old row behind.
690
+ await repository.deleteWorkflow(options.id)
691
+ }
692
+ await repository.upsertWorkflow(workflow)
693
+ printCreatedWorkflow(workflow)
694
+ return
695
+ }
696
+
697
+ case "list": {
698
+ printWorkflows(await repository.list())
699
+ return
700
+ }
701
+
702
+ case "runs": {
703
+ printRuns(await repository.listRuns())
704
+ return
705
+ }
706
+
707
+ case "history":
708
+ case "events": {
709
+ const [runId] = args
710
+ if (runId === undefined) {
711
+ throw new Error("wf history requires an execution id")
712
+ }
713
+
714
+ printRunEvents(await repository.listRunEvents(runId))
715
+ return
716
+ }
717
+
718
+ case "run": {
719
+ const [id, rawInput] = args
720
+ if (id === undefined) {
721
+ throw new Error("wf run requires a workflow id")
722
+ }
723
+
724
+ const artifact = await repository.get(id)
725
+ if (artifact === undefined) {
726
+ throw new Error(`Unknown workflow id: ${id}`)
727
+ }
728
+ const loaded = await loadWorkflowArtifact(artifact)
729
+ const input = parseJsonInput(rawInput)
730
+ const { client } = createEngineBackedClient(rootDir)
731
+ const handle = await client.start(loaded.workflow, input)
732
+ console.error(`${eventTag("run")} id ${bold(handle.executionId)}`)
733
+ await repository.startRun({ id: handle.executionId, workflow: artifact, input })
734
+
735
+ await awaitSyncAndPersistRun({ repository, client, runId: handle.executionId })
736
+ return
737
+ }
738
+
739
+ case "signal": {
740
+ const options = parseSignalCommandOptions(args)
741
+ const run = await repository.getRun(options.runId)
742
+ if (run === undefined) {
743
+ throw new Error(`Unknown workflow run: ${options.runId}`)
744
+ }
745
+
746
+ const artifact = await repository.get(run.workflowId)
747
+ if (artifact === undefined) {
748
+ throw new Error(`Workflow artifact was deleted for run ${options.runId}: ${run.workflowId}`)
749
+ }
750
+
751
+ const loaded = await loadWorkflowArtifact(artifact)
752
+ const { runtime, client } = createEngineBackedClient(rootDir)
753
+ runtime.register([loaded.workflow])
754
+
755
+ try {
756
+ await client.signal(
757
+ options.runId,
758
+ options.signalName,
759
+ options.payload,
760
+ options.actor === undefined ? {} : { actor: options.actor }
761
+ )
762
+ } catch (error) {
763
+ const pendingSignals = await client.pendingSignals(options.runId).catch(() => [])
764
+ if (pendingSignals.length === 0) {
765
+ throw error
766
+ }
767
+ const lines = pendingSignals.map((signal) => describePendingSignal(options.runId, signal))
768
+ throw new Error(`${formatError(error)}\n${lines.join("\n")}`)
769
+ }
770
+
771
+ await awaitSyncAndPersistRun({ repository, client, runId: options.runId })
772
+ return
773
+ }
774
+
775
+ case "-h":
776
+ case "--help":
777
+ case "help":
778
+ case undefined:
779
+ console.log(help)
780
+ return
781
+
782
+ default:
783
+ throw new Error(`Unknown command: ${command}\n\n${help}`)
784
+ }
785
+ }
786
+
787
+ main().then(
788
+ () => process.exit(0),
789
+ (error) => {
790
+ console.error(formatError(error))
791
+ process.exit(1)
792
+ }
793
+ )