@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.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/authoring.d.ts +7 -0
- package/dist/authoring.d.ts.map +1 -0
- package/dist/authoring.js +8 -0
- package/dist/authoring.js.map +1 -0
- package/dist/cli/integrations.d.ts +3 -0
- package/dist/cli/integrations.d.ts.map +1 -0
- package/dist/cli/integrations.js +40 -0
- package/dist/cli/integrations.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +591 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/core.d.ts +198 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +1061 -0
- package/dist/core.js.map +1 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +154 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +24 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/replay.d.ts +3 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +28 -0
- package/dist/replay.js.map +1 -0
- package/dist/runtime.d.ts +70 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +183 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +18 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +19 -0
- package/dist/schema.js.map +1 -0
- package/dist/schemas.d.ts +1198 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +383 -0
- package/dist/schemas.js.map +1 -0
- package/dist/sdk/artifact.d.ts +41 -0
- package/dist/sdk/artifact.d.ts.map +1 -0
- package/dist/sdk/artifact.js +94 -0
- package/dist/sdk/artifact.js.map +1 -0
- package/dist/sdk/graph.d.ts +15 -0
- package/dist/sdk/graph.d.ts.map +1 -0
- package/dist/sdk/graph.js +388 -0
- package/dist/sdk/graph.js.map +1 -0
- package/dist/sdk/index.d.ts +14 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +8 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/integrations.d.ts +18 -0
- package/dist/sdk/integrations.d.ts.map +1 -0
- package/dist/sdk/integrations.js +20 -0
- package/dist/sdk/integrations.js.map +1 -0
- package/dist/sdk/json.d.ts +4 -0
- package/dist/sdk/json.d.ts.map +1 -0
- package/dist/sdk/json.js +31 -0
- package/dist/sdk/json.js.map +1 -0
- package/dist/sdk/loader.d.ts +10 -0
- package/dist/sdk/loader.d.ts.map +1 -0
- package/dist/sdk/loader.js +75 -0
- package/dist/sdk/loader.js.map +1 -0
- package/dist/sdk/sdk.d.ts +102 -0
- package/dist/sdk/sdk.d.ts.map +1 -0
- package/dist/sdk/sdk.js +669 -0
- package/dist/sdk/sdk.js.map +1 -0
- package/dist/sdk/sqlite.d.ts +8 -0
- package/dist/sdk/sqlite.d.ts.map +1 -0
- package/dist/sdk/sqlite.js +288 -0
- package/dist/sdk/sqlite.js.map +1 -0
- package/dist/signal.d.ts +17 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +78 -0
- package/dist/signal.js.map +1 -0
- package/dist/testing/index.d.ts +35 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +244 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +67 -0
- package/src/authoring.ts +32 -0
- package/src/cli/integrations.ts +75 -0
- package/src/cli/main.ts +793 -0
- package/src/core.ts +1515 -0
- package/src/errors.ts +19 -0
- package/src/events.ts +37 -0
- package/src/index.ts +109 -0
- package/src/replay.ts +29 -0
- package/src/runtime.ts +314 -0
- package/src/schema.ts +34 -0
- package/src/schemas.ts +473 -0
- package/src/sdk/artifact.ts +176 -0
- package/src/sdk/graph.ts +486 -0
- package/src/sdk/index.ts +64 -0
- package/src/sdk/integrations.ts +49 -0
- package/src/sdk/json.ts +40 -0
- package/src/sdk/loader.ts +99 -0
- package/src/sdk/sdk.ts +911 -0
- package/src/sdk/sqlite.ts +405 -0
- package/src/signal.ts +116 -0
- package/src/testing/index.ts +341 -0
package/src/cli/main.ts
ADDED
|
@@ -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
|
+
)
|