@nicnocquee/dataqueue 1.25.0 → 1.26.0-beta.20260223202259

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 (59) hide show
  1. package/ai/build-docs-content.ts +96 -0
  2. package/ai/build-llms-full.ts +42 -0
  3. package/ai/docs-content.json +284 -0
  4. package/ai/rules/advanced.md +150 -0
  5. package/ai/rules/basic.md +159 -0
  6. package/ai/rules/react-dashboard.md +83 -0
  7. package/ai/skills/dataqueue-advanced/SKILL.md +370 -0
  8. package/ai/skills/dataqueue-core/SKILL.md +234 -0
  9. package/ai/skills/dataqueue-react/SKILL.md +189 -0
  10. package/dist/cli.cjs +1149 -14
  11. package/dist/cli.cjs.map +1 -1
  12. package/dist/cli.d.cts +66 -1
  13. package/dist/cli.d.ts +66 -1
  14. package/dist/cli.js +1146 -13
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.cjs +3236 -1237
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +697 -23
  19. package/dist/index.d.ts +697 -23
  20. package/dist/index.js +3235 -1238
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp-server.cjs +186 -0
  23. package/dist/mcp-server.cjs.map +1 -0
  24. package/dist/mcp-server.d.cts +32 -0
  25. package/dist/mcp-server.d.ts +32 -0
  26. package/dist/mcp-server.js +175 -0
  27. package/dist/mcp-server.js.map +1 -0
  28. package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
  29. package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
  30. package/package.json +24 -21
  31. package/src/backend.ts +170 -5
  32. package/src/backends/postgres.ts +992 -63
  33. package/src/backends/redis-scripts.ts +358 -26
  34. package/src/backends/redis.test.ts +1532 -0
  35. package/src/backends/redis.ts +993 -35
  36. package/src/cli.test.ts +82 -6
  37. package/src/cli.ts +73 -10
  38. package/src/cron.test.ts +126 -0
  39. package/src/cron.ts +40 -0
  40. package/src/db-util.ts +1 -1
  41. package/src/index.test.ts +1034 -11
  42. package/src/index.ts +267 -39
  43. package/src/init-command.test.ts +449 -0
  44. package/src/init-command.ts +709 -0
  45. package/src/install-mcp-command.test.ts +216 -0
  46. package/src/install-mcp-command.ts +185 -0
  47. package/src/install-rules-command.test.ts +218 -0
  48. package/src/install-rules-command.ts +233 -0
  49. package/src/install-skills-command.test.ts +176 -0
  50. package/src/install-skills-command.ts +124 -0
  51. package/src/mcp-server.test.ts +162 -0
  52. package/src/mcp-server.ts +231 -0
  53. package/src/processor.ts +104 -113
  54. package/src/queue.test.ts +465 -0
  55. package/src/queue.ts +34 -252
  56. package/src/supervisor.test.ts +340 -0
  57. package/src/supervisor.ts +177 -0
  58. package/src/types.ts +476 -12
  59. package/LICENSE +0 -21
package/src/queue.ts CHANGED
@@ -16,10 +16,9 @@ import {
16
16
  JobEventType,
17
17
  TagQueryMode,
18
18
  WaitpointRecord,
19
+ AddJobOptions,
19
20
  } from './types.js';
20
21
  import { PostgresBackend } from './backends/postgres.js';
21
- import { randomUUID } from 'crypto';
22
- import { log } from './log-context.js';
23
22
 
