lopata 0.7.0 → 0.8.2
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/dist/dashboard/{chunk-pqnphvm2.css → chunk-csyd2tq2.css} +27 -0
- package/dist/dashboard/{chunk-5nxa3jfc.js → chunk-yxzrcvyh.js} +364 -3
- package/dist/dashboard/index.html +1 -1
- package/package.json +5 -3
- package/src/api/handlers/generations.ts +19 -5
- package/src/api/types.ts +14 -0
- package/src/bindings/cache.ts +14 -8
- package/src/bindings/durable-object.ts +80 -21
- package/src/bindings/kv.ts +12 -8
- package/src/bindings/queue.ts +22 -12
- package/src/bindings/workflow.ts +332 -25
- package/src/env.ts +3 -2
- package/src/file-watcher.ts +59 -32
- package/src/generation-manager.ts +6 -1
- package/src/generation.ts +15 -3
- package/src/plugin.ts +2 -90
- package/src/setup-globals.ts +23 -21
- package/src/testing/clock.ts +26 -0
- package/src/testing/durable-object.ts +325 -0
- package/src/testing/env-builder.ts +126 -0
- package/src/testing/fetch-mock.ts +145 -0
- package/src/testing/index.ts +288 -0
- package/src/testing/setup.ts +68 -0
- package/src/testing/types.ts +68 -0
- package/src/testing/workflow.ts +323 -0
- package/src/tracing/store.ts +6 -0
- package/src/tracing/types.ts +1 -0
- package/src/virtual-modules.ts +99 -0
- package/src/vite-plugin/config-plugin.ts +2 -0
- package/src/vite-plugin/dev-server-plugin.ts +159 -56
package/src/bindings/workflow.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Clock } from '../testing/clock'
|
|
3
|
+
import { realClock } from '../testing/clock'
|
|
2
4
|
import { getActiveContext } from '../tracing/context'
|
|
3
5
|
import { addSpanEvent, persistError, startSpan } from '../tracing/span'
|
|
4
6
|
|
|
@@ -90,6 +92,65 @@ const abortControllers = new Map<string, AbortController>()
|
|
|
90
92
|
|
|
91
93
|
const sleepResolvers = new Map<string, () => void>()
|
|
92
94
|
|
|
95
|
+
// --- Notification hooks (per-process, for testing) ---
|
|
96
|
+
|
|
97
|
+
const stepCallbacks = new Map<string, Map<string, Set<(output: unknown) => void>>>()
|
|
98
|
+
const sleepCallbacks = new Map<string, Set<() => void>>()
|
|
99
|
+
const eventWaitCallbacks = new Map<string, Map<string, Set<() => void>>>()
|
|
100
|
+
const statusCallbacks = new Map<string, Set<(status: string) => void>>()
|
|
101
|
+
|
|
102
|
+
// --- Mock registry (per-process, for testing) ---
|
|
103
|
+
|
|
104
|
+
export interface StepMock {
|
|
105
|
+
type: 'result' | 'error' | 'timeout'
|
|
106
|
+
value?: unknown // result value or Error for error type
|
|
107
|
+
times?: number // how many times to apply (undefined = forever)
|
|
108
|
+
_used?: number // internal counter
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const stepMocks = new Map<string, Map<string, StepMock>>()
|
|
112
|
+
const sleepDisabledInstances = new Set<string>()
|
|
113
|
+
const eventMocks = new Map<string, Map<string, { payload: unknown }>>()
|
|
114
|
+
const eventTimeoutMocks = new Map<string, Set<string>>()
|
|
115
|
+
|
|
116
|
+
export function registerStepMock(instanceId: string, stepName: string, mock: StepMock): void {
|
|
117
|
+
let instanceMocks = stepMocks.get(instanceId)
|
|
118
|
+
if (!instanceMocks) {
|
|
119
|
+
instanceMocks = new Map()
|
|
120
|
+
stepMocks.set(instanceId, instanceMocks)
|
|
121
|
+
}
|
|
122
|
+
instanceMocks.set(stepName, { ...mock, _used: 0 })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function registerSleepDisable(instanceId: string): void {
|
|
126
|
+
sleepDisabledInstances.add(instanceId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function registerEventMock(instanceId: string, eventType: string, payload: unknown): void {
|
|
130
|
+
let instanceMocks = eventMocks.get(instanceId)
|
|
131
|
+
if (!instanceMocks) {
|
|
132
|
+
instanceMocks = new Map()
|
|
133
|
+
eventMocks.set(instanceId, instanceMocks)
|
|
134
|
+
}
|
|
135
|
+
instanceMocks.set(eventType, { payload })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function registerEventTimeoutMock(instanceId: string, eventType: string): void {
|
|
139
|
+
let instanceMocks = eventTimeoutMocks.get(instanceId)
|
|
140
|
+
if (!instanceMocks) {
|
|
141
|
+
instanceMocks = new Set()
|
|
142
|
+
eventTimeoutMocks.set(instanceId, instanceMocks)
|
|
143
|
+
}
|
|
144
|
+
instanceMocks.add(eventType)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function clearInstanceMocks(instanceId: string): void {
|
|
148
|
+
stepMocks.delete(instanceId)
|
|
149
|
+
sleepDisabledInstances.delete(instanceId)
|
|
150
|
+
eventMocks.delete(instanceId)
|
|
151
|
+
eventTimeoutMocks.delete(instanceId)
|
|
152
|
+
}
|
|
153
|
+
|
|
93
154
|
// --- Step ---
|
|
94
155
|
|
|
95
156
|
class WorkflowStepImpl {
|
|
@@ -99,12 +160,14 @@ class WorkflowStepImpl {
|
|
|
99
160
|
private stepCount = 0
|
|
100
161
|
private knownStepNames = new Set<string>()
|
|
101
162
|
private limits: Required<WorkflowLimits>
|
|
163
|
+
private clock: Clock
|
|
102
164
|
|
|
103
|
-
constructor(abortSignal: AbortSignal, db: Database, instanceId: string, limits: Required<WorkflowLimits
|
|
165
|
+
constructor(abortSignal: AbortSignal, db: Database, instanceId: string, limits: Required<WorkflowLimits>, clock?: Clock) {
|
|
104
166
|
this.abortSignal = abortSignal
|
|
105
167
|
this.db = db
|
|
106
168
|
this.instanceId = instanceId
|
|
107
169
|
this.limits = limits
|
|
170
|
+
this.clock = clock ?? realClock
|
|
108
171
|
}
|
|
109
172
|
|
|
110
173
|
private async checkPaused(): Promise<void> {
|
|
@@ -145,7 +208,8 @@ class WorkflowStepImpl {
|
|
|
145
208
|
}
|
|
146
209
|
this.db
|
|
147
210
|
.query('INSERT OR REPLACE INTO workflow_steps (instance_id, step_name, output, completed_at) VALUES (?, ?, ?, ?)')
|
|
148
|
-
.run(this.instanceId, name, serialized,
|
|
211
|
+
.run(this.instanceId, name, serialized, this.clock.now())
|
|
212
|
+
fireStepCallbacks(this.instanceId, name, output)
|
|
149
213
|
}
|
|
150
214
|
|
|
151
215
|
async do<T>(name: string, callbackOrConfig: (() => Promise<T>) | WorkflowStepConfig, maybeCallback?: () => Promise<T>): Promise<T> {
|
|
@@ -174,6 +238,29 @@ class WorkflowStepImpl {
|
|
|
174
238
|
return JSON.parse(cached.output!) as T
|
|
175
239
|
}
|
|
176
240
|
|
|
241
|
+
// Check step mocks
|
|
242
|
+
const instanceMocks = stepMocks.get(this.instanceId)
|
|
243
|
+
const mock = instanceMocks?.get(name)
|
|
244
|
+
if (mock) {
|
|
245
|
+
const shouldApply = mock.times === undefined || (mock._used ?? 0) < mock.times
|
|
246
|
+
if (shouldApply) {
|
|
247
|
+
mock._used = (mock._used ?? 0) + 1
|
|
248
|
+
if (mock.type === 'result') {
|
|
249
|
+
console.log(` [workflow] step: ${name} (mocked)`)
|
|
250
|
+
this.cacheStep(name, mock.value)
|
|
251
|
+
return mock.value as T
|
|
252
|
+
}
|
|
253
|
+
if (mock.type === 'error') {
|
|
254
|
+
console.log(` [workflow] step: ${name} (mocked error)`)
|
|
255
|
+
throw mock.value
|
|
256
|
+
}
|
|
257
|
+
if (mock.type === 'timeout') {
|
|
258
|
+
console.log(` [workflow] step: ${name} (mocked timeout)`)
|
|
259
|
+
throw new Error(`Step "${name}" timed out (mocked)`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
177
264
|
console.log(` [workflow] step: ${name}`)
|
|
178
265
|
|
|
179
266
|
return startSpan({
|
|
@@ -232,7 +319,7 @@ class WorkflowStepImpl {
|
|
|
232
319
|
this.db.query(
|
|
233
320
|
'INSERT OR REPLACE INTO workflow_step_attempts (instance_id, step_name, failed_attempts, last_error, last_error_name, last_error_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
234
321
|
)
|
|
235
|
-
.run(this.instanceId, name, attempt + 1, errMsg, errName, errorId,
|
|
322
|
+
.run(this.instanceId, name, attempt + 1, errMsg, errName, errorId, this.clock.now())
|
|
236
323
|
if (attempt < maxRetries) {
|
|
237
324
|
const d = computeDelay(delayMs, attempt, backoff)
|
|
238
325
|
console.log(` [workflow] step "${name}" attempt ${attempt + 1} failed, retrying in ${d}ms`)
|
|
@@ -249,6 +336,13 @@ class WorkflowStepImpl {
|
|
|
249
336
|
if (this.abortSignal.aborted) throw new Error('workflow terminated')
|
|
250
337
|
this.checkDuplicateStepName(`sleep:${name}`)
|
|
251
338
|
|
|
339
|
+
// Check if sleeps are disabled for this instance
|
|
340
|
+
if (sleepDisabledInstances.has(this.instanceId)) {
|
|
341
|
+
console.log(` [workflow] sleep: ${name} (disabled)`)
|
|
342
|
+
this.cacheStep(`sleep:${name}`, { until: this.clock.now() })
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
252
346
|
console.log(` [workflow] sleep: ${name}`)
|
|
253
347
|
return startSpan({
|
|
254
348
|
name: `sleep ${name}`,
|
|
@@ -258,7 +352,7 @@ class WorkflowStepImpl {
|
|
|
258
352
|
const cached = this.getCachedStep(`sleep:${name}`)
|
|
259
353
|
if (cached) {
|
|
260
354
|
const { until } = JSON.parse(cached.output!) as { until: number }
|
|
261
|
-
const remaining = Math.max(0, until -
|
|
355
|
+
const remaining = Math.max(0, until - this.clock.now())
|
|
262
356
|
if (remaining > 0) {
|
|
263
357
|
await skippableDelay(remaining, this.abortSignal, this.instanceId)
|
|
264
358
|
}
|
|
@@ -269,7 +363,7 @@ class WorkflowStepImpl {
|
|
|
269
363
|
if (ms > this.limits.maxSleepMs) {
|
|
270
364
|
throw new Error(`Sleep duration ${ms}ms exceeds maximum of ${this.limits.maxSleepMs}ms`)
|
|
271
365
|
}
|
|
272
|
-
const until =
|
|
366
|
+
const until = this.clock.now() + ms
|
|
273
367
|
this.cacheStep(`sleep:${name}`, { until })
|
|
274
368
|
|
|
275
369
|
if (ms > 0) {
|
|
@@ -283,6 +377,14 @@ class WorkflowStepImpl {
|
|
|
283
377
|
if (this.abortSignal.aborted) throw new Error('workflow terminated')
|
|
284
378
|
this.checkDuplicateStepName(`sleepUntil:${name}`)
|
|
285
379
|
|
|
380
|
+
// Check if sleeps are disabled for this instance
|
|
381
|
+
if (sleepDisabledInstances.has(this.instanceId)) {
|
|
382
|
+
console.log(` [workflow] sleepUntil: ${name} (disabled)`)
|
|
383
|
+
const ts = typeof timestamp === 'number' ? new Date(timestamp) : timestamp
|
|
384
|
+
this.cacheStep(`sleepUntil:${name}`, { until: ts.toISOString() })
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
286
388
|
const ts = typeof timestamp === 'number' ? new Date(timestamp) : timestamp
|
|
287
389
|
|
|
288
390
|
console.log(` [workflow] sleepUntil: ${name}`)
|
|
@@ -293,14 +395,14 @@ class WorkflowStepImpl {
|
|
|
293
395
|
}, async () => {
|
|
294
396
|
const cached = this.getCachedStep(`sleepUntil:${name}`)
|
|
295
397
|
if (cached) {
|
|
296
|
-
const remaining = Math.max(0, ts.getTime() -
|
|
398
|
+
const remaining = Math.max(0, ts.getTime() - this.clock.now())
|
|
297
399
|
if (remaining > 0) {
|
|
298
400
|
await skippableDelay(remaining, this.abortSignal, this.instanceId)
|
|
299
401
|
}
|
|
300
402
|
return
|
|
301
403
|
}
|
|
302
404
|
|
|
303
|
-
const delay = Math.max(0, ts.getTime() -
|
|
405
|
+
const delay = Math.max(0, ts.getTime() - this.clock.now())
|
|
304
406
|
if (delay > this.limits.maxSleepMs) {
|
|
305
407
|
throw new Error(`Sleep duration ${delay}ms exceeds maximum of ${this.limits.maxSleepMs}ms`)
|
|
306
408
|
}
|
|
@@ -324,6 +426,23 @@ class WorkflowStepImpl {
|
|
|
324
426
|
throw new Error(`Invalid event type "${options.type}". Must be 1-100 characters, only letters, digits, hyphens and underscores.`)
|
|
325
427
|
}
|
|
326
428
|
|
|
429
|
+
// Check event timeout mocks
|
|
430
|
+
const timeoutMocks = eventTimeoutMocks.get(this.instanceId)
|
|
431
|
+
if (timeoutMocks?.has(options.type)) {
|
|
432
|
+
console.log(` [workflow] waitForEvent: ${name} (mocked timeout)`)
|
|
433
|
+
throw new Error(`waitForEvent timed out (mocked)`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Check event mocks — pre-insert into DB so existing flow picks them up
|
|
437
|
+
const instanceEventMocks = eventMocks.get(this.instanceId)
|
|
438
|
+
const eventMock = instanceEventMocks?.get(options.type)
|
|
439
|
+
if (eventMock) {
|
|
440
|
+
instanceEventMocks!.delete(options.type)
|
|
441
|
+
this.db
|
|
442
|
+
.query('INSERT INTO workflow_events (instance_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)')
|
|
443
|
+
.run(this.instanceId, options.type, eventMock.payload !== undefined ? JSON.stringify(eventMock.payload) : null, this.clock.now())
|
|
444
|
+
}
|
|
445
|
+
|
|
327
446
|
console.log(` [workflow] waitForEvent: ${name} (type: ${options.type})`)
|
|
328
447
|
return startSpan({
|
|
329
448
|
name: `waitForEvent ${name}`,
|
|
@@ -345,7 +464,7 @@ class WorkflowStepImpl {
|
|
|
345
464
|
// Update status to waiting
|
|
346
465
|
this.db
|
|
347
466
|
.query("UPDATE workflow_instances SET status = 'waiting', updated_at = ? WHERE id = ?")
|
|
348
|
-
.run(
|
|
467
|
+
.run(this.clock.now(), this.instanceId)
|
|
349
468
|
|
|
350
469
|
// Check if event already exists in DB
|
|
351
470
|
const existing = this.db
|
|
@@ -358,7 +477,7 @@ class WorkflowStepImpl {
|
|
|
358
477
|
.run(this.instanceId, options.type)
|
|
359
478
|
this.db
|
|
360
479
|
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
361
|
-
.run(
|
|
480
|
+
.run(this.clock.now(), this.instanceId)
|
|
362
481
|
const payload = (existing.payload !== null ? JSON.parse(existing.payload) : undefined) as T
|
|
363
482
|
const event = { payload, timestamp: new Date(existing.created_at), type: options.type }
|
|
364
483
|
this.cacheStep(`waitForEvent:${name}`, event)
|
|
@@ -400,12 +519,13 @@ class WorkflowStepImpl {
|
|
|
400
519
|
reject(new Error('workflow terminated'))
|
|
401
520
|
}
|
|
402
521
|
this.abortSignal.addEventListener('abort', abortHandler)
|
|
522
|
+
fireEventWaitCallbacks(this.instanceId, options.type)
|
|
403
523
|
})
|
|
404
524
|
|
|
405
525
|
// Restore running status
|
|
406
526
|
this.db
|
|
407
527
|
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
408
|
-
.run(
|
|
528
|
+
.run(this.clock.now(), this.instanceId)
|
|
409
529
|
|
|
410
530
|
this.cacheStep(`waitForEvent:${name}`, result)
|
|
411
531
|
return result
|
|
@@ -467,6 +587,7 @@ function skippableDelay(ms: number, abortSignal: AbortSignal, instanceId: string
|
|
|
467
587
|
resolve()
|
|
468
588
|
})
|
|
469
589
|
abortSignal.addEventListener('abort', abortHandler)
|
|
590
|
+
fireSleepCallbacks(instanceId)
|
|
470
591
|
})
|
|
471
592
|
}
|
|
472
593
|
|
|
@@ -482,6 +603,104 @@ export function isInstanceSleeping(instanceId: string): boolean {
|
|
|
482
603
|
return sleepResolvers.has(instanceId)
|
|
483
604
|
}
|
|
484
605
|
|
|
606
|
+
// --- Notification hook registration (for testing) ---
|
|
607
|
+
|
|
608
|
+
function fireStepCallbacks(instanceId: string, stepName: string, output: unknown): void {
|
|
609
|
+
const instanceCbs = stepCallbacks.get(instanceId)
|
|
610
|
+
if (!instanceCbs) return
|
|
611
|
+
const cbs = instanceCbs.get(stepName)
|
|
612
|
+
if (!cbs) return
|
|
613
|
+
for (const cb of cbs) cb(output)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function fireSleepCallbacks(instanceId: string): void {
|
|
617
|
+
const cbs = sleepCallbacks.get(instanceId)
|
|
618
|
+
if (!cbs) return
|
|
619
|
+
for (const cb of cbs) cb()
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function fireEventWaitCallbacks(instanceId: string, eventType: string): void {
|
|
623
|
+
const instanceCbs = eventWaitCallbacks.get(instanceId)
|
|
624
|
+
if (!instanceCbs) return
|
|
625
|
+
const cbs = instanceCbs.get(eventType)
|
|
626
|
+
if (!cbs) return
|
|
627
|
+
for (const cb of cbs) cb()
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function fireStatusCallbacks(instanceId: string, status: string): void {
|
|
631
|
+
const cbs = statusCallbacks.get(instanceId)
|
|
632
|
+
if (!cbs) return
|
|
633
|
+
for (const cb of cbs) cb(status)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Register a callback for when a step completes. Returns an unsubscribe function. */
|
|
637
|
+
export function onStepComplete(instanceId: string, stepName: string, cb: (output: unknown) => void): () => void {
|
|
638
|
+
let instanceCbs = stepCallbacks.get(instanceId)
|
|
639
|
+
if (!instanceCbs) {
|
|
640
|
+
instanceCbs = new Map()
|
|
641
|
+
stepCallbacks.set(instanceId, instanceCbs)
|
|
642
|
+
}
|
|
643
|
+
let cbs = instanceCbs.get(stepName)
|
|
644
|
+
if (!cbs) {
|
|
645
|
+
cbs = new Set()
|
|
646
|
+
instanceCbs.set(stepName, cbs)
|
|
647
|
+
}
|
|
648
|
+
cbs.add(cb)
|
|
649
|
+
return () => {
|
|
650
|
+
cbs!.delete(cb)
|
|
651
|
+
if (cbs!.size === 0) instanceCbs!.delete(stepName)
|
|
652
|
+
if (instanceCbs!.size === 0) stepCallbacks.delete(instanceId)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Register a callback for when the instance starts sleeping. Returns an unsubscribe function. */
|
|
657
|
+
export function onSleepRegistered(instanceId: string, cb: () => void): () => void {
|
|
658
|
+
let cbs = sleepCallbacks.get(instanceId)
|
|
659
|
+
if (!cbs) {
|
|
660
|
+
cbs = new Set()
|
|
661
|
+
sleepCallbacks.set(instanceId, cbs)
|
|
662
|
+
}
|
|
663
|
+
cbs.add(cb)
|
|
664
|
+
return () => {
|
|
665
|
+
cbs!.delete(cb)
|
|
666
|
+
if (cbs!.size === 0) sleepCallbacks.delete(instanceId)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Register a callback for when the instance starts waiting for an event. Returns an unsubscribe function. */
|
|
671
|
+
export function onEventWaitRegistered(instanceId: string, eventType: string, cb: () => void): () => void {
|
|
672
|
+
let instanceCbs = eventWaitCallbacks.get(instanceId)
|
|
673
|
+
if (!instanceCbs) {
|
|
674
|
+
instanceCbs = new Map()
|
|
675
|
+
eventWaitCallbacks.set(instanceId, instanceCbs)
|
|
676
|
+
}
|
|
677
|
+
let cbs = instanceCbs.get(eventType)
|
|
678
|
+
if (!cbs) {
|
|
679
|
+
cbs = new Set()
|
|
680
|
+
instanceCbs.set(eventType, cbs)
|
|
681
|
+
}
|
|
682
|
+
cbs.add(cb)
|
|
683
|
+
return () => {
|
|
684
|
+
cbs!.delete(cb)
|
|
685
|
+
if (cbs!.size === 0) instanceCbs!.delete(eventType)
|
|
686
|
+
if (instanceCbs!.size === 0) eventWaitCallbacks.delete(instanceId)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Register a callback for when the instance status changes. Returns an unsubscribe function. */
|
|
691
|
+
export function onStatusChange(instanceId: string, cb: (status: string) => void): () => void {
|
|
692
|
+
let cbs = statusCallbacks.get(instanceId)
|
|
693
|
+
if (!cbs) {
|
|
694
|
+
cbs = new Set()
|
|
695
|
+
statusCallbacks.set(instanceId, cbs)
|
|
696
|
+
}
|
|
697
|
+
cbs.add(cb)
|
|
698
|
+
return () => {
|
|
699
|
+
cbs!.delete(cb)
|
|
700
|
+
if (cbs!.size === 0) statusCallbacks.delete(instanceId)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
485
704
|
export function parseDuration(duration: string | number): number {
|
|
486
705
|
if (typeof duration === 'number') return duration
|
|
487
706
|
const match = duration.match(/^(\d+)\s*(ms|milliseconds?|s|seconds?|m|minutes?|h|hours?|d|days?|w|weeks?|months?|y|years?)$/i)
|
|
@@ -567,6 +786,7 @@ export class SqliteWorkflowInstance {
|
|
|
567
786
|
// Abort via global registry so get()-retrieved instances also work
|
|
568
787
|
const ac = abortControllers.get(this.instanceId)
|
|
569
788
|
ac?.abort()
|
|
789
|
+
fireStatusCallbacks(this.instanceId, 'terminated')
|
|
570
790
|
}
|
|
571
791
|
|
|
572
792
|
async restart(options?: { fromStep?: string }): Promise<void> {
|
|
@@ -659,12 +879,14 @@ export class SqliteWorkflowBinding {
|
|
|
659
879
|
private _env?: unknown
|
|
660
880
|
private counter = 0
|
|
661
881
|
private limits: Required<WorkflowLimits>
|
|
882
|
+
private clock: Clock
|
|
662
883
|
|
|
663
|
-
constructor(db: Database, workflowName: string, className: string, limits?: WorkflowLimits) {
|
|
884
|
+
constructor(db: Database, workflowName: string, className: string, limits?: WorkflowLimits, clock?: Clock) {
|
|
664
885
|
this.db = db
|
|
665
886
|
this.workflowName = workflowName
|
|
666
887
|
this.className = className
|
|
667
888
|
this.limits = { ...WORKFLOW_DEFAULTS, ...limits }
|
|
889
|
+
this.clock = clock ?? realClock
|
|
668
890
|
}
|
|
669
891
|
|
|
670
892
|
_setClass(cls: new(ctx: unknown, env: unknown) => WorkflowEntrypointBase, env: unknown) {
|
|
@@ -687,6 +909,9 @@ export class SqliteWorkflowBinding {
|
|
|
687
909
|
_getLimits() {
|
|
688
910
|
return this.limits
|
|
689
911
|
}
|
|
912
|
+
_getClock() {
|
|
913
|
+
return this.clock
|
|
914
|
+
}
|
|
690
915
|
|
|
691
916
|
/** Abort all running/queued/waiting instances for this workflow */
|
|
692
917
|
abortRunning(): void {
|
|
@@ -700,7 +925,7 @@ export class SqliteWorkflowBinding {
|
|
|
700
925
|
|
|
701
926
|
private cleanupRetentionExpired(): void {
|
|
702
927
|
if (this.limits.maxRetentionMs <= 0) return
|
|
703
|
-
const cutoff =
|
|
928
|
+
const cutoff = this.clock.now() - this.limits.maxRetentionMs
|
|
704
929
|
// Clean up step attempts for instances being deleted
|
|
705
930
|
const expiredIds = this.db
|
|
706
931
|
.query("SELECT id FROM workflow_instances WHERE workflow_name = ? AND status IN ('complete', 'errored') AND updated_at < ?")
|
|
@@ -725,7 +950,7 @@ export class SqliteWorkflowBinding {
|
|
|
725
950
|
|
|
726
951
|
this.cleanupRetentionExpired()
|
|
727
952
|
|
|
728
|
-
const id = options?.id ?? `wf-${++this.counter}-${
|
|
953
|
+
const id = options?.id ?? `wf-${++this.counter}-${this.clock.now()}`
|
|
729
954
|
if (id.length > this.limits.maxInstanceIdLength) {
|
|
730
955
|
throw new Error(`Workflow instance ID must be ${this.limits.maxInstanceIdLength} characters or fewer, got ${id.length}`)
|
|
731
956
|
}
|
|
@@ -735,7 +960,7 @@ export class SqliteWorkflowBinding {
|
|
|
735
960
|
if (existing) throw new Error(`Workflow instance with ID "${id}" already exists`)
|
|
736
961
|
|
|
737
962
|
const params = options?.params ?? {}
|
|
738
|
-
const now =
|
|
963
|
+
const now = this.clock.now()
|
|
739
964
|
|
|
740
965
|
// Check concurrency
|
|
741
966
|
const isQueued = this.limits.maxConcurrentInstances !== Infinity && this.countRunning() >= this.limits.maxConcurrentInstances
|
|
@@ -753,7 +978,18 @@ export class SqliteWorkflowBinding {
|
|
|
753
978
|
|
|
754
979
|
if (!isQueued) {
|
|
755
980
|
console.log(`[workflow] started ${id}`)
|
|
756
|
-
SqliteWorkflowBinding.executeWorkflow(
|
|
981
|
+
SqliteWorkflowBinding.executeWorkflow(
|
|
982
|
+
this.db,
|
|
983
|
+
id,
|
|
984
|
+
this._class,
|
|
985
|
+
this._env,
|
|
986
|
+
params,
|
|
987
|
+
abortController,
|
|
988
|
+
this.workflowName,
|
|
989
|
+
this.limits,
|
|
990
|
+
now,
|
|
991
|
+
this.clock,
|
|
992
|
+
)
|
|
757
993
|
} else {
|
|
758
994
|
console.log(`[workflow] queued ${id} (concurrency limit: ${this.limits.maxConcurrentInstances})`)
|
|
759
995
|
}
|
|
@@ -761,6 +997,60 @@ export class SqliteWorkflowBinding {
|
|
|
761
997
|
return handle
|
|
762
998
|
}
|
|
763
999
|
|
|
1000
|
+
/** Create a workflow instance without starting execution. Call _executeInstance() to start it. */
|
|
1001
|
+
async _createPrepared(options?: { id?: string; params?: unknown }): Promise<SqliteWorkflowInstance> {
|
|
1002
|
+
if (!this._class) throw new Error('Workflow class not wired yet')
|
|
1003
|
+
|
|
1004
|
+
this.cleanupRetentionExpired()
|
|
1005
|
+
|
|
1006
|
+
const id = options?.id ?? `wf-${++this.counter}-${this.clock.now()}`
|
|
1007
|
+
if (id.length > this.limits.maxInstanceIdLength) {
|
|
1008
|
+
throw new Error(`Workflow instance ID must be ${this.limits.maxInstanceIdLength} characters or fewer, got ${id.length}`)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const existing = this.db.query('SELECT id FROM workflow_instances WHERE id = ?').get(id)
|
|
1012
|
+
if (existing) throw new Error(`Workflow instance with ID "${id}" already exists`)
|
|
1013
|
+
|
|
1014
|
+
const params = options?.params ?? {}
|
|
1015
|
+
const now = this.clock.now()
|
|
1016
|
+
|
|
1017
|
+
this.db
|
|
1018
|
+
.query(
|
|
1019
|
+
'INSERT INTO workflow_instances (id, workflow_name, class_name, params, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
1020
|
+
)
|
|
1021
|
+
.run(id, this.workflowName, this.className, JSON.stringify(params), 'queued', now, now)
|
|
1022
|
+
|
|
1023
|
+
const abortController = new AbortController()
|
|
1024
|
+
abortControllers.set(id, abortController)
|
|
1025
|
+
|
|
1026
|
+
return new SqliteWorkflowInstance(this.db, id, this)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Start execution of a prepared (queued) workflow instance. */
|
|
1030
|
+
_executeInstance(id: string): void {
|
|
1031
|
+
if (!this._class) throw new Error('Workflow class not wired yet')
|
|
1032
|
+
|
|
1033
|
+
const row = this.db
|
|
1034
|
+
.query('SELECT params, created_at FROM workflow_instances WHERE id = ?')
|
|
1035
|
+
.get(id) as { params: string | null; created_at: number } | null
|
|
1036
|
+
if (!row) throw new Error(`Workflow instance ${id} not found`)
|
|
1037
|
+
|
|
1038
|
+
const params = row.params !== null ? JSON.parse(row.params) : {}
|
|
1039
|
+
|
|
1040
|
+
this.db
|
|
1041
|
+
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
1042
|
+
.run(this.clock.now(), id)
|
|
1043
|
+
|
|
1044
|
+
let ac = abortControllers.get(id)
|
|
1045
|
+
if (!ac) {
|
|
1046
|
+
ac = new AbortController()
|
|
1047
|
+
abortControllers.set(id, ac)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
console.log(`[workflow] started ${id}`)
|
|
1051
|
+
SqliteWorkflowBinding.executeWorkflow(this.db, id, this._class, this._env, params, ac, this.workflowName, this.limits, row.created_at, this.clock)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
764
1054
|
async createBatch(batch: { id?: string; params?: unknown }[]): Promise<SqliteWorkflowInstance[]> {
|
|
765
1055
|
if (batch.length > this.limits.maxBatchSize) {
|
|
766
1056
|
throw new Error(`Batch size ${batch.length} exceeds maximum of ${this.limits.maxBatchSize}`)
|
|
@@ -797,7 +1087,7 @@ export class SqliteWorkflowBinding {
|
|
|
797
1087
|
// Reset to running before re-executing (waiting status needs to restart from last checkpoint)
|
|
798
1088
|
this.db
|
|
799
1089
|
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
800
|
-
.run(
|
|
1090
|
+
.run(this.clock.now(), row.id)
|
|
801
1091
|
SqliteWorkflowBinding.executeWorkflow(
|
|
802
1092
|
this.db,
|
|
803
1093
|
row.id,
|
|
@@ -808,6 +1098,7 @@ export class SqliteWorkflowBinding {
|
|
|
808
1098
|
this.workflowName,
|
|
809
1099
|
this.limits,
|
|
810
1100
|
row.created_at,
|
|
1101
|
+
this.clock,
|
|
811
1102
|
)
|
|
812
1103
|
}
|
|
813
1104
|
}
|
|
@@ -825,7 +1116,7 @@ export class SqliteWorkflowBinding {
|
|
|
825
1116
|
|
|
826
1117
|
this.db
|
|
827
1118
|
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
828
|
-
.run(
|
|
1119
|
+
.run(this.clock.now(), queued.id)
|
|
829
1120
|
|
|
830
1121
|
const abortController = new AbortController()
|
|
831
1122
|
abortControllers.set(queued.id, abortController)
|
|
@@ -841,6 +1132,7 @@ export class SqliteWorkflowBinding {
|
|
|
841
1132
|
this.workflowName,
|
|
842
1133
|
this.limits,
|
|
843
1134
|
queued.created_at,
|
|
1135
|
+
this.clock,
|
|
844
1136
|
)
|
|
845
1137
|
}
|
|
846
1138
|
}
|
|
@@ -855,14 +1147,16 @@ export class SqliteWorkflowBinding {
|
|
|
855
1147
|
workflowName?: string,
|
|
856
1148
|
limits?: Required<WorkflowLimits>,
|
|
857
1149
|
createdAt?: number,
|
|
1150
|
+
clock?: Clock,
|
|
858
1151
|
): void {
|
|
859
1152
|
const resolvedLimits = limits ?? WORKFLOW_DEFAULTS
|
|
860
|
-
const
|
|
861
|
-
const step = new WorkflowStepImpl(abortController.signal, db, id, resolvedLimits)
|
|
862
|
-
const event = { payload: params, timestamp: new Date(createdAt ?? Date.now()), instanceId: id }
|
|
1153
|
+
const resolvedClock = clock ?? realClock
|
|
863
1154
|
;(async () => {
|
|
864
1155
|
let workflowTraceId: string | undefined
|
|
865
1156
|
try {
|
|
1157
|
+
const instance = new workflowClass({}, env)
|
|
1158
|
+
const step = new WorkflowStepImpl(abortController.signal, db, id, resolvedLimits, resolvedClock)
|
|
1159
|
+
const event = { payload: params, timestamp: new Date(createdAt ?? resolvedClock.now()), instanceId: id }
|
|
866
1160
|
const result = await startSpan({
|
|
867
1161
|
name: `workflow ${workflowName ?? 'run'}`,
|
|
868
1162
|
kind: 'server',
|
|
@@ -875,10 +1169,11 @@ export class SqliteWorkflowBinding {
|
|
|
875
1169
|
})
|
|
876
1170
|
if (abortController.signal.aborted) return
|
|
877
1171
|
db.query("UPDATE workflow_instances SET status = 'complete', output = ?, updated_at = ? WHERE id = ?")
|
|
878
|
-
.run(JSON.stringify(result),
|
|
1172
|
+
.run(JSON.stringify(result), resolvedClock.now(), id)
|
|
879
1173
|
// Clean up step attempts on successful completion
|
|
880
1174
|
db.query('DELETE FROM workflow_step_attempts WHERE instance_id = ?').run(id)
|
|
881
1175
|
console.log(`[workflow] completed ${id}:`, result)
|
|
1176
|
+
fireStatusCallbacks(id, 'complete')
|
|
882
1177
|
} catch (err) {
|
|
883
1178
|
const errorName = err instanceof Error ? (err.name || err.constructor.name || 'Error') : 'Error'
|
|
884
1179
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -886,14 +1181,15 @@ export class SqliteWorkflowBinding {
|
|
|
886
1181
|
if (abortController.signal.aborted) {
|
|
887
1182
|
// Terminated — store error info but keep "terminated" status
|
|
888
1183
|
db.query('UPDATE workflow_instances SET error = ?, error_name = ?, updated_at = ? WHERE id = ?')
|
|
889
|
-
.run(message, errorName,
|
|
1184
|
+
.run(message, errorName, resolvedClock.now(), id)
|
|
890
1185
|
persistError(err, 'workflow', workflowName, workflowTraceId)
|
|
891
1186
|
return
|
|
892
1187
|
}
|
|
893
1188
|
db.query("UPDATE workflow_instances SET status = 'errored', error = ?, error_name = ?, updated_at = ? WHERE id = ?")
|
|
894
|
-
.run(message, errorName,
|
|
1189
|
+
.run(message, errorName, resolvedClock.now(), id)
|
|
895
1190
|
console.error(`[workflow] failed ${id}:`, err)
|
|
896
1191
|
persistError(err, 'workflow', workflowName, workflowTraceId)
|
|
1192
|
+
fireStatusCallbacks(id, 'errored')
|
|
897
1193
|
} finally {
|
|
898
1194
|
eventWaiters.delete(id)
|
|
899
1195
|
abortControllers.delete(id)
|
|
@@ -905,12 +1201,23 @@ export class SqliteWorkflowBinding {
|
|
|
905
1201
|
.get(workflowName) as { id: string; params: string | null; created_at: number } | null
|
|
906
1202
|
if (queued) {
|
|
907
1203
|
db.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
908
|
-
.run(
|
|
1204
|
+
.run(resolvedClock.now(), queued.id)
|
|
909
1205
|
const qParams = queued.params !== null ? JSON.parse(queued.params) : {}
|
|
910
1206
|
const ac = new AbortController()
|
|
911
1207
|
abortControllers.set(queued.id, ac)
|
|
912
1208
|
console.log(`[workflow] starting queued instance ${queued.id}`)
|
|
913
|
-
SqliteWorkflowBinding.executeWorkflow(
|
|
1209
|
+
SqliteWorkflowBinding.executeWorkflow(
|
|
1210
|
+
db,
|
|
1211
|
+
queued.id,
|
|
1212
|
+
workflowClass,
|
|
1213
|
+
env,
|
|
1214
|
+
qParams,
|
|
1215
|
+
ac,
|
|
1216
|
+
workflowName,
|
|
1217
|
+
resolvedLimits,
|
|
1218
|
+
queued.created_at,
|
|
1219
|
+
resolvedClock,
|
|
1220
|
+
)
|
|
914
1221
|
}
|
|
915
1222
|
}
|
|
916
1223
|
}
|
package/src/env.ts
CHANGED
|
@@ -321,7 +321,7 @@ export function buildEnv(
|
|
|
321
321
|
|
|
322
322
|
// Static assets
|
|
323
323
|
if (config.assets) {
|
|
324
|
-
const assetsDir = path.resolve(config.assets.directory)
|
|
324
|
+
const assetsDir = devVarsDir ? path.resolve(devVarsDir, config.assets.directory) : path.resolve(config.assets.directory)
|
|
325
325
|
const assets = new StaticAssets(assetsDir, config.assets.html_handling, config.assets.not_found_handling)
|
|
326
326
|
registry.staticAssets = assets
|
|
327
327
|
if (config.assets.binding) {
|
|
@@ -363,11 +363,12 @@ export function wireClassRefs(
|
|
|
363
363
|
workerModule: Record<string, unknown>,
|
|
364
364
|
env: Record<string, unknown>,
|
|
365
365
|
workerRegistry?: WorkerRegistry,
|
|
366
|
+
generationId?: number,
|
|
366
367
|
) {
|
|
367
368
|
for (const entry of registry.durableObjects) {
|
|
368
369
|
const cls = workerModule[entry.className]
|
|
369
370
|
if (!cls) throw new Error(`Durable Object class "${entry.className}" not exported from worker module`)
|
|
370
|
-
entry.namespace._setClass(cls as any, env)
|
|
371
|
+
entry.namespace._setClass(cls as any, env, generationId)
|
|
371
372
|
console.log(`[lopata] Wired DO class: ${entry.className}`)
|
|
372
373
|
}
|
|
373
374
|
|