ai-workflows 2.1.3 → 2.3.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +8 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +3 -3
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +132 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -169
- package/LICENSE +0 -21
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowStateAdapter - Persistent workflow state using ai-database
|
|
3
|
+
*
|
|
4
|
+
* Provides durable storage for workflow state with:
|
|
5
|
+
* - Optimistic locking via version control
|
|
6
|
+
* - Step checkpoints for recovery
|
|
7
|
+
* - Query by status and IDs
|
|
8
|
+
* - Snapshots for point-in-time recovery
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { DB } from 'ai-database'
|
|
13
|
+
* import { WorkflowStateAdapter } from 'ai-workflows/worker'
|
|
14
|
+
*
|
|
15
|
+
* const { db } = DB({ WorkflowState: { status: 'string' } })
|
|
16
|
+
* const adapter = new WorkflowStateAdapter(db)
|
|
17
|
+
*
|
|
18
|
+
* await adapter.save('wf-123', {
|
|
19
|
+
* workflowId: 'wf-123',
|
|
20
|
+
* status: 'running',
|
|
21
|
+
* currentStep: 'process-payment',
|
|
22
|
+
* context: { orderId: 'order-1' },
|
|
23
|
+
* })
|
|
24
|
+
*
|
|
25
|
+
* const state = await adapter.load('wf-123')
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Types for persisted workflow state
|
|
33
|
+
*/
|
|
34
|
+
export interface PersistedWorkflowState {
|
|
35
|
+
workflowId: string
|
|
36
|
+
version: number
|
|
37
|
+
status: 'pending' | 'running' | 'completed' | 'failed' | 'paused'
|
|
38
|
+
currentStep: string
|
|
39
|
+
context: Record<string, unknown>
|
|
40
|
+
checkpoints: Map<string, StepCheckpoint>
|
|
41
|
+
history: WorkflowHistoryEntry[]
|
|
42
|
+
input?: unknown
|
|
43
|
+
output?: unknown
|
|
44
|
+
error?: string
|
|
45
|
+
createdAt: Date
|
|
46
|
+
updatedAt: Date
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface StepCheckpoint {
|
|
50
|
+
stepId: string
|
|
51
|
+
status: 'pending' | 'running' | 'completed' | 'failed'
|
|
52
|
+
result?: unknown
|
|
53
|
+
error?: string
|
|
54
|
+
attempt: number
|
|
55
|
+
startedAt?: Date
|
|
56
|
+
completedAt?: Date
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WorkflowHistoryEntry {
|
|
60
|
+
timestamp: number
|
|
61
|
+
type: 'event' | 'schedule' | 'transition' | 'action' | 'checkpoint'
|
|
62
|
+
name: string
|
|
63
|
+
data?: unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Snapshot metadata
|
|
68
|
+
*/
|
|
69
|
+
export interface SnapshotInfo {
|
|
70
|
+
id: string
|
|
71
|
+
label?: string
|
|
72
|
+
createdAt: Date
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Database connection interface
|
|
77
|
+
* Compatible with ai-database DB() result or any similar provider
|
|
78
|
+
*/
|
|
79
|
+
export interface DatabaseConnection {
|
|
80
|
+
get: (type: string, id: string) => Promise<Record<string, unknown> | null>
|
|
81
|
+
create: (
|
|
82
|
+
type: string,
|
|
83
|
+
data: Record<string, unknown>,
|
|
84
|
+
id?: string
|
|
85
|
+
) => Promise<Record<string, unknown>>
|
|
86
|
+
update: (
|
|
87
|
+
type: string,
|
|
88
|
+
id: string,
|
|
89
|
+
data: Record<string, unknown>
|
|
90
|
+
) => Promise<Record<string, unknown>>
|
|
91
|
+
delete: (type: string, id: string) => Promise<boolean>
|
|
92
|
+
list: (
|
|
93
|
+
type: string,
|
|
94
|
+
options?: { limit?: number; offset?: number; where?: Record<string, unknown> }
|
|
95
|
+
) => Promise<Record<string, unknown>[]>
|
|
96
|
+
search?: (
|
|
97
|
+
type: string,
|
|
98
|
+
query: string,
|
|
99
|
+
options?: Record<string, unknown>
|
|
100
|
+
) => Promise<Record<string, unknown>[]>
|
|
101
|
+
emit?: (event: string, data: unknown) => Promise<{ id: string }>
|
|
102
|
+
clear?: () => void
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Serialized state format for storage
|
|
107
|
+
* Includes index signature for compatibility with Record<string, unknown>
|
|
108
|
+
*/
|
|
109
|
+
interface SerializedState {
|
|
110
|
+
[key: string]: unknown
|
|
111
|
+
$id?: string
|
|
112
|
+
$type?: string
|
|
113
|
+
workflowId: string
|
|
114
|
+
version: number
|
|
115
|
+
status: string
|
|
116
|
+
currentStep: string
|
|
117
|
+
context: string // JSON serialized
|
|
118
|
+
checkpoints: string // JSON serialized Map entries
|
|
119
|
+
history: string // JSON serialized
|
|
120
|
+
input?: string // JSON serialized
|
|
121
|
+
output?: string // JSON serialized
|
|
122
|
+
error?: string
|
|
123
|
+
createdAt: string // ISO string
|
|
124
|
+
updatedAt: string // ISO string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Serialized snapshot format
|
|
129
|
+
* Includes index signature for compatibility with Record<string, unknown>
|
|
130
|
+
*/
|
|
131
|
+
interface SerializedSnapshot {
|
|
132
|
+
[key: string]: unknown
|
|
133
|
+
$id?: string
|
|
134
|
+
$type?: string
|
|
135
|
+
snapshotId: string
|
|
136
|
+
workflowId: string
|
|
137
|
+
label?: string
|
|
138
|
+
state: string // JSON serialized full state
|
|
139
|
+
createdAt: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* WorkflowStateAdapter - Persists workflow state using ai-database
|
|
144
|
+
*
|
|
145
|
+
* Provides optimistic locking, checkpoints, and snapshot recovery.
|
|
146
|
+
*/
|
|
147
|
+
export class WorkflowStateAdapter {
|
|
148
|
+
private db: DatabaseConnection
|
|
149
|
+
private readonly STATE_TYPE = 'WorkflowState'
|
|
150
|
+
private readonly CHECKPOINT_TYPE = 'WorkflowCheckpoint'
|
|
151
|
+
private readonly SNAPSHOT_TYPE = 'WorkflowSnapshot'
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Create a new WorkflowStateAdapter
|
|
155
|
+
*
|
|
156
|
+
* @param database - Database connection (from ai-database DB() or compatible provider)
|
|
157
|
+
* @throws Error if database connection is null/undefined
|
|
158
|
+
*/
|
|
159
|
+
constructor(database: DatabaseConnection) {
|
|
160
|
+
if (!database) {
|
|
161
|
+
throw new Error('Database connection is required')
|
|
162
|
+
}
|
|
163
|
+
this.db = database
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Save workflow state to database
|
|
168
|
+
*
|
|
169
|
+
* Creates a new record if not exists, updates existing if found.
|
|
170
|
+
* Automatically increments version and updates timestamps.
|
|
171
|
+
*
|
|
172
|
+
* @param workflowId - Unique workflow identifier
|
|
173
|
+
* @param state - Partial state to save (merged with existing)
|
|
174
|
+
*/
|
|
175
|
+
async save(workflowId: string, state: Partial<PersistedWorkflowState>): Promise<void> {
|
|
176
|
+
const existing = await this.loadRaw(workflowId)
|
|
177
|
+
const now = new Date()
|
|
178
|
+
|
|
179
|
+
if (existing) {
|
|
180
|
+
// Update existing state
|
|
181
|
+
const updated = this.mergeState(existing, state)
|
|
182
|
+
updated.version = (existing.version || 0) + 1
|
|
183
|
+
updated.updatedAt = now
|
|
184
|
+
|
|
185
|
+
const serialized = this.serialize(updated)
|
|
186
|
+
await this.db.update(this.STATE_TYPE, workflowId, serialized)
|
|
187
|
+
|
|
188
|
+
// Emit state updated event
|
|
189
|
+
await this.emitEvent('WorkflowState.updated', {
|
|
190
|
+
workflowId,
|
|
191
|
+
version: updated.version,
|
|
192
|
+
status: updated.status,
|
|
193
|
+
})
|
|
194
|
+
} else {
|
|
195
|
+
// Create new state
|
|
196
|
+
const newState: PersistedWorkflowState = {
|
|
197
|
+
workflowId,
|
|
198
|
+
version: state.version ?? 1,
|
|
199
|
+
status: state.status ?? 'pending',
|
|
200
|
+
currentStep: state.currentStep ?? '',
|
|
201
|
+
context: state.context ?? {},
|
|
202
|
+
checkpoints: state.checkpoints ?? new Map(),
|
|
203
|
+
history: state.history ?? [],
|
|
204
|
+
...(state.input !== undefined && { input: state.input }),
|
|
205
|
+
...(state.output !== undefined && { output: state.output }),
|
|
206
|
+
...(state.error !== undefined && { error: state.error }),
|
|
207
|
+
createdAt: now,
|
|
208
|
+
updatedAt: now,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const serialized = this.serialize(newState)
|
|
212
|
+
await this.db.create(this.STATE_TYPE, serialized, workflowId)
|
|
213
|
+
|
|
214
|
+
// Emit state created event
|
|
215
|
+
await this.emitEvent('WorkflowState.created', {
|
|
216
|
+
workflowId,
|
|
217
|
+
version: newState.version,
|
|
218
|
+
status: newState.status,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Load workflow state from database
|
|
225
|
+
*
|
|
226
|
+
* @param workflowId - Workflow identifier to load
|
|
227
|
+
* @returns The persisted state or null if not found
|
|
228
|
+
*/
|
|
229
|
+
async load(workflowId: string): Promise<PersistedWorkflowState | null> {
|
|
230
|
+
return this.loadRaw(workflowId)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Save a step checkpoint
|
|
235
|
+
*
|
|
236
|
+
* Checkpoints are stored as part of the workflow state.
|
|
237
|
+
*
|
|
238
|
+
* @param workflowId - Workflow identifier
|
|
239
|
+
* @param stepId - Step identifier
|
|
240
|
+
* @param checkpoint - Checkpoint data
|
|
241
|
+
*/
|
|
242
|
+
async checkpoint(workflowId: string, stepId: string, checkpoint: StepCheckpoint): Promise<void> {
|
|
243
|
+
const state = await this.loadRaw(workflowId)
|
|
244
|
+
|
|
245
|
+
if (!state) {
|
|
246
|
+
// Create minimal state with checkpoint
|
|
247
|
+
await this.save(workflowId, {
|
|
248
|
+
workflowId,
|
|
249
|
+
status: 'running',
|
|
250
|
+
checkpoints: new Map([[stepId, checkpoint]]),
|
|
251
|
+
})
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Update checkpoint in existing state
|
|
256
|
+
const checkpoints = state.checkpoints ?? new Map()
|
|
257
|
+
checkpoints.set(stepId, checkpoint)
|
|
258
|
+
|
|
259
|
+
await this.save(workflowId, { checkpoints })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get a step checkpoint
|
|
264
|
+
*
|
|
265
|
+
* @param workflowId - Workflow identifier
|
|
266
|
+
* @param stepId - Step identifier
|
|
267
|
+
* @returns The checkpoint or null if not found
|
|
268
|
+
*/
|
|
269
|
+
async getCheckpoint(workflowId: string, stepId: string): Promise<StepCheckpoint | null> {
|
|
270
|
+
const state = await this.loadRaw(workflowId)
|
|
271
|
+
if (!state || !state.checkpoints) {
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
return state.checkpoints.get(stepId) ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Update state with optimistic locking
|
|
279
|
+
*
|
|
280
|
+
* Only updates if the current version matches expectedVersion.
|
|
281
|
+
* Returns false if version mismatch (concurrent modification detected).
|
|
282
|
+
*
|
|
283
|
+
* @param workflowId - Workflow identifier
|
|
284
|
+
* @param expectedVersion - Expected current version
|
|
285
|
+
* @param state - State updates to apply
|
|
286
|
+
* @returns true if updated, false if version mismatch
|
|
287
|
+
*/
|
|
288
|
+
async updateWithVersion(
|
|
289
|
+
workflowId: string,
|
|
290
|
+
expectedVersion: number,
|
|
291
|
+
state: Partial<PersistedWorkflowState>
|
|
292
|
+
): Promise<boolean> {
|
|
293
|
+
const existing = await this.loadRaw(workflowId)
|
|
294
|
+
|
|
295
|
+
if (!existing) {
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (existing.version !== expectedVersion) {
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Version matches, perform update
|
|
304
|
+
await this.save(workflowId, state)
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Query workflows by status
|
|
310
|
+
*
|
|
311
|
+
* @param status - Status to filter by
|
|
312
|
+
* @returns Array of workflows matching the status
|
|
313
|
+
*/
|
|
314
|
+
async queryByStatus(status: PersistedWorkflowState['status']): Promise<PersistedWorkflowState[]> {
|
|
315
|
+
try {
|
|
316
|
+
const results = await this.db.list(this.STATE_TYPE, {
|
|
317
|
+
where: { status },
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
return results.map((r) => this.deserialize(r as unknown as SerializedState))
|
|
321
|
+
} catch {
|
|
322
|
+
// Fallback: list all and filter
|
|
323
|
+
const all = await this.db.list(this.STATE_TYPE)
|
|
324
|
+
return all
|
|
325
|
+
.map((r) => this.deserialize(r as unknown as SerializedState))
|
|
326
|
+
.filter((s) => s.status === status)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Query workflows by IDs (batch query)
|
|
332
|
+
*
|
|
333
|
+
* @param workflowIds - Array of workflow IDs to query
|
|
334
|
+
* @returns Array of found workflows (non-existent IDs are excluded)
|
|
335
|
+
*/
|
|
336
|
+
async queryByIds(workflowIds: string[]): Promise<PersistedWorkflowState[]> {
|
|
337
|
+
const results: PersistedWorkflowState[] = []
|
|
338
|
+
|
|
339
|
+
for (const id of workflowIds) {
|
|
340
|
+
const state = await this.loadRaw(id)
|
|
341
|
+
if (state) {
|
|
342
|
+
results.push(state)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return results
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Delete a workflow and its checkpoints
|
|
351
|
+
*
|
|
352
|
+
* @param workflowId - Workflow identifier to delete
|
|
353
|
+
* @returns true if deleted, false if not found
|
|
354
|
+
*/
|
|
355
|
+
async delete(workflowId: string): Promise<boolean> {
|
|
356
|
+
const existing = await this.loadRaw(workflowId)
|
|
357
|
+
|
|
358
|
+
if (!existing) {
|
|
359
|
+
return false
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
await this.db.delete(this.STATE_TYPE, workflowId)
|
|
363
|
+
|
|
364
|
+
// Emit deletion event
|
|
365
|
+
await this.emitEvent('WorkflowState.deleted', { workflowId })
|
|
366
|
+
|
|
367
|
+
return true
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* List all workflows with pagination
|
|
372
|
+
*
|
|
373
|
+
* @param options - Pagination options (limit, offset)
|
|
374
|
+
* @returns Array of workflows
|
|
375
|
+
*/
|
|
376
|
+
async listAll(options?: { limit?: number; offset?: number }): Promise<PersistedWorkflowState[]> {
|
|
377
|
+
const results = await this.db.list(this.STATE_TYPE, {
|
|
378
|
+
...(options?.limit !== undefined && { limit: options.limit }),
|
|
379
|
+
...(options?.offset !== undefined && { offset: options.offset }),
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
return results.map((r) => this.deserialize(r as unknown as SerializedState))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Create a snapshot of current workflow state
|
|
387
|
+
*
|
|
388
|
+
* Snapshots allow point-in-time recovery of workflow state.
|
|
389
|
+
*
|
|
390
|
+
* @param workflowId - Workflow identifier
|
|
391
|
+
* @param label - Optional human-readable label
|
|
392
|
+
* @returns Snapshot ID
|
|
393
|
+
*/
|
|
394
|
+
async createSnapshot(workflowId: string, label?: string): Promise<string> {
|
|
395
|
+
const state = await this.loadRaw(workflowId)
|
|
396
|
+
|
|
397
|
+
if (!state) {
|
|
398
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const snapshotId = `snap-${workflowId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
402
|
+
|
|
403
|
+
const snapshot: SerializedSnapshot = {
|
|
404
|
+
snapshotId,
|
|
405
|
+
workflowId,
|
|
406
|
+
...(label !== undefined && { label }),
|
|
407
|
+
state: JSON.stringify(this.serialize(state)),
|
|
408
|
+
createdAt: new Date().toISOString(),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
await this.db.create(
|
|
412
|
+
this.SNAPSHOT_TYPE,
|
|
413
|
+
snapshot as unknown as Record<string, unknown>,
|
|
414
|
+
snapshotId
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return snapshotId
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Restore workflow state from a snapshot
|
|
422
|
+
*
|
|
423
|
+
* @param workflowId - Workflow identifier
|
|
424
|
+
* @param snapshotId - Snapshot ID to restore from
|
|
425
|
+
*/
|
|
426
|
+
async restoreSnapshot(workflowId: string, snapshotId: string): Promise<void> {
|
|
427
|
+
const snapshot = (await this.db.get(
|
|
428
|
+
this.SNAPSHOT_TYPE,
|
|
429
|
+
snapshotId
|
|
430
|
+
)) as unknown as SerializedSnapshot | null
|
|
431
|
+
|
|
432
|
+
if (!snapshot) {
|
|
433
|
+
throw new Error(`Snapshot "${snapshotId}" not found`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (snapshot.workflowId !== workflowId) {
|
|
437
|
+
throw new Error(`Snapshot "${snapshotId}" does not belong to workflow "${workflowId}"`)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const restoredState = JSON.parse(snapshot.state) as SerializedState
|
|
441
|
+
const state = this.deserialize(restoredState)
|
|
442
|
+
|
|
443
|
+
// Save restored state with incremented version
|
|
444
|
+
await this.db.update(
|
|
445
|
+
this.STATE_TYPE,
|
|
446
|
+
workflowId,
|
|
447
|
+
this.serialize({
|
|
448
|
+
...state,
|
|
449
|
+
version: state.version + 1,
|
|
450
|
+
updatedAt: new Date(),
|
|
451
|
+
})
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
// Emit restoration event
|
|
455
|
+
await this.emitEvent('WorkflowState.restored', {
|
|
456
|
+
workflowId,
|
|
457
|
+
snapshotId,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get all snapshots for a workflow
|
|
463
|
+
*
|
|
464
|
+
* @param workflowId - Workflow identifier
|
|
465
|
+
* @returns Array of snapshot metadata
|
|
466
|
+
*/
|
|
467
|
+
async getSnapshots(workflowId: string): Promise<SnapshotInfo[]> {
|
|
468
|
+
const all = await this.db.list(this.SNAPSHOT_TYPE)
|
|
469
|
+
|
|
470
|
+
return all
|
|
471
|
+
.map((r) => r as unknown as SerializedSnapshot)
|
|
472
|
+
.filter((s) => s.workflowId === workflowId)
|
|
473
|
+
.map((s) => ({
|
|
474
|
+
id: s.snapshotId,
|
|
475
|
+
...(s.label !== undefined && { label: s.label }),
|
|
476
|
+
createdAt: new Date(s.createdAt),
|
|
477
|
+
}))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ==========================================================================
|
|
481
|
+
// Private helpers
|
|
482
|
+
// ==========================================================================
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Load raw state from database
|
|
486
|
+
*/
|
|
487
|
+
private async loadRaw(workflowId: string): Promise<PersistedWorkflowState | null> {
|
|
488
|
+
const data = await this.db.get(this.STATE_TYPE, workflowId)
|
|
489
|
+
|
|
490
|
+
if (!data) {
|
|
491
|
+
return null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return this.deserialize(data as unknown as SerializedState)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Merge partial state into existing state
|
|
499
|
+
*/
|
|
500
|
+
private mergeState(
|
|
501
|
+
existing: PersistedWorkflowState,
|
|
502
|
+
updates: Partial<PersistedWorkflowState>
|
|
503
|
+
): PersistedWorkflowState {
|
|
504
|
+
return {
|
|
505
|
+
...existing,
|
|
506
|
+
...updates,
|
|
507
|
+
// Deep merge context
|
|
508
|
+
context: {
|
|
509
|
+
...existing.context,
|
|
510
|
+
...(updates.context ?? {}),
|
|
511
|
+
},
|
|
512
|
+
// Merge checkpoints (updates override)
|
|
513
|
+
checkpoints: updates.checkpoints ?? existing.checkpoints,
|
|
514
|
+
// Append history if provided
|
|
515
|
+
history: updates.history ?? existing.history,
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Serialize state for database storage
|
|
521
|
+
*/
|
|
522
|
+
private serialize(state: PersistedWorkflowState): SerializedState {
|
|
523
|
+
return {
|
|
524
|
+
workflowId: state.workflowId,
|
|
525
|
+
version: state.version,
|
|
526
|
+
status: state.status,
|
|
527
|
+
currentStep: state.currentStep,
|
|
528
|
+
context: JSON.stringify(state.context),
|
|
529
|
+
checkpoints: JSON.stringify(state.checkpoints ? Array.from(state.checkpoints.entries()) : []),
|
|
530
|
+
history: JSON.stringify(state.history),
|
|
531
|
+
...(state.input !== undefined && { input: JSON.stringify(state.input) }),
|
|
532
|
+
...(state.output !== undefined && { output: JSON.stringify(state.output) }),
|
|
533
|
+
...(state.error !== undefined && { error: state.error }),
|
|
534
|
+
createdAt: state.createdAt.toISOString(),
|
|
535
|
+
updatedAt: state.updatedAt.toISOString(),
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Deserialize state from database format
|
|
541
|
+
*/
|
|
542
|
+
private deserialize(data: SerializedState): PersistedWorkflowState {
|
|
543
|
+
let checkpointsMap: Map<string, StepCheckpoint>
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const entries = JSON.parse(data.checkpoints || '[]') as Array<[string, StepCheckpoint]>
|
|
547
|
+
checkpointsMap = new Map(
|
|
548
|
+
entries.map(([key, cp]) => [
|
|
549
|
+
key,
|
|
550
|
+
{
|
|
551
|
+
...cp,
|
|
552
|
+
...(cp.startedAt !== undefined && { startedAt: new Date(cp.startedAt) }),
|
|
553
|
+
...(cp.completedAt !== undefined && { completedAt: new Date(cp.completedAt) }),
|
|
554
|
+
},
|
|
555
|
+
])
|
|
556
|
+
)
|
|
557
|
+
} catch {
|
|
558
|
+
checkpointsMap = new Map()
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
workflowId: data.workflowId,
|
|
563
|
+
version: data.version,
|
|
564
|
+
status: data.status as PersistedWorkflowState['status'],
|
|
565
|
+
currentStep: data.currentStep,
|
|
566
|
+
context: JSON.parse(data.context || '{}'),
|
|
567
|
+
checkpoints: checkpointsMap,
|
|
568
|
+
history: JSON.parse(data.history || '[]'),
|
|
569
|
+
...(data.input !== undefined && { input: JSON.parse(data.input) }),
|
|
570
|
+
...(data.output !== undefined && { output: JSON.parse(data.output) }),
|
|
571
|
+
...(data.error !== undefined && { error: data.error }),
|
|
572
|
+
createdAt: new Date(data.createdAt),
|
|
573
|
+
updatedAt: new Date(data.updatedAt),
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Emit an event to the database (if supported)
|
|
579
|
+
*/
|
|
580
|
+
private async emitEvent(event: string, data: unknown): Promise<void> {
|
|
581
|
+
if (this.db.emit) {
|
|
582
|
+
try {
|
|
583
|
+
await this.db.emit(event, data)
|
|
584
|
+
} catch {
|
|
585
|
+
// Event emission is best-effort
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|