@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/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
|
+
}
|