abxbus 2.4.16 → 2.4.19

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.
Files changed (44) hide show
  1. package/dist/cjs/event_bus.js +1 -1
  2. package/dist/cjs/event_bus.js.map +2 -2
  3. package/dist/cjs/event_handler.d.ts +1 -0
  4. package/dist/cjs/event_handler.js +14 -1
  5. package/dist/cjs/event_handler.js.map +2 -2
  6. package/dist/cjs/middleware_otel_tracing.d.ts +9 -1
  7. package/dist/cjs/middleware_otel_tracing.js +73 -6
  8. package/dist/cjs/middleware_otel_tracing.js.map +2 -2
  9. package/dist/cjs/retry.js +39 -0
  10. package/dist/cjs/retry.js.map +2 -2
  11. package/dist/esm/event_bus.js +1 -1
  12. package/dist/esm/event_bus.js.map +2 -2
  13. package/dist/esm/event_handler.js +14 -1
  14. package/dist/esm/event_handler.js.map +2 -2
  15. package/dist/esm/middleware_otel_tracing.js +78 -7
  16. package/dist/esm/middleware_otel_tracing.js.map +2 -2
  17. package/dist/esm/retry.js +39 -0
  18. package/dist/esm/retry.js.map +2 -2
  19. package/dist/types/event_handler.d.ts +1 -0
  20. package/dist/types/middleware_otel_tracing.d.ts +9 -1
  21. package/package.json +5 -1
  22. package/src/async_context.ts +70 -0
  23. package/src/base_event.ts +1201 -0
  24. package/src/bridge_jsonl.ts +174 -0
  25. package/src/bridge_nats.ts +104 -0
  26. package/src/bridge_postgres.ts +277 -0
  27. package/src/bridge_redis.ts +194 -0
  28. package/src/bridge_sqlite.ts +289 -0
  29. package/src/bridges.ts +376 -0
  30. package/src/event_bus.ts +1263 -0
  31. package/src/event_handler.ts +379 -0
  32. package/src/event_history.ts +247 -0
  33. package/src/event_result.ts +483 -0
  34. package/src/events_suck.ts +96 -0
  35. package/src/helpers.ts +65 -0
  36. package/src/index.ts +37 -0
  37. package/src/lock_manager.ts +401 -0
  38. package/src/logging.ts +261 -0
  39. package/src/middleware_otel_tracing.ts +290 -0
  40. package/src/middlewares.ts +16 -0
  41. package/src/optional_deps.ts +52 -0
  42. package/src/retry.ts +578 -0
  43. package/src/timing.ts +52 -0
  44. package/src/types.ts +132 -0