24
23
  /* Thin wrappers — every function creates a lightweight backend wrapper
25
24
  around the given pool and forwards the call. The class itself holds
@@ -36,7 +35,14 @@ export const recordJobEvent = async (
36
35
  export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
37
36
  pool: Pool,
38
37
  job: JobOptions<PayloadMap, T>,
39
- ): Promise<number> => new PostgresBackend(pool).addJob(job);
38
+ options?: AddJobOptions,
39
+ ): Promise<number> => new PostgresBackend(pool).addJob(job, options);
40
+
41
+ export const addJobs = async <PayloadMap, T extends keyof PayloadMap & string>(
42
+ pool: Pool,
43
+ jobs: JobOptions<PayloadMap, T>[],
44
+ options?: AddJobOptions,
45
+ ): Promise<number[]> => new PostgresBackend(pool).addJobs(jobs, options);
40
46
 
41
47
  export const getJob = async <PayloadMap, T extends keyof PayloadMap & string>(
42
48
  pool: Pool,
@@ -94,7 +100,9 @@ export const retryJob = async (pool: Pool, jobId: number): Promise<void> =>
94
100
  export const cleanupOldJobs = async (
95
101
  pool: Pool,
96
102
  daysToKeep = 30,
97
- ): Promise<number> => new PostgresBackend(pool).cleanupOldJobs(daysToKeep);
103
+ batchSize = 1000,
104
+ ): Promise<number> =>
105
+ new PostgresBackend(pool).cleanupOldJobs(daysToKeep, batchSize);
98
106
 
99
107
  export const cancelJob = async (pool: Pool, jobId: number): Promise<void> =>
100
108
  new PostgresBackend(pool).cancelJob(jobId);
@@ -109,6 +117,9 @@ export const editJob = async <PayloadMap, T extends keyof PayloadMap & string>(
109
117
  runAt?: Date | null;
110
118
  timeoutMs?: number | null;
111
119
  tags?: string[] | null;
120
+ retryDelay?: number | null;
121
+ retryBackoff?: boolean | null;
122
+ retryDelayMax?: number | null;
112
123
  },
113
124
  ): Promise<void> => new PostgresBackend(pool).editJob(jobId, updates);
114
125
 
@@ -134,6 +145,9 @@ export const editAllPendingJobs = async <
134
145
  runAt?: Date | null;
135
146
  timeoutMs?: number;
136
147
  tags?: string[];
148
+ retryDelay?: number | null;
149
+ retryBackoff?: boolean | null;
150
+ retryDelayMax?: number | null;
137
151
  },
138
152
  ): Promise<number> =>
139
153
  new PostgresBackend(pool).editAllPendingJobs(filters, updates);
@@ -214,12 +228,9 @@ export const updateProgress = async (
214
228
  progress: number,
215
229
  ): Promise<void> => new PostgresBackend(pool).updateProgress(jobId, progress);
216
230
 
217
- // ── Wait support functions (PostgreSQL-only) ─────────────────────────────────
231
+ // ── Wait support functions (backward-compatible delegates) ────────────────────
218
232
 
219
- /**
220
- * Transition a job to 'waiting' status with wait_until and/or wait_token_id.
221
- * Saves step_data so the handler can resume from where it left off.
222
- */
233
+ /** @deprecated Use backend.waitJob() directly. Delegates to PostgresBackend. */
223
234
  export const waitJob = async (
224
235
  pool: Pool,
225
236
  jobId: number,
@@ -228,266 +239,37 @@ export const waitJob = async (
228
239
  waitTokenId?: string;
229
240
  stepData: Record<string, any>;
230
241
  },
231
- ): Promise<void> => {
232
- const client = await pool.connect();
233
- try {
234
- const result = await client.query(
235
- `
236
- UPDATE job_queue
237
- SET status = 'waiting',
238
- wait_until = $2,
239
- wait_token_id = $3,
240
- step_data = $4,
241
- locked_at = NULL,
242
- locked_by = NULL,
243
- updated_at = NOW()
244
- WHERE id = $1 AND status = 'processing'
245
- `,
246
- [
247
- jobId,
248
- options.waitUntil ?? null,
249
- options.waitTokenId ?? null,
250
- JSON.stringify(options.stepData),
251
- ],
252
- );
253
- if (result.rowCount === 0) {
254
- log(
255
- `Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`,
256
- );
257
- return;
258
- }
259
- await recordJobEvent(pool, jobId, JobEventType.Waiting, {
260
- waitUntil: options.waitUntil?.toISOString() ?? null,
261
- waitTokenId: options.waitTokenId ?? null,
262
- });
263
- log(`Job ${jobId} set to waiting`);
264
- } catch (error) {
265
- log(`Error setting job ${jobId} to waiting: ${error}`);
266
- throw error;
267
- } finally {
268
- client.release();
269
- }
270
- };
242
+ ): Promise<void> => new PostgresBackend(pool).waitJob(jobId, options);
271
243
 
