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.
@@ -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, Date.now())
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, Date.now())
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 - Date.now())
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 = Date.now() + ms
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() - Date.now())
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() - Date.now())
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(Date.now(), this.instanceId)
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(Date.now(), this.instanceId)
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(Date.now(), this.instanceId)
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 = Date.now() - this.limits.maxRetentionMs
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}-${Date.now()}`
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 = Date.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(this.db, id, this._class, this._env, params, abortController, this.workflowName, this.limits, now)
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(Date.now(), row.id)
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(Date.now(), queued.id)
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 instance = new workflowClass({}, env)
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), Date.now(), id)
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, Date.now(), id)
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, Date.now(), id)
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(Date.now(), queued.id)
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(db, queued.id, workflowClass, env, qParams, ac, workflowName, resolvedLimits, queued.created_at)
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