@@ -0,0 +1,401 @@
1
+ import type { BaseEvent } from './base_event.js'
2
+ import type { EventResult } from './event_result.js'
3
+ import { createAsyncLocalStorage, type AsyncLocalStorageLike } from './async_context.js'
4
+
5
+ // ─── Deferred / withResolvers ────────────────────────────────────────────────
6
+
7
+ export type Deferred<T> = {
8
+ promise: Promise<T>
9
+ resolve: (value: T | PromiseLike<T>) => void
10
+ reject: (reason?: unknown) => void
11
+ }
12
+
13
+ export const withResolvers = <T>(): Deferred<T> => {
14
+ if (typeof Promise.withResolvers === 'function') {
15
+ return Promise.withResolvers<T>()
16
+ }
17
+ let resolve!: (value: T | PromiseLike<T>) => void
18
+ let reject!: (reason?: unknown) => void
19
+ const promise = new Promise<T>((resolve_fn, reject_fn) => {
20
+ resolve = resolve_fn
21
+ reject = reject_fn
22
+ })
23
+ return { promise, resolve, reject }
24
+ }
25
+
26
+ // ─── Concurrency modes ──────────────────────────────────────────────────────
27
+
28
+ export const EVENT_CONCURRENCY_MODES = ['global-serial', 'bus-serial', 'parallel'] as const
29
+ export type EventConcurrencyMode = (typeof EVENT_CONCURRENCY_MODES)[number]
30
+
31
+ export const EVENT_HANDLER_CONCURRENCY_MODES = ['serial', 'parallel'] as const
32
+ export type EventHandlerConcurrencyMode = (typeof EVENT_HANDLER_CONCURRENCY_MODES)[number]
33
+
34
+ export const EVENT_HANDLER_COMPLETION_MODES = ['all', 'first'] as const
35
+ export type EventHandlerCompletionMode = (typeof EVENT_HANDLER_COMPLETION_MODES)[number]
36
+
37
+ // ─── AsyncLock ───────────────────────────────────────────────────────────────
38
+
39
+ export class AsyncLock {
40
+ size: number
41
+ in_use: number
42
+ waiters: Array<() => void>
43
+
44
+ constructor(size: number) {
45
+ this.size = size
46
+ this.in_use = 0
47
+ this.waiters = []
48
+ }
49
+
50
+ async acquire(): Promise<void> {
51
+ if (this.size === Infinity) {
52
+ return
53
+ }
54
+ if (this.in_use < this.size) {
55
+ this.in_use += 1
56
+ return
57
+ }
58
+ await new Promise<void>((resolve) => {
59
+ this.waiters.push(resolve)
60
+ })
61
+ }
62
+
63
+ release(): void {
64
+ if (this.size === Infinity) {
65
+ return
66
+ }
67
+ const next = this.waiters.shift()
68
+ if (next) {
69
+ // Handoff: keep permit accounted for and transfer directly to next waiter.
70
+ next()
71
+ return
72
+ }
73
+ this.in_use = Math.max(0, this.in_use - 1)
74
+ }
75
+ }
76
+
77
+ export const runWithLock = async <T>(lock: AsyncLock | null, fn: () => Promise<T>): Promise<T> => {
78
+ if (!lock) {
79
+ return await fn()
80
+ }
81
+ await lock.acquire()
82
+ try {
83
+ return await fn()
84
+ } finally {
85
+ lock.release()
86
+ }
87
+ }
88
+
89
+ const handler_context_storage: AsyncLocalStorageLike | null = createAsyncLocalStorage()
90
+
91
+ // ─── HandlerLock ─────────────────────────────────────────────────────────────
92
+
93
+ export type HandlerExecutionState = 'held' | 'yielded' | 'closed'
94
+
95
+ // Tracks a single handler execution's ownership of a handler lock.
96
+ // Reacquire is race-safe: if the handler exits while waiting to reclaim,
97
+ // the reclaimed lock is immediately released to avoid leaks.
98
+ export class HandlerLock {
99
+ private lock: AsyncLock | null
100
+ private state: HandlerExecutionState
101
+
102
+ constructor(lock: AsyncLock | null) {
103
+ this.lock = lock
104
+ this.state = 'held'
105
+ }
106
+
107
+ // used by EventBus._processEventImmediately to yield the parent handler's lock to the child event so it can be processed immediately
108
+ yieldHandlerLockForChildRun(): boolean {
109
+ if (!this.lock || this.state !== 'held') {
110
+ return false
111
+ }
112
+ this.state = 'yielded'
113
+ this.lock.release()
114
+ return true
115
+ }
116
+
117
+ // used by EventBus._processEventImmediately to reacquire the handler lock after the child event has been processed
118
+ async reclaimHandlerLockIfRunning(): Promise<boolean> {
119
+ if (!this.lock || this.state !== 'yielded') {
120
+ return false
121
+ }
122
+ await this.lock.acquire()
123
+ if (this.state !== 'yielded') {
124
+ // Handler exited while this reacquire was pending.
125
+ this.lock.release()
126
+ return false
127
+ }
128
+ this.state = 'held'
129
+ return true
130
+ }
131
+
132
+ // used by EventResult.runHandler to exit the handler lock after the handler has finished executing
133
+ exitHandlerRun(): void {
134
+ if (this.state === 'closed') {
135
+ return
136
+ }
137
+ const should_release = !!this.lock && this.state === 'held'
138
+ this.state = 'closed'
139
+ if (should_release) {
140
+ this.lock!.release()
141
+ }
142
+ }
143
+
144
+ // used by EventBus._processEventImmediately to yield the handler lock and reacquire it after the child event has been processed
145
+ async runQueueJump<T>(fn: () => Promise<T>): Promise<T> {
146
+ const yielded = this.yieldHandlerLockForChildRun()
147
+ try {
148
+ return await fn()
149
+ } finally {
150
+ if (yielded) {
151
+ await this.reclaimHandlerLockIfRunning()
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ // ─── LockManager ─────────────────────────────────────────────────────────────
158
+
159
+ // Interface that must be implemented by the EventBus class to be used by the LockManager
160
+ export type EventBusInterfaceForLockManager = {
161
+ isIdleAndQueueEmpty: () => boolean
162
+ event_concurrency: EventConcurrencyMode
163
+ _lock_for_event_global_serial: AsyncLock
164
+ }
165
+
166
+ export type LockManagerOptions = {
167
+ auto_schedule_idle_checks?: boolean
168
+ }
169
+
170
+ // The LockManager is responsible for managing the concurrency of events and handlers
171
+ export class LockManager {
172
+ private bus: EventBusInterfaceForLockManager // Live bus reference; used to read defaults and idle state.
173
+ private auto_schedule_idle_checks: boolean
174
+
175
+ readonly bus_event_lock: AsyncLock // Per-bus event lock; created with LockManager and never swapped.
176
+ private pause_depth: number // Re-entrant pause counter; increments on _requestRunloopPause, decrements on release.
177
+ private pause_waiters: Array<() => void> // Resolvers for _waitUntilRunloopResumed; drained when pause_depth hits 0.
178
+ private active_handler_results: EventResult[] // Stack of active handler results for "inside handler" detection.
179
+
180
+ private idle_waiters: Array<(became_idle: boolean) => void> // Resolvers waiting for stable idle; cleared when idle confirmed.
181
+ private idle_check_pending: boolean // Debounce flag to avoid scheduling redundant idle checks.
182
+ private idle_check_streak: number // Counts consecutive idle checks; used to require two ticks of idle.
183
+
184
+ constructor(bus: EventBusInterfaceForLockManager, options: LockManagerOptions = {}) {
185
+ this.bus = bus
186
+ this.auto_schedule_idle_checks = options.auto_schedule_idle_checks ?? true
187
+ this.bus_event_lock = new AsyncLock(1) // used for the bus-serial concurrency mode
188
+
189
+ this.pause_depth = 0
190
+ this.pause_waiters = []
191
+ this.active_handler_results = []
192
+
193
+ this.idle_waiters = []
194
+ this.idle_check_pending = false
195
+ this.idle_check_streak = 0
196
+ }
197
+
198
+ // Low-level runloop pause: increments a re-entrant counter and returns a release
199
+ // function. Used for broad, bus-scoped pauses during queue-jump across buses.
200
+ _requestRunloopPause(): () => void {
201
+ this.pause_depth += 1
202
+ let released = false
203
+ return () => {
204
+ if (released) {
205
+ return
206
+ }
207
+ released = true
208
+ this.pause_depth = Math.max(0, this.pause_depth - 1)
209
+ if (this.pause_depth !== 0) {
210
+ return
211
+ }
212
+ const waiters = this.pause_waiters
213
+ this.pause_waiters = []
214
+ for (const resolve of waiters) {
215
+ resolve()
216
+ }
217
+ }
218
+ }
219
+
220
+ _waitUntilRunloopResumed(): Promise<void> {
221
+ if (this.pause_depth === 0) {
222
+ return Promise.resolve()
223
+ }
224
+ return new Promise((resolve) => {
225
+ this.pause_waiters.push(resolve)
226
+ })
227
+ }
228
+
229
+ _isPaused(): boolean {
230
+ return this.pause_depth > 0
231
+ }
232
+
233
+ async _runWithHandlerDispatchContext<T>(result: EventResult, fn: () => Promise<T>): Promise<T> {
234
+ this.active_handler_results.push(result)
235
+ try {
236
+ if (!handler_context_storage) {
237
+ return await fn()
238
+ }
239
+ return await handler_context_storage.run(result, fn)
240
+ } finally {
241
+ const idx = this.active_handler_results.indexOf(result)
242
+ if (idx >= 0) {
243
+ this.active_handler_results.splice(idx, 1)
244
+ }
245
+ }
246
+ }
247
+
248
+ _getActiveHandlerResultForCurrentAsyncContext(): EventResult | undefined {
249
+ const result = handler_context_storage?.getStore() as EventResult | undefined
250
+ return result?.status === 'started' ? result : undefined
251
+ }
252
+
253
+ _getActiveHandlerResults(): EventResult[] {
254
+ return [...this.active_handler_results]
255
+ }
256
+
257
+ // Per-bus check: true only if this specific bus has a handler on its stack.
258
+ _isAnyHandlerActive(): boolean {
259
+ return this.active_handler_results.length > 0
260
+ }
261
+
262
+ waitForIdle(timeout_seconds: number | null = null): Promise<boolean> {
263
+ return new Promise((resolve) => {
264
+ let done = false
265
+ let timeout_id: ReturnType<typeof setTimeout> | null = null
266
+
267
+ const finish = (became_idle: boolean): void => {
268
+ if (done) {
269
+ return
270
+ }
271
+ done = true
272
+ if (timeout_id !== null) {
273
+ clearTimeout(timeout_id)
274
+ timeout_id = null
275
+ }
276
+ resolve(became_idle)
277
+ }
278
+
279
+ this.idle_waiters.push(finish)
280
+ this.scheduleIdleCheck()
281
+
282
+ if (timeout_seconds === null || timeout_seconds === undefined) {
283
+ return
284
+ }
285
+
286
+ const timeout_ms = Math.max(0, Number(timeout_seconds)) * 1000
287
+ if (!Number.isFinite(timeout_ms)) {
288
+ return
289
+ }
290
+
291
+ timeout_id = setTimeout(() => {
292
+ const index = this.idle_waiters.indexOf(finish)
293
+ if (index >= 0) {
294
+ this.idle_waiters.splice(index, 1)
295
+ }
296
+ finish(false)
297
+ }, timeout_ms)
298
+ })
299
+ }
300
+
301
+ // Called by EventBus.markEventCompleted and EventBus.markHandlerCompleted to notify
302
+ // waitUntilIdle() callers that the bus may now be idle.
303
+ _notifyIdleListeners(): void {
304
+ // Fast-path: most completions have no waitUntilIdle() callers waiting,
305
+ // so skip expensive idle snapshot scans in that common case.
306
+ if (this.idle_waiters.length === 0) {
307
+ this.idle_check_streak = 0
308
+ return
309
+ }
310
+
311
+ if (!this.bus.isIdleAndQueueEmpty()) {
312
+ this.idle_check_streak = 0
313
+ if (this.idle_waiters.length > 0) {
314
+ this.scheduleIdleCheck()
315
+ }
316
+ return
317
+ }
318
+
319
+ this.idle_check_streak += 1
320
+ if (this.idle_check_streak < 2) {
321
+ if (this.idle_waiters.length > 0) {
322
+ this.scheduleIdleCheck()
323
+ }
324
+ return
325
+ }
326
+
327
+ this.idle_check_streak = 0
328
+ const waiters = this.idle_waiters
329
+ this.idle_waiters = []
330
+ for (const resolve of waiters) {
331
+ resolve(true)
332
+ }
333
+ }
334
+
335
+ // get the bus-level lock that prevents/allows multiple events to be processed concurrently on the same bus
336
+ getLockForEvent(event: BaseEvent): AsyncLock | null {
337
+ const resolved = event.event_concurrency ?? this.bus.event_concurrency
338
+ if (resolved === 'parallel') {
339
+ return null
340
+ }
341
+ if (resolved === 'global-serial') {
342
+ return this.bus._lock_for_event_global_serial
343
+ }
344
+ return this.bus_event_lock
345
+ }
346
+
347
+ async _runWithEventLock<T>(
348
+ event: BaseEvent,
349
+ fn: () => Promise<T>,
350
+ options: { bypass_event_locks?: boolean; pre_acquired_lock?: AsyncLock | null } = {}
351
+ ): Promise<T> {
352
+ const pre_acquired = options.pre_acquired_lock ?? null
353
+ if (options.bypass_event_locks || pre_acquired) {
354
+ return await fn()
355
+ }
356
+ return await runWithLock(this.getLockForEvent(event), fn)
357
+ }
358
+
359
+ async _runWithHandlerLock<T>(
360
+ event: BaseEvent,
361
+ default_handler_concurrency: EventHandlerConcurrencyMode | undefined,
362
+ fn: (lock: HandlerLock | null) => Promise<T>
363
+ ): Promise<T> {
364
+ const lock = event._getHandlerLock(default_handler_concurrency)
365
+ if (lock) {
366
+ await lock.acquire()
367
+ }
368
+ const handler_lock = lock ? new HandlerLock(lock) : null
369
+ try {
370
+ return await fn(handler_lock)
371
+ } finally {
372
+ handler_lock?.exitHandlerRun()
373
+ }
374
+ }
375
+
376
+ // Schedules a debounced idle check to run after a short delay. Used to gate
377
+ // waitUntilIdle() calls during handler execution and after event completion.
378
+ private scheduleIdleCheck(): void {
379
+ if (!this.auto_schedule_idle_checks) {
380
+ return
381
+ }
382
+ if (this.idle_check_pending) {
383
+ return
384
+ }
385
+ this.idle_check_pending = true
386
+ setTimeout(() => {
387
+ this.idle_check_pending = false
388
+ this._notifyIdleListeners()
389
+ }, 0)
390
+ }
391
+
392
+ // Reset all state to initial values
393
+ clear(): void {
394
+ this.pause_depth = 0
395
+ this.pause_waiters = []
396
+ this.active_handler_results = []
397
+ this.idle_waiters = []
398
+ this.idle_check_pending = false
399
+ this.idle_check_streak = 0
400
+ }
401
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,261 @@
1
+ import { BaseEvent } from './base_event.js'
2
+ import { EventResult } from './event_result.js'
3
+ import { EventHandlerAbortedError, EventHandlerCancelledError, EventHandlerTimeoutError } from './event_handler.js'
4
+
5
+ type LogTreeBus = {
6
+ name: string
7
+ event_history: {
8
+ values(): IterableIterator<BaseEvent>
9
+ has(event_id: string): boolean
10
+ }
11
+ toString?: () => string
12
+ }
13
+
14
+ export const logTree = (bus: LogTreeBus): string => {
15
+ const parent_to_children = new Map<string, BaseEvent[]>()
16
+
17
+ const addChild = (parent_id: string, child: BaseEvent): void => {
18
+ const existing = parent_to_children.get(parent_id) ?? []
19
+ existing.push(child)
20
+ parent_to_children.set(parent_id, existing)
21
+ }
22
+
23
+ const root_events: BaseEvent[] = []
24
+ const seen = new Set<string>()
25
+
26
+ for (const event of bus.event_history.values()) {
27
+ const parent_id = event.event_parent_id
28
+ if (!parent_id || parent_id === event.event_id || !bus.event_history.has(parent_id)) {
29
+ if (!seen.has(event.event_id)) {
30
+ root_events.push(event)
31
+ seen.add(event.event_id)
32
+ }
33
+ }
34
+ }
35
+
36
+ if (root_events.length === 0) {
37
+ return '(No events in history)'
38
+ }
39
+
40
+ const nodes_by_id = new Map<string, BaseEvent>()
41
+ for (const root of root_events) {
42
+ nodes_by_id.set(root.event_id, root)
43
+ for (const descendant of root.event_descendants) {
44
+ nodes_by_id.set(descendant.event_id, descendant)
45
+ }
46
+ }
47
+
48
+ for (const node of nodes_by_id.values()) {
49
+ const parent_id = node.event_parent_id
50
+ if (!parent_id || parent_id === node.event_id) {
51
+ continue
52
+ }
53
+ if (!nodes_by_id.has(parent_id)) {
54
+ continue
55
+ }
56
+ addChild(parent_id, node)
57
+ }
58
+
59
+ for (const children of parent_to_children.values()) {
60
+ children.sort((a, b) => (a.event_created_at < b.event_created_at ? -1 : a.event_created_at > b.event_created_at ? 1 : 0))
61
+ }
62
+
63
+ const lines: string[] = []
64
+ const bus_label = typeof bus.toString === 'function' ? bus.toString() : bus.name
65
+ lines.push(`📊 Event History Tree for ${bus_label}`)
66
+ lines.push('='.repeat(80))
67
+
68
+ root_events.sort((a, b) => (a.event_created_at < b.event_created_at ? -1 : a.event_created_at > b.event_created_at ? 1 : 0))
69
+ const visited = new Set<string>()
70
+ root_events.forEach((event, index) => {
71
+ lines.push(buildTreeLine(event, '', index === root_events.length - 1, parent_to_children, visited))
72
+ })
73
+
74
+ lines.push('='.repeat(80))
75
+
76
+ return lines.join('\n')
77
+ }
78
+
79
+ export const buildTreeLine = (
80
+ event: BaseEvent,
81
+ indent: string,
82
+ is_last: boolean,
83
+ parent_to_children: Map<string, BaseEvent[]>,
84
+ visited: Set<string>
85
+ ): string => {
86
+ const connector = is_last ? '└── ' : '├── '
87
+ const status_icon = event.event_status === 'completed' ? '✅' : event.event_status === 'started' ? '🏃' : '⏳'
88
+
89
+ const created_at = formatTimestamp(event.event_created_at)
90
+ let timing = `[${created_at}`
91
+ if (event.event_completed_at) {
92
+ const created_ms = Date.parse(event.event_created_at)
93
+ const completed_ms = Date.parse(event.event_completed_at)
94
+ if (!Number.isNaN(created_ms) && !Number.isNaN(completed_ms)) {
95
+ const duration = (completed_ms - created_ms) / 1000
96
+ timing += ` (${duration.toFixed(3)}s)`
97
+ }
98
+ }
99
+ timing += ']'
100
+
101
+ const line = `${indent}${connector}${status_icon} ${event.event_type}#${event.event_id.slice(-4)} ${timing}`
102
+
103
+ if (visited.has(event.event_id)) {
104
+ return line
105
+ }
106
+ visited.add(event.event_id)
107
+
108
+ const extension = is_last ? ' ' : '│ '
109
+ const new_indent = indent + extension
110
+
111
+ const result_items: Array<{ type: 'result'; result: EventResult } | { type: 'child'; child: BaseEvent }> = []
112
+ for (const result of event.event_results.values()) {
113
+ result_items.push({ type: 'result', result })
114
+ }
115
+ const children = parent_to_children.get(event.event_id) ?? []
116
+ const printed_child_ids = new Set<string>(event.event_results.size > 0 ? event.event_results.keys() : [])
117
+ for (const child of children) {
118
+ if (!printed_child_ids.has(child.event_id) && !child.event_emitted_by_handler_id) {
119
+ result_items.push({ type: 'child', child })
120
+ printed_child_ids.add(child.event_id)
121
+ }
122
+ }
123
+
124
+ if (result_items.length === 0) {
125
+ return line
126
+ }
127
+
128
+ const child_lines: string[] = []
129
+ result_items.forEach((item, index) => {
130
+ const is_last_item = index === result_items.length - 1
131
+ if (item.type === 'result') {
132
+ child_lines.push(buildResultLine(item.result, new_indent, is_last_item, parent_to_children, visited))
133
+ } else {
134
+ child_lines.push(buildTreeLine(item.child, new_indent, is_last_item, parent_to_children, visited))
135
+ }
136
+ })
137
+
138
+ return [line, ...child_lines].join('\n')
139
+ }
140
+
141
+ export const buildResultLine = (
142
+ result: EventResult,
143
+ indent: string,
144
+ is_last: boolean,
145
+ parent_to_children: Map<string, BaseEvent[]>,
146
+ visited: Set<string>
147
+ ): string => {
148
+ const connector = is_last ? '└── ' : '├── '
149
+ const status_icon =
150
+ result.status === 'completed'
151
+ ? '✅'
152
+ : result.status === 'error' && isCancellationControlError(result.error)
153
+ ? '🚫'
154
+ : result.status === 'error'
155
+ ? '❌'
156
+ : result.status === 'started'
157
+ ? '🏃'
158
+ : '⏳'
159
+
160
+ const handler_label =
161
+ result.handler_name && result.handler_name !== 'anonymous'
162
+ ? result.handler_name
163
+ : result.handler_file_path
164
+ ? result.handler_file_path
165
+ : 'anonymous'
166
+ const handler_display = `${result.eventbus_label}.${handler_label}#${result.handler_id.slice(-4)}`
167
+ let line = `${indent}${connector}${status_icon} ${handler_display}`
168
+
169
+ if (result.started_at) {
170
+ line += ` [${formatTimestamp(result.started_at)}`
171
+ if (result.completed_at) {
172
+ const started_ms = Date.parse(result.started_at)
173
+ const completed_ms = Date.parse(result.completed_at)
174
+ if (!Number.isNaN(started_ms) && !Number.isNaN(completed_ms)) {
175
+ const duration = (completed_ms - started_ms) / 1000
176
+ line += ` (${duration.toFixed(3)}s)`
177
+ }
178
+ }
179
+ line += ']'
180
+ }
181
+
182
+ if (result.status === 'error' && result.error) {
183
+ if (result.error instanceof EventHandlerTimeoutError) {
184
+ line += ` ⏱️ Timeout: ${result.error.message}`
185
+ } else if (result.error instanceof EventHandlerCancelledError) {
186
+ line += ` Cancelled: ${result.error.message}`
187
+ } else if (result.error instanceof EventHandlerAbortedError) {
188
+ line += ` Aborted: ${result.error.message}`
189
+ } else {
190
+ const error_name = result.error instanceof Error ? result.error.name : 'Error'
191
+ const error_message = result.error instanceof Error ? result.error.message : String(result.error)
192
+ line += ` ☠️ ${error_name}: ${error_message}`
193
+ }
194
+ } else if (result.status === 'completed') {
195
+ line += ` → ${formatResultValue(result.result)}`
196
+ }
197
+
198
+ const extension = is_last ? ' ' : '│ '
199
+ const new_indent = indent + extension
200
+
201
+ const direct_children = result.event_children
202
+ if (direct_children.length === 0) {
203
+ return line
204
+ }
205
+
206
+ const child_lines: string[] = []
207
+ const parent_children = parent_to_children.get(result.event_id) ?? []
208
+ const emitted_children = parent_children.filter((child) => child.event_emitted_by_handler_id === result.handler_id)
209
+ const children_by_id = new Map<string, BaseEvent>()
210
+ direct_children.forEach((child) => {
211
+ children_by_id.set(child.event_id, child)
212
+ })
213
+ emitted_children.forEach((child) => {
214
+ if (!children_by_id.has(child.event_id)) {
215
+ children_by_id.set(child.event_id, child)
216
+ }
217
+ })
218
+ const children_to_print = Array.from(children_by_id.values()).filter((child) => !visited.has(child.event_id))
219
+
220
+ children_to_print.forEach((child, index) => {
221
+ child_lines.push(buildTreeLine(child, new_indent, index === children_to_print.length - 1, parent_to_children, visited))
222
+ })
223
+
224
+ return [line, ...child_lines].join('\n')
225
+ }
226
+
227
+ const isCancellationControlError = (error: unknown): boolean =>
228
+ error instanceof EventHandlerCancelledError || error instanceof EventHandlerAbortedError
229
+
230
+ export const formatTimestamp = (value?: string): string => {
231
+ if (!value) {
232
+ return 'N/A'
233
+ }
234
+ const date = new Date(value)
235
+ if (Number.isNaN(date.getTime())) {
236
+ return 'N/A'
237
+ }
238
+ return date.toISOString().slice(11, 23)
239
+ }
240
+
241
+ export const formatResultValue = (value: unknown): string => {
242
+ if (value === null || value === undefined) {
243
+ return 'None'
244
+ }
245
+ if (value instanceof BaseEvent) {
246
+ return `Event(${value.event_type}#${value.event_id.slice(-4)})`
247
+ }
248
+ if (typeof value === 'string') {
249
+ return JSON.stringify(value)
250
+ }
251
+ if (typeof value === 'number' || typeof value === 'boolean') {
252
+ return String(value)
253
+ }
254
+ if (Array.isArray(value)) {
255
+ return `list(${value.length} items)`
256
+ }
257
+ if (typeof value === 'object') {
258
+ return `dict(${Object.keys(value as Record<string, unknown>).length} items)`
259
+ }
260
+ return `${typeof value}(...)`
261
+ }