272
- /**
273
- * Update step_data for a job. Called after each ctx.run() step completes
274
- * to persist intermediate progress.
275
- */
244
+ /** @deprecated Use backend.updateStepData() directly. Delegates to PostgresBackend. */
276
245
  export const updateStepData = async (
277
246
  pool: Pool,
278
247
  jobId: number,
279
248
  stepData: Record<string, any>,
280
- ): Promise<void> => {
281
- const client = await pool.connect();
282
- try {
283
- await client.query(
284
- `UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
285
- [jobId, JSON.stringify(stepData)],
286
- );
287
- } catch (error) {
288
- log(`Error updating step_data for job ${jobId}: ${error}`);
289
- // Best-effort: do not throw to avoid killing the running handler
290
- } finally {
291
- client.release();
292
- }
293
- };
249
+ ): Promise<void> => new PostgresBackend(pool).updateStepData(jobId, stepData);
294
250
 
295
- /**
296
- * Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds.
297
- */
298
- /**
299
- * Maximum allowed timeout in milliseconds (~365 days).
300
- * Prevents overflow to Infinity when computing Date offsets.
301
- */
302
- const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
303
-
304
- function parseTimeoutString(timeout: string): number {
305
- const match = timeout.match(/^(\d+)(s|m|h|d)$/);
306
- if (!match) {
307
- throw new Error(
308
- `Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`,
309
- );
310
- }
311
- const value = parseInt(match[1], 10);
312
- const unit = match[2];
313
- let ms: number;
314
- switch (unit) {
315
- case 's':
316
- ms = value * 1000;
317
- break;
318
- case 'm':
319
- ms = value * 60 * 1000;
320
- break;
321
- case 'h':
322
- ms = value * 60 * 60 * 1000;
323
- break;
324
- case 'd':
325
- ms = value * 24 * 60 * 60 * 1000;
326
- break;
327
- default:
328
- throw new Error(`Unknown timeout unit: "${unit}"`);
329
- }
330
- if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
331
- throw new Error(
332
- `Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`,
333
- );
334
- }
335
- return ms;
336
- }
337
-
338
- /**
339
- * Create a waitpoint token in the database.
340
- * The token can be used to pause a job until an external signal completes it.
341
- *
342
- * @param pool - The database pool
343
- * @param jobId - The job ID to associate with the token (null if created outside a handler)
344
- * @param options - Optional timeout and tags
345
- * @returns The created waitpoint token
346
- */
251
+ /** @deprecated Use backend.createWaitpoint() directly. Delegates to PostgresBackend. */
347
252
  export const createWaitpoint = async (
348
253
  pool: Pool,
349
254
  jobId: number | null,
350
255
  options?: { timeout?: string; tags?: string[] },
351
- ): Promise<{ id: string }> => {
352
- const client = await pool.connect();
353
- try {
354
- const id = `wp_${randomUUID()}`;
355
- let timeoutAt: Date | null = null;
356
-
357
- if (options?.timeout) {
358
- const ms = parseTimeoutString(options.timeout);
359
- timeoutAt = new Date(Date.now() + ms);
360
- }
256
+ ): Promise<{ id: string }> =>
257
+ new PostgresBackend(pool).createWaitpoint(jobId, options);
361
258
 
362
- await client.query(
363
- `INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
364
- [id, jobId, timeoutAt, options?.tags ?? null],
365
- );
366
-
367
- log(`Created waitpoint ${id} for job ${jobId}`);
368
- return { id };
369
- } catch (error) {
370
- log(`Error creating waitpoint: ${error}`);
371
- throw error;
372
- } finally {
373
- client.release();
374
- }
375
- };
376
-
377
- /**
378
- * Complete a waitpoint token, optionally providing output data.
379
- * This also moves the associated job from 'waiting' back to 'pending' so
380
- * it gets picked up by the polling loop.
381
- */
259
+ /** @deprecated Use backend.completeWaitpoint() directly. Delegates to PostgresBackend. */
382
260
  export const completeWaitpoint = async (
383
261
  pool: Pool,
384
262
  tokenId: string,
385
263
  data?: any,
386
- ): Promise<void> => {
387
- const client = await pool.connect();
388
- try {
389
- await client.query('BEGIN');
390
-
391
- // Update the waitpoint
392
- const wpResult = await client.query(
393
- `UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
394
- WHERE id = $1 AND status = 'waiting'
395
- RETURNING job_id`,
396
- [tokenId, data != null ? JSON.stringify(data) : null],
397
- );
398
-
399
- if (wpResult.rows.length === 0) {
400
- await client.query('ROLLBACK');
401
- log(`Waitpoint ${tokenId} not found or already completed`);
402
- return;
403
- }
404
-
405
- const jobId = wpResult.rows[0].job_id;
406
-
407
- // Move the associated job back to 'pending' so it gets picked up
408
- if (jobId != null) {
409
- await client.query(
410
- `UPDATE job_queue
411
- SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
412
- WHERE id = $1 AND status = 'waiting'`,
413
- [jobId],
414
- );
415
- }
416
-
417
- await client.query('COMMIT');
418
- log(`Completed waitpoint ${tokenId} for job ${jobId}`);
419
- } catch (error) {
420
- await client.query('ROLLBACK');
421
- log(`Error completing waitpoint ${tokenId}: ${error}`);
422
- throw error;
423
- } finally {
424
- client.release();
425
- }
426
- };
264
+ ): Promise<void> => new PostgresBackend(pool).completeWaitpoint(tokenId, data);
427
265
 
