digital-tasks 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +560 -0
  3. package/package.json +29 -14
  4. package/src/client.ts +268 -0
  5. package/src/index.ts +12 -10
  6. package/src/markdown.ts +63 -48
  7. package/src/project.ts +57 -42
  8. package/src/queue.ts +76 -37
  9. package/src/task.ts +132 -75
  10. package/src/types.ts +177 -40
  11. package/src/worker.ts +959 -0
  12. package/test/project.test.ts +28 -84
  13. package/test/queue.test.ts +51 -24
  14. package/test/task.test.ts +80 -27
  15. package/test/worker.test.ts +1158 -0
  16. package/tsconfig.json +2 -13
  17. package/vitest.config.ts +48 -0
  18. package/wrangler.jsonc +44 -0
  19. package/.turbo/turbo-build.log +0 -4
  20. package/LICENSE +0 -21
  21. package/dist/function-task.d.ts +0 -319
  22. package/dist/function-task.d.ts.map +0 -1
  23. package/dist/function-task.js +0 -286
  24. package/dist/function-task.js.map +0 -1
  25. package/dist/index.d.ts +0 -72
  26. package/dist/index.d.ts.map +0 -1
  27. package/dist/index.js +0 -74
  28. package/dist/index.js.map +0 -1
  29. package/dist/markdown.d.ts +0 -112
  30. package/dist/markdown.d.ts.map +0 -1
  31. package/dist/markdown.js +0 -510
  32. package/dist/markdown.js.map +0 -1
  33. package/dist/project.d.ts +0 -259
  34. package/dist/project.d.ts.map +0 -1
  35. package/dist/project.js +0 -397
  36. package/dist/project.js.map +0 -1
  37. package/dist/queue.d.ts +0 -17
  38. package/dist/queue.d.ts.map +0 -1
  39. package/dist/queue.js +0 -347
  40. package/dist/queue.js.map +0 -1
  41. package/dist/task.d.ts +0 -69
  42. package/dist/task.d.ts.map +0 -1
  43. package/dist/task.js +0 -321
  44. package/dist/task.js.map +0 -1
  45. package/dist/types.d.ts +0 -292
  46. package/dist/types.d.ts.map +0 -1
  47. package/dist/types.js +0 -15
  48. package/dist/types.js.map +0 -1
  49. package/src/index.js +0 -73
  50. package/src/markdown.js +0 -509
  51. package/src/project.js +0 -396
  52. package/src/queue.js +0 -346
  53. package/src/task.js +0 -320
  54. package/src/types.js +0 -14
