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.
- package/CHANGELOG.md +9 -0
- package/README.md +560 -0
- package/package.json +29 -14
- package/src/client.ts +268 -0
- package/src/index.ts +12 -10
- package/src/markdown.ts +63 -48
- package/src/project.ts +57 -42
- package/src/queue.ts +76 -37
- package/src/task.ts +132 -75
- package/src/types.ts +177 -40
- package/src/worker.ts +959 -0
- package/test/project.test.ts +28 -84
- package/test/queue.test.ts +51 -24
- package/test/task.test.ts +80 -27
- package/test/worker.test.ts +1158 -0
- package/tsconfig.json +2 -13
- package/vitest.config.ts +48 -0
- package/wrangler.jsonc +44 -0
- package/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
- package/dist/function-task.d.ts +0 -319
- package/dist/function-task.d.ts.map +0 -1
- package/dist/function-task.js +0 -286
- package/dist/function-task.js.map +0 -1
- package/dist/index.d.ts +0 -72
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -74
- package/dist/index.js.map +0 -1
- package/dist/markdown.d.ts +0 -112
- package/dist/markdown.d.ts.map +0 -1
- package/dist/markdown.js +0 -510
- package/dist/markdown.js.map +0 -1
- package/dist/project.d.ts +0 -259
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js +0 -397
- package/dist/project.js.map +0 -1
- package/dist/queue.d.ts +0 -17
- package/dist/queue.d.ts.map +0 -1
- package/dist/queue.js +0 -347
- package/dist/queue.js.map +0 -1
- package/dist/task.d.ts +0 -69
- package/dist/task.d.ts.map +0 -1
- package/dist/task.js +0 -321
- package/dist/task.js.map +0 -1
- package/dist/types.d.ts +0 -292
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -15
- package/dist/types.js.map +0 -1
- package/src/index.js +0 -73
- package/src/markdown.js +0 -509
- package/src/project.js +0 -396
- package/src/queue.js +0 -346
- package/src/task.js +0 -320
- 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
|
+
})
|