428
- /**
429
- * Retrieve a waitpoint token by its ID.
430
- */
266
+ /** @deprecated Use backend.getWaitpoint() directly. Delegates to PostgresBackend. */
431
267
  export const getWaitpoint = async (
432
268
  pool: Pool,
433
269
  tokenId: string,
434
- ): Promise<WaitpointRecord | null> => {
435
- const client = await pool.connect();
436
- try {
437
- const result = await client.query(
438
- `SELECT id, job_id AS "jobId", status, output, timeout_at AS "timeoutAt", created_at AS "createdAt", completed_at AS "completedAt", tags FROM waitpoints WHERE id = $1`,
439
- [tokenId],
440
- );
441
- if (result.rows.length === 0) return null;
442
- return result.rows[0] as WaitpointRecord;
443
- } catch (error) {
444
- log(`Error getting waitpoint ${tokenId}: ${error}`);
445
- throw error;
446
- } finally {
447
- client.release();
448
- }
449
- };
450
-
451
- /**
452
- * Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
453
- * Should be called periodically (e.g., alongside reclaimStuckJobs).
454
- */
455
- export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> => {
456
- const client = await pool.connect();
457
- try {
458
- await client.query('BEGIN');
459
-
460
- // Find and expire timed-out waitpoints
461
- const result = await client.query(
462
- `UPDATE waitpoints
463
- SET status = 'timed_out'
464
- WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
465
- RETURNING id, job_id`,
466
- );
467
-
468
- // Move associated jobs back to 'pending'
469
- for (const row of result.rows) {
470
- if (row.job_id != null) {
471
- await client.query(
472
- `UPDATE job_queue
473
- SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
474
- WHERE id = $1 AND status = 'waiting'`,
475
- [row.job_id],
476
- );
477
- }
478
- }
270
+ ): Promise<WaitpointRecord | null> =>
271
+ new PostgresBackend(pool).getWaitpoint(tokenId);
479
272
 
480
- await client.query('COMMIT');
481
- const count = result.rowCount || 0;
482
- if (count > 0) {
483
- log(`Expired ${count} timed-out waitpoints`);
484
- }
485
- return count;
486
- } catch (error) {
487
- await client.query('ROLLBACK');
488
- log(`Error expiring timed-out waitpoints: ${error}`);
489
- throw error;
490
- } finally {
491
- client.release();
492
- }
493
- };
273
+ /** @deprecated Use backend.expireTimedOutWaitpoints() directly. Delegates to PostgresBackend. */
274
+ export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> =>
275
+ new PostgresBackend(pool).expireTimedOutWaitpoints();
@@ -0,0 +1,340 @@
1
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
2
+ import { createSupervisor } from './supervisor.js';
3
+ import type { QueueBackend } from './backend.js';
4
+ import type { SupervisorOptions } from './types.js';
5
+
6
+ /**
7
+ * Builds a fake {@link QueueBackend} with only the methods the supervisor
8
+ * calls, each backed by a vi.fn() that resolves to 0 by default.
9
+ */
10
+ function createFakeBackend(overrides: Partial<QueueBackend> = {}) {
11
+ return {
12
+ reclaimStuckJobs: vi.fn().mockResolvedValue(0),
13
+ cleanupOldJobs: vi.fn().mockResolvedValue(0),
14
+ cleanupOldJobEvents: vi.fn().mockResolvedValue(0),
15
+ expireTimedOutWaitpoints: vi.fn().mockResolvedValue(0),
16
+ ...overrides,
17
+ } as unknown as QueueBackend;
18
+ }
19
+
20
+ describe('createSupervisor', () => {
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ vi.useRealTimers();
24
+ });
25
+
26
+ describe('start (one-shot)', () => {
27
+ it('runs all maintenance tasks and returns results', async () => {
28
+ // Setup
29
+ const backend = createFakeBackend({
30
+ reclaimStuckJobs: vi.fn().mockResolvedValue(3),
31
+ cleanupOldJobs: vi.fn().mockResolvedValue(15),
32
+ cleanupOldJobEvents: vi.fn().mockResolvedValue(7),
33
+ expireTimedOutWaitpoints: vi.fn().mockResolvedValue(2),
34
+ });
35
+ const supervisor = createSupervisor(backend);
36
+
37
+ // Act
38
+ const result = await supervisor.start();
39
+
40
+ // Assert
41
+ expect(result).toEqual({
42
+ reclaimedJobs: 3,
43
+ cleanedUpJobs: 15,
44
+ cleanedUpEvents: 7,
45
+ expiredTokens: 2,
46
+ });
47
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledWith(10);
48
+ expect(backend.cleanupOldJobs).toHaveBeenCalledWith(30, 1000);
49
+ expect(backend.cleanupOldJobEvents).toHaveBeenCalledWith(30, 1000);
50
+ expect(backend.expireTimedOutWaitpoints).toHaveBeenCalledOnce();
51
+ });
52
+
53
+ it('passes custom option values to backend methods', async () => {
54
+ // Setup
55
+ const backend = createFakeBackend();
56
+ const supervisor = createSupervisor(backend, {
57
+ stuckJobsTimeoutMinutes: 20,
58
+ cleanupJobsDaysToKeep: 60,
59
+ cleanupEventsDaysToKeep: 14,
60
+ cleanupBatchSize: 500,
61
+ });
62
+
63
+ // Act
64
+ await supervisor.start();
65
+
66
+ // Assert
67
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledWith(20);
68
+ expect(backend.cleanupOldJobs).toHaveBeenCalledWith(60, 500);
69
+ expect(backend.cleanupOldJobEvents).toHaveBeenCalledWith(14, 500);
70
+ });
71
+
72
+ it('skips reclaimStuckJobs when disabled', async () => {
73
+ // Setup
74
+ const backend = createFakeBackend();
75
+ const supervisor = createSupervisor(backend, {
76
+ reclaimStuckJobs: false,
77
+ });
78
+
79
+ // Act
80
+ const result = await supervisor.start();
81
+
82
+ // Assert
83
+ expect(backend.reclaimStuckJobs).not.toHaveBeenCalled();
84
+ expect(result.reclaimedJobs).toBe(0);
85
+ expect(backend.cleanupOldJobs).toHaveBeenCalledOnce();
86
+ });
87
+
88
+ it('skips cleanupOldJobs when cleanupJobsDaysToKeep is 0', async () => {
89
+ // Setup
90
+ const backend = createFakeBackend();
91
+ const supervisor = createSupervisor(backend, {
92
+ cleanupJobsDaysToKeep: 0,
93
+ });
94
+
95
+ // Act
96
+ const result = await supervisor.start();
97
+
98
+ // Assert
99
+ expect(backend.cleanupOldJobs).not.toHaveBeenCalled();
100
+ expect(result.cleanedUpJobs).toBe(0);
101
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledOnce();
102
+ });
103
+
104
+ it('skips cleanupOldJobEvents when cleanupEventsDaysToKeep is 0', async () => {
105
+ // Setup
106
+ const backend = createFakeBackend();
107
+ const supervisor = createSupervisor(backend, {
108
+ cleanupEventsDaysToKeep: 0,
109
+ });
110
+
111
+ // Act
112
+ const result = await supervisor.start();
113
+
114
+ // Assert
115
+ expect(backend.cleanupOldJobEvents).not.toHaveBeenCalled();
116
+ expect(result.cleanedUpEvents).toBe(0);
117
+ });
118
+
119
+ it('skips expireTimedOutTokens when disabled', async () => {
120
+ // Setup
121
+ const backend = createFakeBackend();
122
+ const supervisor = createSupervisor(backend, {
123
+ expireTimedOutTokens: false,
124
+ });
125
+
126
+ // Act
127
+ const result = await supervisor.start();
128
+
129
+ // Assert
130
+ expect(backend.expireTimedOutWaitpoints).not.toHaveBeenCalled();
131
+ expect(result.expiredTokens).toBe(0);
132
+ });
133
+
134
+ it('calls onError and continues when a task throws', async () => {
135
+ // Setup
136
+ const onError = vi.fn();
137
+ const taskError = new Error('reclaim failed');
138
+ const backend = createFakeBackend({
139
+ reclaimStuckJobs: vi.fn().mockRejectedValue(taskError),
140
+ cleanupOldJobs: vi.fn().mockResolvedValue(5),
141
+ cleanupOldJobEvents: vi
142
+ .fn()
143
+ .mockRejectedValue(new Error('events boom')),
144
+ expireTimedOutWaitpoints: vi.fn().mockResolvedValue(1),
145
+ });
146
+ const supervisor = createSupervisor(backend, { onError });
147
+
148
+ // Act
149
+ const result = await supervisor.start();
150
+
151
+ // Assert
152
+ expect(onError).toHaveBeenCalledTimes(2);
153
+ expect(onError).toHaveBeenCalledWith(taskError);
154
+ expect(result.reclaimedJobs).toBe(0);
155
+ expect(result.cleanedUpJobs).toBe(5);
156
+ expect(result.cleanedUpEvents).toBe(0);
157
+ expect(result.expiredTokens).toBe(1);
158
+ });
159
+
160
+ it('wraps non-Error throws in an Error object', async () => {
161
+ // Setup
162
+ const onError = vi.fn();
163
+ const backend = createFakeBackend({
164
+ reclaimStuckJobs: vi.fn().mockRejectedValue('string error'),
165
+ });
166
+ const supervisor = createSupervisor(backend, { onError });
167
+
168
+ // Act
169
+ await supervisor.start();
170
+
171
+ // Assert
172
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
173
+ expect(onError.mock.calls[0][0].message).toBe('string error');
174
+ });
175
+ });
176
+
177
+ describe('startInBackground / stop', () => {
178
+ it('polls on interval and can be stopped', async () => {
179
+ // Setup
180
+ vi.useFakeTimers();
181
+ const backend = createFakeBackend();
182
+ const supervisor = createSupervisor(backend, { intervalMs: 1000 });
183
+
184
+ // Act
185
+ supervisor.startInBackground();
186
+ expect(supervisor.isRunning()).toBe(true);
187
+
188
+ // First run is immediate (microtask)
189
+ await vi.advanceTimersByTimeAsync(0);
190
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(1);
191
+
192
+ // Advance to trigger second run
193
+ await vi.advanceTimersByTimeAsync(1000);
194
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(2);
195
+
196
+ // Advance to trigger third run
197
+ await vi.advanceTimersByTimeAsync(1000);
198
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(3);
199
+
200
+ // Stop
201
+ supervisor.stop();
202
+ expect(supervisor.isRunning()).toBe(false);
203
+
204
+ // No more runs after stop
205
+ await vi.advanceTimersByTimeAsync(5000);
206
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(3);
207
+ });
208
+
209
+ it('does nothing when called while already running', async () => {
210
+ // Setup
211
+ vi.useFakeTimers();
212
+ const backend = createFakeBackend();
213
+ const supervisor = createSupervisor(backend, { intervalMs: 1000 });
214
+
215
+ // Act
216
+ supervisor.startInBackground();
217
+ supervisor.startInBackground(); // second call should be ignored
218
+ await vi.advanceTimersByTimeAsync(0);
219
+
220
+ // Assert -- only one loop running, single call
221
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(1);
222
+
223
+ supervisor.stop();
224
+ });
225
+ });
226
+
227
+ describe('stopAndDrain', () => {
228
+ it('waits for current maintenance run to finish', async () => {
229
+ // Setup
230
+ vi.useFakeTimers();
231
+ let resolveTask!: () => void;
232
+ const slowTask = new Promise<number>((resolve) => {
233
+ resolveTask = () => resolve(2);
234
+ });
235
+ const backend = createFakeBackend({
236
+ reclaimStuckJobs: vi.fn().mockReturnValue(slowTask),
237
+ });
238
+ const supervisor = createSupervisor(backend, { intervalMs: 1000 });
239
+
240
+ // Act
241
+ supervisor.startInBackground();
242
+ // Let the loop start (but reclaimStuckJobs is blocked)
243
+ await vi.advanceTimersByTimeAsync(0);
244
+
245
+ let drained = false;
246
+ const drainPromise = supervisor.stopAndDrain().then(() => {
247
+ drained = true;
248
+ });
249
+
250
+ // Assert -- not drained yet because slowTask is still pending
251
+ expect(drained).toBe(false);
252
+ expect(supervisor.isRunning()).toBe(false);
253
+
254
+ // Resolve the slow task
255
+ resolveTask();
256
+ await drainPromise;
257
+
258
+ // Assert -- now drained
259
+ expect(drained).toBe(true);
260
+ });
261
+
262
+ it('resolves immediately when no maintenance run is in progress', async () => {
263
+ // Setup
264
+ const backend = createFakeBackend();
265
+ const supervisor = createSupervisor(backend);
266
+
267
+ // Act & Assert
268
+ await expect(supervisor.stopAndDrain()).resolves.toBeUndefined();
269
+ });
270
+
271
+ it('resolves after timeout if maintenance run hangs', async () => {
272
+ // Setup
273
+ vi.useFakeTimers();
274
+ const neverResolve = new Promise<number>(() => {});
275
+ const backend = createFakeBackend({
276
+ reclaimStuckJobs: vi.fn().mockReturnValue(neverResolve),
277
+ });
278
+ const supervisor = createSupervisor(backend, { intervalMs: 5000 });
279
+
280
+ // Act
281
+ supervisor.startInBackground();
282
+ await vi.advanceTimersByTimeAsync(0);
283
+
284
+ let drained = false;
285
+ const drainPromise = supervisor.stopAndDrain(500).then(() => {
286
+ drained = true;
287
+ });
288
+
289
+ // Assert -- not drained yet
290
+ expect(drained).toBe(false);
291
+
292
+ // Advance past the drain timeout
293
+ await vi.advanceTimersByTimeAsync(500);
294
+ await drainPromise;
295
+
296
+ // Assert -- drained by timeout
297
+ expect(drained).toBe(true);
298
+ });
299
+ });
300
+
301
+ describe('isRunning', () => {
302
+ it('returns false before start', () => {
303
+ // Setup
304
+ const backend = createFakeBackend();
305
+ const supervisor = createSupervisor(backend);
306
+
307
+ // Assert
308
+ expect(supervisor.isRunning()).toBe(false);
309
+ });
310
+
311
+ it('returns true after startInBackground', async () => {
312
+ // Setup
313
+ vi.useFakeTimers();
314
+ const backend = createFakeBackend();
315
+ const supervisor = createSupervisor(backend);
316
+
317
+ // Act
318
+ supervisor.startInBackground();
319
+
320
+ // Assert
321
+ expect(supervisor.isRunning()).toBe(true);
322
+
323
+ supervisor.stop();
324
+ });
325
+
326
+ it('returns false after stop', async () => {
327
+ // Setup
328
+ vi.useFakeTimers();
329
+ const backend = createFakeBackend();
330
+ const supervisor = createSupervisor(backend);
331
+
332
+ // Act
333
+ supervisor.startInBackground();
334
+ supervisor.stop();
335
+
336
+ // Assert
337
+ expect(supervisor.isRunning()).toBe(false);
338
+ });
339
+ });
340
+ });