@@ -0,0 +1,1158 @@
1
+ /**
2
+ * Worker Export Tests for digital-tasks (RED phase)
3
+ *
4
+ * Tests for the /worker export that provides TaskService (WorkerEntrypoint)
5
+ * with a connect() method that returns TaskServiceCore (RpcTarget).
6
+ *
7
+ * Uses @cloudflare/vitest-pool-workers for real Cloudflare Workers execution.
8
+ * NO MOCKS - tests use real Durable Objects for task persistence and Queues.
9
+ *
10
+ * These tests should FAIL initially because src/worker.ts doesn't exist yet.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach } from 'vitest'
16
+ import { env } from 'cloudflare:test'
17
+
18
+ // These imports will FAIL because worker.ts doesn't exist yet
19
+ import { TaskService, TaskServiceCore } from '../src/worker.js'
20
+
21
+ // Types for test assertions
22
+ interface TaskData<TInput = unknown, TOutput = unknown> {
23
+ id: string
24
+ status: string
25
+ priority: string
26
+ input?: TInput
27
+ output?: TOutput
28
+ error?: string
29
+ scheduledFor?: Date
30
+ deadline?: Date
31
+ createdAt: Date
32
+ startedAt?: Date
33
+ completedAt?: Date
34
+ progress?: {
35
+ percent: number
36
+ step?: string
37
+ updatedAt: Date
38
+ }
39
+ dependencies?: Array<{
40
+ type: string
41
+ taskId: string
42
+ satisfied: boolean
43
+ }>
44
+ assignment?: {
45
+ worker: { type: string; id: string; name?: string }
46
+ assignedAt: Date
47
+ }
48
+ metadata?: Record<string, unknown>
49
+ }
50
+
51
+ interface TaskStats {
52
+ total: number
53
+ byStatus: Record<string, number>
54
+ byPriority: Record<string, number>
55
+ }
56
+
57
+ describe('TaskServiceCore (RpcTarget)', () => {
58
+ let service: TaskServiceCore
59
+
60
+ beforeEach(() => {
61
+ service = new TaskServiceCore(env)
62
+ })
63
+
64
+ describe('constructor', () => {
65
+ it('creates a new TaskServiceCore instance', () => {
66
+ expect(service).toBeInstanceOf(TaskServiceCore)
67
+ })
68
+
69
+ it('accepts env with required bindings', () => {
70
+ const serviceWithEnv = new TaskServiceCore(env)
71
+ expect(serviceWithEnv).toBeDefined()
72
+ })
73
+
74
+ it('extends RpcTarget for RPC communication', () => {
75
+ expect(service.constructor.name).toBe('TaskServiceCore')
76
+ })
77
+ })
78
+
79
+ describe('create()', () => {
80
+ it('creates a new task with minimal options', async () => {
81
+ const task = await service.create({
82
+ name: 'Test Task',
83
+ description: 'A test task for creation',
84
+ })
85
+
86
+ expect(task).toBeDefined()
87
+ expect(task.id).toBeDefined()
88
+ expect(task.id.length).toBeGreaterThan(0)
89
+ expect(task.status).toBe('pending')
90
+ expect(task.createdAt).toBeInstanceOf(Date)
91
+ })
92
+
93
+ it('creates a task with custom ID', async () => {
94
+ const task = await service.create({
95
+ id: 'custom-task-id',
96
+ name: 'Custom ID Task',
97
+ description: 'Task with custom ID',
98
+ })
99
+
100
+ expect(task.id).toBe('custom-task-id')
101
+ })
102
+
103
+ it('creates a task with priority', async () => {
104
+ const task = await service.create({
105
+ name: 'High Priority Task',
106
+ description: 'An urgent task',
107
+ priority: 'high',
108
+ })
109
+
110
+ expect(task.priority).toBe('high')
111
+ })
112
+
113
+ it('creates a task with input data', async () => {
114
+ const input = { url: 'https://example.com', method: 'GET' }
115
+ const task = await service.create({
116
+ name: 'Fetch Data',
117
+ description: 'Fetch data from URL',
118
+ input,
119
+ })
120
+
121
+ expect(task.input).toEqual(input)
122
+ })
123
+
124
+ it('creates a task with scheduled execution time', async () => {
125
+ const scheduledFor = new Date(Date.now() + 3600000) // 1 hour from now
126
+ const task = await service.create({
127
+ name: 'Scheduled Task',
128
+ description: 'Run later',
129
+ scheduledFor,
130
+ })
131
+
132
+ expect(task.scheduledFor).toEqual(scheduledFor)
133
+ expect(task.status).toBe('scheduled')
134
+ })
135
+
136
+ it('creates a task with deadline', async () => {
137
+ const deadline = new Date(Date.now() + 86400000) // 24 hours
138
+ const task = await service.create({
139
+ name: 'Task with Deadline',
140
+ description: 'Must complete in time',
141
+ deadline,
142
+ })
143
+
144
+ expect(task.deadline).toEqual(deadline)
145
+ })
146
+
147
+ it('creates a task with tags', async () => {
148
+ const task = await service.create({
149
+ name: 'Tagged Task',
150
+ description: 'Task with tags',
151
+ tags: ['important', 'review'],
152
+ })
153
+
154
+ expect(task.metadata?.tags).toContain('important')
155
+ expect(task.metadata?.tags).toContain('review')
156
+ })
157
+
158
+ it('creates a task with metadata', async () => {
159
+ const task = await service.create({
160
+ name: 'Task with Metadata',
161
+ description: 'Has extra info',
162
+ metadata: { source: 'api', version: 2 },
163
+ })
164
+
165
+ expect(task.metadata?.source).toBe('api')
166
+ expect(task.metadata?.version).toBe(2)
167
+ })
168
+
169
+ it('creates a task with dependencies', async () => {
170
+ const dep1 = await service.create({ name: 'Dependency 1', description: 'First' })
171
+ const dep2 = await service.create({ name: 'Dependency 2', description: 'Second' })
172
+
173
+ const task = await service.create({
174
+ name: 'Dependent Task',
175
+ description: 'Depends on others',
176
+ dependencies: [dep1.id, dep2.id],
177
+ })
178
+
179
+ expect(task.dependencies).toHaveLength(2)
180
+ expect(task.dependencies?.map((d) => d.taskId)).toContain(dep1.id)
181
+ expect(task.dependencies?.map((d) => d.taskId)).toContain(dep2.id)
182
+ expect(task.status).toBe('blocked')
183
+ })
184
+ })
185
+
186
+ describe('schedule()', () => {
187
+ it('schedules a task for future execution', async () => {
188
+ const task = await service.create({
189
+ name: 'Task to Schedule',
190
+ description: 'Will be scheduled',
191
+ })
192
+
193
+ const scheduledFor = new Date(Date.now() + 3600000)
194
+ const scheduled = await service.schedule(task.id, scheduledFor)
195
+
196
+ expect(scheduled.scheduledFor).toEqual(scheduledFor)
197
+ expect(scheduled.status).toBe('scheduled')
198
+ })
199
+
200
+ it('schedules with priority override', async () => {
201
+ const task = await service.create({
202
+ name: 'Schedule with Priority',
203
+ description: 'Override priority',
204
+ priority: 'normal',
205
+ })
206
+
207
+ const scheduledFor = new Date(Date.now() + 3600000)
208
+ const scheduled = await service.schedule(task.id, scheduledFor, {
209
+ priority: 'urgent',
210
+ })
211
+
212
+ expect(scheduled.priority).toBe('urgent')
213
+ })
214
+
215
+ it('throws error for non-existent task', async () => {
216
+ const scheduledFor = new Date(Date.now() + 3600000)
217
+
218
+ await expect(service.schedule('nonexistent-task', scheduledFor)).rejects.toThrow()
219
+ })
220
+
221
+ it('prevents scheduling already completed tasks', async () => {
222
+ const task = await service.create({
223
+ name: 'Completed Task',
224
+ description: 'Already done',
225
+ })
226
+ await service.execute(task.id)
227
+ await service.complete(task.id, { result: 'done' })
228
+
229
+ const scheduledFor = new Date(Date.now() + 3600000)
230
+
231
+ await expect(service.schedule(task.id, scheduledFor)).rejects.toThrow()
232
+ })
233
+
234
+ it('allows rescheduling pending tasks', async () => {
235
+ const task = await service.create({
236
+ name: 'Reschedulable Task',
237
+ description: 'Can be rescheduled',
238
+ })
239
+
240
+ const firstSchedule = new Date(Date.now() + 3600000)
241
+ await service.schedule(task.id, firstSchedule)
242
+
243
+ const secondSchedule = new Date(Date.now() + 7200000)
244
+ const rescheduled = await service.schedule(task.id, secondSchedule)
245
+
246
+ expect(rescheduled.scheduledFor).toEqual(secondSchedule)
247
+ })
248
+ })
249
+
250
+ describe('execute()', () => {
251
+ it('starts task execution', async () => {
252
+ const task = await service.create({
253
+ name: 'Task to Execute',
254
+ description: 'Will be executed',
255
+ })
256
+
257
+ const executing = await service.execute(task.id)
258
+
259
+ expect(executing.status).toBe('in_progress')
260
+ expect(executing.startedAt).toBeInstanceOf(Date)
261
+ })
262
+
263
+ it('assigns worker when executing', async () => {
264
+ const task = await service.create({
265
+ name: 'Worker Assigned Task',
266
+ description: 'Assigned to worker',
267
+ })
268
+
269
+ const worker = { type: 'agent' as const, id: 'agent_123', name: 'Test Agent' }
270
+ const executing = await service.execute(task.id, { worker })
271
+
272
+ expect(executing.assignment?.worker.id).toBe('agent_123')
273
+ expect(executing.assignment?.worker.name).toBe('Test Agent')
274
+ })
275
+
276
+ it('throws error for non-existent task', async () => {
277
+ await expect(service.execute('nonexistent-task')).rejects.toThrow()
278
+ })
279
+
280
+ it('prevents executing already in-progress tasks', async () => {
281
+ const task = await service.create({
282
+ name: 'Already Running',
283
+ description: 'In progress',
284
+ })
285
+
286
+ await service.execute(task.id)
287
+
288
+ await expect(service.execute(task.id)).rejects.toThrow()
289
+ })
290
+
291
+ it('prevents executing completed tasks', async () => {
292
+ const task = await service.create({
293
+ name: 'Completed Task',
294
+ description: 'Done',
295
+ })
296
+ await service.execute(task.id)
297
+ await service.complete(task.id, { result: 'done' })
298
+
299
+ await expect(service.execute(task.id)).rejects.toThrow()
300
+ })
301
+
302
+ it('respects dependency blocking', async () => {
303
+ const blocker = await service.create({
304
+ name: 'Blocker Task',
305
+ description: 'Blocks other',
306
+ })
307
+
308
+ const blocked = await service.create({
309
+ name: 'Blocked Task',
310
+ description: 'Depends on blocker',
311
+ dependencies: [blocker.id],
312
+ })
313
+
314
+ await expect(service.execute(blocked.id)).rejects.toThrow(/blocked/)
315
+ })
316
+
317
+ it('executes unblocked tasks after dependency completion', async () => {
318
+ const blocker = await service.create({
319
+ name: 'Blocker',
320
+ description: 'Will complete',
321
+ })
322
+
323
+ const blocked = await service.create({
324
+ name: 'Blocked',
325
+ description: 'Waiting',
326
+ dependencies: [blocker.id],
327
+ })
328
+
329
+ // Complete the blocker
330
+ await service.execute(blocker.id)
331
+ await service.complete(blocker.id, { result: 'done' })
332
+
333
+ // Now blocked should be executable
334
+ const executing = await service.execute(blocked.id)
335
+ expect(executing.status).toBe('in_progress')
336
+ })
337
+ })
338
+
339
+ describe('complete()', () => {
340
+ it('marks task as completed with output', async () => {
341
+ const task = await service.create({
342
+ name: 'Task to Complete',
343
+ description: 'Will be completed',
344
+ })
345
+ await service.execute(task.id)
346
+
347
+ const completed = await service.complete(task.id, {
348
+ result: 'success',
349
+ data: { count: 42 },
350
+ })
351
+
352
+ expect(completed.status).toBe('completed')
353
+ expect(completed.output).toEqual({
354
+ result: 'success',
355
+ data: { count: 42 },
356
+ })
357
+ expect(completed.completedAt).toBeInstanceOf(Date)
358
+ })
359
+
360
+ it('throws error for non-existent task', async () => {
361
+ await expect(service.complete('nonexistent', { result: 'done' })).rejects.toThrow()
362
+ })
363
+
364
+ it('throws error for non-executing task', async () => {
365
+ const task = await service.create({
366
+ name: 'Not Started',
367
+ description: 'Never executed',
368
+ })
369
+
370
+ await expect(service.complete(task.id, { result: 'done' })).rejects.toThrow()
371
+ })
372
+
373
+ it('unblocks dependent tasks on completion', async () => {
374
+ const blocker = await service.create({
375
+ name: 'Blocker',
376
+ description: 'Unblocks on complete',
377
+ })
378
+
379
+ const blocked = await service.create({
380
+ name: 'Blocked',
381
+ description: 'Will be unblocked',
382
+ dependencies: [blocker.id],
383
+ })
384
+
385
+ expect(blocked.status).toBe('blocked')
386
+
387
+ await service.execute(blocker.id)
388
+ await service.complete(blocker.id, { result: 'done' })
389
+
390
+ const updated = await service.getStatus(blocked.id)
391
+ expect(updated.status).not.toBe('blocked')
392
+ expect(updated.dependencies?.[0].satisfied).toBe(true)
393
+ })
394
+ })
395
+
396
+ describe('fail()', () => {
397
+ it('marks task as failed with error message', async () => {
398
+ const task = await service.create({
399
+ name: 'Task to Fail',
400
+ description: 'Will fail',
401
+ })
402
+ await service.execute(task.id)
403
+
404
+ const failed = await service.fail(task.id, 'Something went wrong')
405
+
406
+ expect(failed.status).toBe('failed')
407
+ expect(failed.error).toBe('Something went wrong')
408
+ expect(failed.completedAt).toBeInstanceOf(Date)
409
+ })
410
+
411
+ it('marks task as failed with Error object', async () => {
412
+ const task = await service.create({
413
+ name: 'Task with Error',
414
+ description: 'Will fail with Error',
415
+ })
416
+ await service.execute(task.id)
417
+
418
+ const error = new Error('Network timeout')
419
+ const failed = await service.fail(task.id, error)
420
+
421
+ expect(failed.status).toBe('failed')
422
+ expect(failed.error).toContain('Network timeout')
423
+ })
424
+
425
+ it('throws error for non-existent task', async () => {
426
+ await expect(service.fail('nonexistent', 'error')).rejects.toThrow()
427
+ })
428
+ })
429
+
430
+ describe('getStatus()', () => {
431
+ it('returns full task status', async () => {
432
+ const task = await service.create({
433
+ name: 'Status Check Task',
434
+ description: 'Check its status',
435
+ priority: 'high',
436
+ })
437
+
438
+ const status = await service.getStatus(task.id)
439
+
440
+ expect(status.id).toBe(task.id)
441
+ expect(status.status).toBe('pending')
442
+ expect(status.priority).toBe('high')
443
+ expect(status.createdAt).toBeInstanceOf(Date)
444
+ })
445
+
446
+ it('returns progress information', async () => {
447
+ const task = await service.create({
448
+ name: 'Progress Task',
449
+ description: 'Has progress',
450
+ })
451
+ await service.execute(task.id)
452
+ await service.updateProgress(task.id, 50, 'Processing data')
453
+
454
+ const status = await service.getStatus(task.id)
455
+
456
+ expect(status.progress?.percent).toBe(50)
457
+ expect(status.progress?.step).toBe('Processing data')
458
+ })
459
+
460
+ it('returns null for non-existent task', async () => {
461
+ const status = await service.getStatus('nonexistent-id')
462
+ expect(status).toBeNull()
463
+ })
464
+
465
+ it('includes dependency status', async () => {
466
+ const dep = await service.create({ name: 'Dep', description: 'Dependency' })
467
+ const task = await service.create({
468
+ name: 'Main',
469
+ description: 'Has dependency',
470
+ dependencies: [dep.id],
471
+ })
472
+
473
+ const status = await service.getStatus(task.id)
474
+
475
+ expect(status.dependencies).toHaveLength(1)
476
+ expect(status.dependencies?.[0].taskId).toBe(dep.id)
477
+ expect(status.dependencies?.[0].satisfied).toBe(false)
478
+ })
479
+ })
480
+
481
+ describe('updateProgress()', () => {
482
+ it('updates task progress percentage', async () => {
483
+ const task = await service.create({
484
+ name: 'Progress Update Task',
485
+ description: 'Track progress',
486
+ })
487
+ await service.execute(task.id)
488
+
489
+ const updated = await service.updateProgress(task.id, 25)
490
+
491
+ expect(updated.progress?.percent).toBe(25)
492
+ expect(updated.progress?.updatedAt).toBeInstanceOf(Date)
493
+ })
494
+
495
+ it('updates progress with step description', async () => {
496
+ const task = await service.create({
497
+ name: 'Step Progress Task',
498
+ description: 'With step info',
499
+ })
500
+ await service.execute(task.id)
501
+
502
+ const updated = await service.updateProgress(task.id, 50, 'Downloading files')
503
+
504
+ expect(updated.progress?.percent).toBe(50)
505
+ expect(updated.progress?.step).toBe('Downloading files')
506
+ })
507
+
508
+ it('throws error for non-executing task', async () => {
509
+ const task = await service.create({
510
+ name: 'Not Started',
511
+ description: 'Cannot update progress',
512
+ })
513
+
514
+ await expect(service.updateProgress(task.id, 50)).rejects.toThrow()
515
+ })
516
+
517
+ it('validates progress percentage range', async () => {
518
+ const task = await service.create({
519
+ name: 'Invalid Progress',
520
+ description: 'Bad percentage',
521
+ })
522
+ await service.execute(task.id)
523
+
524
+ await expect(service.updateProgress(task.id, 150)).rejects.toThrow()
525
+
526
+ await expect(service.updateProgress(task.id, -10)).rejects.toThrow()
527
+ })
528
+ })
529
+
530
+ describe('cancel()', () => {
531
+ it('cancels a pending task', async () => {
532
+ const task = await service.create({
533
+ name: 'Task to Cancel',
534
+ description: 'Will be cancelled',
535
+ })
536
+
537
+ const cancelled = await service.cancel(task.id)
538
+
539
+ expect(cancelled).toBe(true)
540
+
541
+ const status = await service.getStatus(task.id)
542
+ expect(status?.status).toBe('cancelled')
543
+ })
544
+
545
+ it('cancels a task with reason', async () => {
546
+ const task = await service.create({
547
+ name: 'Task with Reason',
548
+ description: 'Cancelled with reason',
549
+ })
550
+
551
+ await service.cancel(task.id, 'No longer needed')
552
+
553
+ const status = await service.getStatus(task.id)
554
+ expect(status?.metadata?.cancellationReason).toBe('No longer needed')
555
+ })
556
+
557
+ it('cancels an in-progress task', async () => {
558
+ const task = await service.create({
559
+ name: 'Running Task',
560
+ description: 'Will be cancelled while running',
561
+ })
562
+ await service.execute(task.id)
563
+
564
+ const cancelled = await service.cancel(task.id)
565
+ expect(cancelled).toBe(true)
566
+
567
+ const status = await service.getStatus(task.id)
568
+ expect(status?.status).toBe('cancelled')
569
+ })
570
+
571
+ it('returns false for non-existent task', async () => {
572
+ const cancelled = await service.cancel('nonexistent-id')
573
+ expect(cancelled).toBe(false)
574
+ })
575
+
576
+ it('cannot cancel completed tasks', async () => {
577
+ const task = await service.create({
578
+ name: 'Completed Task',
579
+ description: 'Already done',
580
+ })
581
+ await service.execute(task.id)
582
+ await service.complete(task.id, { result: 'done' })
583
+
584
+ const cancelled = await service.cancel(task.id)
585
+ expect(cancelled).toBe(false)
586
+ })
587
+ })
588
+
589
+ describe('list()', () => {
590
+ beforeEach(async () => {
591
+ // Create some test tasks
592
+ await service.create({ name: 'Task A', description: 'First', priority: 'high' })
593
+ await service.create({ name: 'Task B', description: 'Second', priority: 'normal' })
594
+ await service.create({ name: 'Task C', description: 'Third', priority: 'low' })
595
+ })
596
+
597
+ it('lists all tasks', async () => {
598
+ const tasks = await service.list()
599
+
600
+ expect(tasks.length).toBeGreaterThanOrEqual(3)
601
+ expect(
602
+ tasks.some((t) => t.id.includes('Task A') || (t.metadata as any)?.name === 'Task A')
603
+ ).toBe(true)
604
+ })
605
+
606
+ it('filters by status', async () => {
607
+ const task = await service.create({ name: 'Running Task', description: 'In progress' })
608
+ await service.execute(task.id)
609
+
610
+ const inProgress = await service.list({ status: 'in_progress' })
611
+
612
+ expect(inProgress.length).toBeGreaterThanOrEqual(1)
613
+ expect(inProgress.every((t) => t.status === 'in_progress')).toBe(true)
614
+ })
615
+
616
+ it('filters by priority', async () => {
617
+ const highPriority = await service.list({ priority: 'high' })
618
+
619
+ expect(highPriority.length).toBeGreaterThanOrEqual(1)
620
+ expect(highPriority.every((t) => t.priority === 'high')).toBe(true)
621
+ })
622
+
623
+ it('filters by multiple statuses', async () => {
624
+ const task1 = await service.create({ name: 'Pending', description: 'Pending' })
625
+ const task2 = await service.create({ name: 'Running', description: 'Running' })
626
+ await service.execute(task2.id)
627
+
628
+ const filtered = await service.list({ status: ['pending', 'in_progress'] })
629
+
630
+ expect(filtered.length).toBeGreaterThanOrEqual(2)
631
+ expect(filtered.every((t) => ['pending', 'in_progress'].includes(t.status))).toBe(true)
632
+ })
633
+
634
+ it('supports pagination with limit', async () => {
635
+ const limited = await service.list({ limit: 2 })
636
+
637
+ expect(limited).toHaveLength(2)
638
+ })
639
+
640
+ it('supports pagination with offset', async () => {
641
+ const all = await service.list()
642
+ const offset = await service.list({ offset: 1, limit: 2 })
643
+
644
+ expect(offset).toHaveLength(2)
645
+ expect(offset[0].id).toBe(all[1].id)
646
+ })
647
+
648
+ it('sorts by createdAt', async () => {
649
+ const sorted = await service.list({ sortBy: 'createdAt', sortOrder: 'asc' })
650
+
651
+ for (let i = 1; i < sorted.length; i++) {
652
+ expect(sorted[i].createdAt.getTime()).toBeGreaterThanOrEqual(
653
+ sorted[i - 1].createdAt.getTime()
654
+ )
655
+ }
656
+ })
657
+
658
+ it('sorts by priority', async () => {
659
+ const sorted = await service.list({ sortBy: 'priority', sortOrder: 'desc' })
660
+
661
+ const priorityOrder = { critical: 5, urgent: 4, high: 3, normal: 2, low: 1 }
662
+ for (let i = 1; i < sorted.length; i++) {
663
+ expect(priorityOrder[sorted[i].priority as keyof typeof priorityOrder]).toBeLessThanOrEqual(
664
+ priorityOrder[sorted[i - 1].priority as keyof typeof priorityOrder]
665
+ )
666
+ }
667
+ })
668
+
669
+ it('filters by tags', async () => {
670
+ await service.create({
671
+ name: 'Tagged Task',
672
+ description: 'Has specific tag',
673
+ tags: ['urgent', 'api'],
674
+ })
675
+
676
+ const tagged = await service.list({ tags: ['urgent'] })
677
+
678
+ expect(tagged.length).toBeGreaterThanOrEqual(1)
679
+ })
680
+
681
+ it('searches by name/description', async () => {
682
+ await service.create({ name: 'Unique Name XYZ', description: 'Searchable task' })
683
+
684
+ const results = await service.list({ search: 'XYZ' })
685
+
686
+ expect(results.length).toBeGreaterThanOrEqual(1)
687
+ })
688
+ })
689
+
690
+ describe('getStats()', () => {
691
+ it('returns task statistics', async () => {
692
+ await service.create({ name: 'Pending 1', description: 'Test' })
693
+ await service.create({ name: 'Pending 2', description: 'Test', priority: 'high' })
694
+ const running = await service.create({ name: 'Running', description: 'Test' })
695
+ await service.execute(running.id)
696
+
697
+ const stats = await service.getStats()
698
+
699
+ expect(stats.total).toBeGreaterThanOrEqual(3)
700
+ expect(stats.byStatus).toHaveProperty('pending')
701
+ expect(stats.byStatus).toHaveProperty('in_progress')
702
+ expect(stats.byPriority).toHaveProperty('normal')
703
+ expect(stats.byPriority).toHaveProperty('high')
704
+ })
705
+ })
706
+
707
+ describe('retry()', () => {
708
+ it('retries a failed task', async () => {
709
+ const task = await service.create({
710
+ name: 'Retry Task',
711
+ description: 'Will fail then retry',
712
+ })
713
+ await service.execute(task.id)
714
+ await service.fail(task.id, 'Initial failure')
715
+
716
+ const retried = await service.retry(task.id)
717
+
718
+ expect(retried.status).toBe('pending')
719
+ expect(retried.error).toBeUndefined()
720
+ expect(retried.metadata?.retryCount).toBe(1)
721
+ })
722
+
723
+ it('increments retry count on each retry', async () => {
724
+ const task = await service.create({
725
+ name: 'Multi-Retry Task',
726
+ description: 'Fails multiple times',
727
+ })
728
+
729
+ // First attempt
730
+ await service.execute(task.id)
731
+ await service.fail(task.id, 'Failure 1')
732
+ const retry1 = await service.retry(task.id)
733
+ expect(retry1.metadata?.retryCount).toBe(1)
734
+
735
+ // Second attempt
736
+ await service.execute(task.id)
737
+ await service.fail(task.id, 'Failure 2')
738
+ const retry2 = await service.retry(task.id)
739
+ expect(retry2.metadata?.retryCount).toBe(2)
740
+ })
741
+
742
+ it('throws error for non-failed task', async () => {
743
+ const task = await service.create({
744
+ name: 'Not Failed',
745
+ description: 'Cannot retry',
746
+ })
747
+
748
+ await expect(service.retry(task.id)).rejects.toThrow()
749
+ })
750
+
751
+ it('respects max retry limit', async () => {
752
+ const task = await service.create({
753
+ name: 'Limited Retries',
754
+ description: 'Max 3 retries',
755
+ metadata: { maxRetries: 3 },
756
+ })
757
+
758
+ // Fail and retry 3 times
759
+ for (let i = 0; i < 3; i++) {
760
+ await service.execute(task.id)
761
+ await service.fail(task.id, `Failure ${i + 1}`)
762
+ await service.retry(task.id)
763
+ }
764
+
765
+ // Fail one more time
766
+ await service.execute(task.id)
767
+ await service.fail(task.id, 'Final failure')
768
+
769
+ // Should not be able to retry again
770
+ await expect(service.retry(task.id)).rejects.toThrow(/max retries/)
771
+ })
772
+ })
773
+ })
774
+
775
+ describe('Task Dependencies and Workflows', () => {
776
+ let service: TaskServiceCore
777
+
778
+ beforeEach(() => {
779
+ service = new TaskServiceCore(env)
780
+ })
781
+
782
+ describe('dependency resolution', () => {
783
+ it('blocks tasks with unsatisfied dependencies', async () => {
784
+ const task1 = await service.create({ name: 'First', description: 'Runs first' })
785
+ const task2 = await service.create({
786
+ name: 'Second',
787
+ description: 'Depends on first',
788
+ dependencies: [task1.id],
789
+ })
790
+
791
+ expect(task2.status).toBe('blocked')
792
+ })
793
+
794
+ it('unblocks tasks when all dependencies complete', async () => {
795
+ const dep1 = await service.create({ name: 'Dep 1', description: 'First dep' })
796
+ const dep2 = await service.create({ name: 'Dep 2', description: 'Second dep' })
797
+ const main = await service.create({
798
+ name: 'Main',
799
+ description: 'Depends on both',
800
+ dependencies: [dep1.id, dep2.id],
801
+ })
802
+
803
+ expect(main.status).toBe('blocked')
804
+
805
+ // Complete first dependency
806
+ await service.execute(dep1.id)
807
+ await service.complete(dep1.id, { result: 'done' })
808
+
809
+ let status = await service.getStatus(main.id)
810
+ expect(status?.status).toBe('blocked') // Still blocked
811
+
812
+ // Complete second dependency
813
+ await service.execute(dep2.id)
814
+ await service.complete(dep2.id, { result: 'done' })
815
+
816
+ status = await service.getStatus(main.id)
817
+ expect(status?.status).not.toBe('blocked') // Now unblocked
818
+ })
819
+
820
+ it('fails dependent tasks when dependency fails', async () => {
821
+ const dep = await service.create({ name: 'Dep', description: 'Will fail' })
822
+ const main = await service.create({
823
+ name: 'Main',
824
+ description: 'Depends on failing task',
825
+ dependencies: [dep.id],
826
+ metadata: { failOnDependencyFailure: true },
827
+ })
828
+
829
+ await service.execute(dep.id)
830
+ await service.fail(dep.id, 'Dependency failed')
831
+
832
+ const status = await service.getStatus(main.id)
833
+ expect(status?.status).toBe('failed')
834
+ expect(status?.error).toContain('dependency')
835
+ })
836
+ })
837
+
838
+ describe('workflow execution', () => {
839
+ it('executes parallel tasks independently', async () => {
840
+ const parallel1 = await service.create({ name: 'Parallel 1', description: 'P1' })
841
+ const parallel2 = await service.create({ name: 'Parallel 2', description: 'P2' })
842
+
843
+ // Both can be executed without waiting for each other
844
+ await service.execute(parallel1.id)
845
+ await service.execute(parallel2.id)
846
+
847
+ const status1 = await service.getStatus(parallel1.id)
848
+ const status2 = await service.getStatus(parallel2.id)
849
+
850
+ expect(status1?.status).toBe('in_progress')
851
+ expect(status2?.status).toBe('in_progress')
852
+ })
853
+
854
+ it('executes sequential tasks in order', async () => {
855
+ const first = await service.create({ name: 'First', description: 'Step 1' })
856
+ const second = await service.create({
857
+ name: 'Second',
858
+ description: 'Step 2',
859
+ dependencies: [first.id],
860
+ })
861
+ const third = await service.create({
862
+ name: 'Third',
863
+ description: 'Step 3',
864
+ dependencies: [second.id],
865
+ })
866
+
867
+ // Cannot execute second before first
868
+ await expect(service.execute(second.id)).rejects.toThrow()
869
+
870
+ // Execute in order
871
+ await service.execute(first.id)
872
+ await service.complete(first.id, { result: '1' })
873
+
874
+ await service.execute(second.id)
875
+ await service.complete(second.id, { result: '2' })
876
+
877
+ await service.execute(third.id)
878
+ const status = await service.getStatus(third.id)
879
+ expect(status?.status).toBe('in_progress')
880
+ })
881
+
882
+ it('supports mixed parallel and sequential execution', async () => {
883
+ // Create parallel tasks
884
+ const p1 = await service.create({ name: 'P1', description: 'Parallel 1' })
885
+ const p2 = await service.create({ name: 'P2', description: 'Parallel 2' })
886
+
887
+ // Create sequential task that depends on both parallel tasks
888
+ const sequential = await service.create({
889
+ name: 'Sequential',
890
+ description: 'After parallel',
891
+ dependencies: [p1.id, p2.id],
892
+ })
893
+
894
+ expect(sequential.status).toBe('blocked')
895
+
896
+ // Complete parallel tasks
897
+ await service.execute(p1.id)
898
+ await service.execute(p2.id)
899
+ await service.complete(p1.id, { result: 'p1' })
900
+ await service.complete(p2.id, { result: 'p2' })
901
+
902
+ // Now sequential should be executable
903
+ const status = await service.getStatus(sequential.id)
904
+ expect(status?.status).not.toBe('blocked')
905
+ })
906
+ })
907
+
908
+ describe('getReadyTasks()', () => {
909
+ it('returns tasks ready for execution', async () => {
910
+ await service.create({ name: 'Ready 1', description: 'No deps' })
911
+ await service.create({ name: 'Ready 2', description: 'No deps' })
912
+
913
+ const blocker = await service.create({ name: 'Blocker', description: 'Blocks other' })
914
+ await service.create({
915
+ name: 'Blocked',
916
+ description: 'Has deps',
917
+ dependencies: [blocker.id],
918
+ })
919
+
920
+ const ready = await service.getReadyTasks()
921
+
922
+ expect(ready.length).toBeGreaterThanOrEqual(3) // Ready 1, Ready 2, Blocker
923
+ expect(ready.every((t) => t.status !== 'blocked')).toBe(true)
924
+ })
925
+ })
926
+
927
+ describe('getDependants()', () => {
928
+ it('returns tasks that depend on a given task', async () => {
929
+ const parent = await service.create({ name: 'Parent', description: 'Has dependants' })
930
+ await service.create({
931
+ name: 'Child 1',
932
+ description: 'Depends on parent',
933
+ dependencies: [parent.id],
934
+ })
935
+ await service.create({
936
+ name: 'Child 2',
937
+ description: 'Also depends on parent',
938
+ dependencies: [parent.id],
939
+ })
940
+
941
+ const dependants = await service.getDependants(parent.id)
942
+
943
+ expect(dependants).toHaveLength(2)
944
+ })
945
+ })
946
+ })
947
+
948
+ describe('TaskService (WorkerEntrypoint)', () => {
949
+ describe('class definition', () => {
950
+ it('exports TaskService class', async () => {
951
+ const { default: TaskServiceClass } = await import('../src/worker.js')
952
+ expect(TaskServiceClass).toBeDefined()
953
+ expect(typeof TaskServiceClass).toBe('function')
954
+ })
955
+
956
+ it('TaskService has connect method in prototype', () => {
957
+ expect(typeof TaskService.prototype.connect).toBe('function')
958
+ })
959
+
960
+ it('extends WorkerEntrypoint', () => {
961
+ expect(TaskService.name).toBe('TaskService')
962
+ })
963
+ })
964
+
965
+ describe('connect()', () => {
966
+ it('returns a TaskServiceCore instance', () => {
967
+ const service = new TaskService({ env } as any, {} as any)
968
+ const core = service.connect()
969
+
970
+ expect(core).toBeInstanceOf(TaskServiceCore)
971
+ })
972
+
973
+ it('returns RpcTarget for RPC communication', () => {
974
+ const service = new TaskService({ env } as any, {} as any)
975
+ const core = service.connect()
976
+
977
+ expect(core).toBeDefined()
978
+ expect(typeof core.create).toBe('function')
979
+ expect(typeof core.schedule).toBe('function')
980
+ expect(typeof core.execute).toBe('function')
981
+ expect(typeof core.complete).toBe('function')
982
+ expect(typeof core.fail).toBe('function')
983
+ expect(typeof core.getStatus).toBe('function')
984
+ expect(typeof core.cancel).toBe('function')
985
+ expect(typeof core.list).toBe('function')
986
+ })
987
+ })
988
+ })
989
+
990
+ describe('Default export', () => {
991
+ it('exports TaskService as default', async () => {
992
+ const { default: DefaultExport } = await import('../src/worker.js')
993
+ expect(DefaultExport).toBe(TaskService)
994
+ })
995
+ })
996
+
997
+ describe('Queue Integration', () => {
998
+ let service: TaskServiceCore
999
+
1000
+ beforeEach(() => {
1001
+ service = new TaskServiceCore(env)
1002
+ })
1003
+
1004
+ describe('enqueue()', () => {
1005
+ it('enqueues a task for background processing', async () => {
1006
+ const task = await service.create({
1007
+ name: 'Queue Task',
1008
+ description: 'Will be queued',
1009
+ })
1010
+
1011
+ const queued = await service.enqueue(task.id)
1012
+
1013
+ expect(queued.status).toBe('queued')
1014
+ })
1015
+
1016
+ it('enqueues with delay', async () => {
1017
+ const task = await service.create({
1018
+ name: 'Delayed Task',
1019
+ description: 'Delayed by 30 seconds',
1020
+ })
1021
+
1022
+ const queued = await service.enqueue(task.id, { delaySeconds: 30 })
1023
+
1024
+ expect(queued.status).toBe('queued')
1025
+ expect(queued.metadata?.queueDelay).toBe(30)
1026
+ })
1027
+ })
1028
+
1029
+ describe('dequeue()', () => {
1030
+ it('retrieves next task from queue', async () => {
1031
+ await service.create({ name: 'Queued 1', description: 'First' })
1032
+ const task2 = await service.create({ name: 'Queued 2', description: 'Second' })
1033
+
1034
+ await service.enqueue(task2.id)
1035
+
1036
+ const next = await service.dequeue()
1037
+
1038
+ expect(next).toBeDefined()
1039
+ expect(next?.status).toBe('in_progress')
1040
+ })
1041
+
1042
+ it('returns null when queue is empty', async () => {
1043
+ const next = await service.dequeue()
1044
+ expect(next).toBeNull()
1045
+ })
1046
+
1047
+ it('respects task priority in queue', async () => {
1048
+ const low = await service.create({
1049
+ name: 'Low',
1050
+ description: 'Low priority',
1051
+ priority: 'low',
1052
+ })
1053
+ const high = await service.create({
1054
+ name: 'High',
1055
+ description: 'High priority',
1056
+ priority: 'high',
1057
+ })
1058
+
1059
+ await service.enqueue(low.id)
1060
+ await service.enqueue(high.id)
1061
+
1062
+ const next = await service.dequeue()
1063
+
1064
+ expect(next?.priority).toBe('high')
1065
+ })
1066
+ })
1067
+ })
1068
+
1069
+ describe('Persistence and Durability', () => {
1070
+ it('persists tasks across service instances', async () => {
1071
+ const service1 = new TaskServiceCore(env)
1072
+ const task = await service1.create({
1073
+ name: 'Persistent Task',
1074
+ description: 'Survives restart',
1075
+ })
1076
+ const taskId = task.id
1077
+
1078
+ // Simulate new service instance
1079
+ const service2 = new TaskServiceCore(env)
1080
+ const retrieved = await service2.getStatus(taskId)
1081
+
1082
+ expect(retrieved).not.toBeNull()
1083
+ expect(retrieved?.id).toBe(taskId)
1084
+ })
1085
+
1086
+ it('maintains task state across operations', async () => {
1087
+ const service = new TaskServiceCore(env)
1088
+ const task = await service.create({
1089
+ name: 'Stateful Task',
1090
+ description: 'Track state changes',
1091
+ })
1092
+
1093
+ await service.execute(task.id)
1094
+ await service.updateProgress(task.id, 50, 'Halfway')
1095
+
1096
+ // Get fresh service
1097
+ const freshService = new TaskServiceCore(env)
1098
+ const status = await freshService.getStatus(task.id)
1099
+
1100
+ expect(status?.status).toBe('in_progress')
1101
+ expect(status?.progress?.percent).toBe(50)
1102
+ })
1103
+ })
1104
+
1105
+ describe('AI-Powered Task Execution', () => {
1106
+ let service: TaskServiceCore
1107
+
1108
+ beforeEach(() => {
1109
+ service = new TaskServiceCore(env)
1110
+ })
1111
+
1112
+ describe('executeWithAI()', () => {
1113
+ it('executes a generative task with AI', async () => {
1114
+ const task = await service.create({
1115
+ name: 'Summarize Text',
1116
+ description: 'Use AI to summarize input text',
1117
+ input: { text: 'The quick brown fox jumps over the lazy dog.' },
1118
+ metadata: { functionType: 'generative' },
1119
+ })
1120
+
1121
+ const result = await service.executeWithAI(task.id)
1122
+
1123
+ expect(result.status).toBe('completed')
1124
+ expect(result.output).toBeDefined()
1125
+ })
1126
+
1127
+ it('uses AI gateway with caching', async () => {
1128
+ const task = await service.create({
1129
+ name: 'Cached AI Task',
1130
+ description: 'Should use cached response',
1131
+ input: { prompt: 'Say hello' },
1132
+ metadata: {
1133
+ functionType: 'generative',
1134
+ aiOptions: { cache: true },
1135
+ },
1136
+ })
1137
+
1138
+ // First execution
1139
+ const result1 = await service.executeWithAI(task.id)
1140
+ expect(result1.status).toBe('completed')
1141
+
1142
+ // Create same task again
1143
+ const task2 = await service.create({
1144
+ name: 'Same Cached AI Task',
1145
+ description: 'Should use cached response',
1146
+ input: { prompt: 'Say hello' },
1147
+ metadata: {
1148
+ functionType: 'generative',
1149
+ aiOptions: { cache: true },
1150
+ },
1151
+ })
1152
+
1153
+ // Second execution should be faster (cached)
1154
+ const result2 = await service.executeWithAI(task2.id)
1155
+ expect(result2.status).toBe('completed')
1156
+ })
1157
+ })
1158
+ })