digital-tasks 2.0.1

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.
@@ -0,0 +1,562 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ task,
4
+ parallel,
5
+ sequential,
6
+ createProject,
7
+ workflow,
8
+ materializeProject,
9
+ getDependants,
10
+ getDependencies,
11
+ getReadyTasks,
12
+ hasCycles,
13
+ sortTasks,
14
+ } from '../src/index.js'
15
+ import type { AnyTask, TaskDefinition, ParallelGroup, SequentialGroup } from '../src/index.js'
16
+
17
+ describe('Project DSL', () => {
18
+ describe('task()', () => {
19
+ it('should create a task definition', () => {
20
+ const t = task('Implement feature')
21
+
22
+ expect(t.__type).toBe('task')
23
+ expect(t.title).toBe('Implement feature')
24
+ })
25
+
26
+ it('should create a task with options', () => {
27
+ const t = task('Review PR', {
28
+ priority: 'high',
29
+ description: 'Review the pull request',
30
+ tags: ['review', 'urgent'],
31
+ })
32
+
33
+ expect(t.title).toBe('Review PR')
34
+ expect(t.priority).toBe('high')
35
+ expect(t.description).toBe('Review the pull request')
36
+ expect(t.tags).toEqual(['review', 'urgent'])
37
+ })
38
+
39
+ it('should create a task with subtasks', () => {
40
+ const t = task('Parent task', {
41
+ subtasks: [
42
+ task('Subtask 1'),
43
+ task('Subtask 2'),
44
+ ],
45
+ })
46
+
47
+ expect(t.subtasks).toHaveLength(2)
48
+ expect((t.subtasks![0] as TaskDefinition).title).toBe('Subtask 1')
49
+ })
50
+
51
+ it('should create a task with function type', () => {
52
+ const t = task('Human review', {
53
+ functionType: 'human',
54
+ })
55
+
56
+ expect(t.functionType).toBe('human')
57
+ })
58
+ })
59
+
60
+ describe('parallel()', () => {
61
+ it('should create a parallel group', () => {
62
+ const group = parallel(
63
+ task('Task A'),
64
+ task('Task B'),
65
+ task('Task C'),
66
+ )
67
+
68
+ expect(group.__type).toBe('parallel')
69
+ expect(group.tasks).toHaveLength(3)
70
+ })
71
+
72
+ it('should support nested groups', () => {
73
+ const group = parallel(
74
+ task('Task A'),
75
+ sequential(
76
+ task('Step 1'),
77
+ task('Step 2'),
78
+ ),
79
+ )
80
+
81
+ expect(group.tasks).toHaveLength(2)
82
+ expect((group.tasks[1] as SequentialGroup).__type).toBe('sequential')
83
+ })
84
+ })
85
+
86
+ describe('sequential()', () => {
87
+ it('should create a sequential group', () => {
88
+ const group = sequential(
89
+ task('Step 1'),
90
+ task('Step 2'),
91
+ task('Step 3'),
92
+ )
93
+
94
+ expect(group.__type).toBe('sequential')
95
+ expect(group.tasks).toHaveLength(3)
96
+ })
97
+
98
+ it('should support nested groups', () => {
99
+ const group = sequential(
100
+ task('Setup'),
101
+ parallel(
102
+ task('Build frontend'),
103
+ task('Build backend'),
104
+ ),
105
+ task('Deploy'),
106
+ )
107
+
108
+ expect(group.tasks).toHaveLength(3)
109
+ expect((group.tasks[1] as ParallelGroup).__type).toBe('parallel')
110
+ })
111
+ })
112
+
113
+ describe('createProject()', () => {
114
+ it('should create a project with basic options', () => {
115
+ const project = createProject({
116
+ name: 'My Project',
117
+ description: 'Project description',
118
+ })
119
+
120
+ expect(project.id).toMatch(/^proj_/)
121
+ expect(project.name).toBe('My Project')
122
+ expect(project.description).toBe('Project description')
123
+ expect(project.status).toBe('draft')
124
+ expect(project.tasks).toEqual([])
125
+ expect(project.createdAt).toBeInstanceOf(Date)
126
+ })
127
+
128
+ it('should create a project with tasks', () => {
129
+ const project = createProject({
130
+ name: 'Feature Launch',
131
+ tasks: [
132
+ parallel(
133
+ task('Design mockups'),
134
+ task('Write technical spec'),
135
+ ),
136
+ sequential(
137
+ task('Implement backend'),
138
+ task('Implement frontend'),
139
+ task('Write tests'),
140
+ ),
141
+ ],
142
+ })
143
+
144
+ expect(project.tasks).toHaveLength(2)
145
+ expect((project.tasks[0] as ParallelGroup).__type).toBe('parallel')
146
+ expect((project.tasks[1] as SequentialGroup).__type).toBe('sequential')
147
+ })
148
+
149
+ it('should set default execution mode', () => {
150
+ const project = createProject({
151
+ name: 'Test Project',
152
+ defaultMode: 'parallel',
153
+ })
154
+
155
+ expect(project.defaultMode).toBe('parallel')
156
+ })
157
+
158
+ it('should set owner', () => {
159
+ const project = createProject({
160
+ name: 'Test Project',
161
+ owner: { type: 'human', id: 'user_1', name: 'John' },
162
+ })
163
+
164
+ expect(project.owner?.id).toBe('user_1')
165
+ })
166
+ })
167
+
168
+ describe('workflow()', () => {
169
+ it('should create a workflow builder', () => {
170
+ const wf = workflow('My Workflow')
171
+ expect(wf).toBeDefined()
172
+ expect(wf.parallel).toBeDefined()
173
+ expect(wf.sequential).toBeDefined()
174
+ expect(wf.then).toBeDefined()
175
+ expect(wf.task).toBeDefined()
176
+ expect(wf.build).toBeDefined()
177
+ })
178
+
179
+ it('should build a project with fluent API', () => {
180
+ const project = workflow('Feature Launch')
181
+ .parallel(
182
+ task('Design'),
183
+ task('Spec'),
184
+ )
185
+ .then(task('Implement'))
186
+ .then(task('Test'))
187
+ .build()
188
+
189
+ expect(project.name).toBe('Feature Launch')
190
+ expect(project.tasks).toHaveLength(3)
191
+ })
192
+
193
+ it('should support sequential chaining', () => {
194
+ const project = workflow('Sequential Work')
195
+ .task('Step 1')
196
+ .task('Step 2')
197
+ .task('Step 3')
198
+ .build()
199
+
200
+ expect(project.tasks).toHaveLength(3)
201
+ })
202
+
203
+ it('should support mixed parallel and sequential', () => {
204
+ const project = workflow('Mixed Workflow')
205
+ .parallel(task('A'), task('B'))
206
+ .sequential(task('1'), task('2'), task('3'))
207
+ .then(task('Final'))
208
+ .build()
209
+
210
+ expect(project.tasks).toHaveLength(3)
211
+ })
212
+
213
+ it('should accept description', () => {
214
+ const project = workflow('Test', 'Test description').build()
215
+ expect(project.description).toBe('Test description')
216
+ })
217
+
218
+ it('should accept build options', () => {
219
+ const project = workflow('Test')
220
+ .task('Task')
221
+ .build({ defaultMode: 'parallel', tags: ['test'] })
222
+
223
+ expect(project.defaultMode).toBe('parallel')
224
+ expect(project.tags).toEqual(['test'])
225
+ })
226
+ })
227
+
228
+ describe('materializeProject()', () => {
229
+ it('should convert project to actual Task objects', async () => {
230
+ const project = createProject({
231
+ name: 'Test Project',
232
+ tasks: [
233
+ task('Task 1'),
234
+ task('Task 2'),
235
+ ],
236
+ })
237
+
238
+ const { tasks } = await materializeProject(project)
239
+
240
+ expect(tasks).toHaveLength(2)
241
+ expect(tasks[0].id).toContain('task_0')
242
+ expect(tasks[1].id).toContain('task_1')
243
+ })
244
+
245
+ it('should create dependencies for sequential tasks', async () => {
246
+ const project = createProject({
247
+ name: 'Sequential Project',
248
+ tasks: [
249
+ sequential(
250
+ task('Step 1'),
251
+ task('Step 2'),
252
+ task('Step 3'),
253
+ ),
254
+ ],
255
+ })
256
+
257
+ const { tasks } = await materializeProject(project)
258
+
259
+ expect(tasks).toHaveLength(3)
260
+ // Step 2 depends on Step 1
261
+ expect(tasks[1].dependencies).toHaveLength(1)
262
+ expect(tasks[1].dependencies![0].taskId).toBe(tasks[0].id)
263
+ // Step 3 depends on Step 2
264
+ expect(tasks[2].dependencies).toHaveLength(1)
265
+ expect(tasks[2].dependencies![0].taskId).toBe(tasks[1].id)
266
+ })
267
+
268
+ it('should not create dependencies for parallel tasks', async () => {
269
+ const project = createProject({
270
+ name: 'Parallel Project',
271
+ tasks: [
272
+ parallel(
273
+ task('Task A'),
274
+ task('Task B'),
275
+ task('Task C'),
276
+ ),
277
+ ],
278
+ })
279
+
280
+ const { tasks } = await materializeProject(project)
281
+
282
+ expect(tasks).toHaveLength(3)
283
+ // Parallel tasks should have no dependencies between them
284
+ tasks.forEach(t => {
285
+ expect(t.dependencies).toBeUndefined()
286
+ })
287
+ })
288
+
289
+ it('should handle nested groups', async () => {
290
+ const project = createProject({
291
+ name: 'Nested Project',
292
+ tasks: [
293
+ sequential(
294
+ parallel(
295
+ task('A1'),
296
+ task('A2'),
297
+ ),
298
+ task('B'),
299
+ ),
300
+ ],
301
+ })
302
+
303
+ const { tasks } = await materializeProject(project)
304
+
305
+ expect(tasks).toHaveLength(3)
306
+ // A1 and A2 should have no dependencies
307
+ expect(tasks[0].dependencies).toBeUndefined()
308
+ expect(tasks[1].dependencies).toBeUndefined()
309
+ // B should depend on both A1 and A2
310
+ expect(tasks[2].dependencies).toHaveLength(2)
311
+ })
312
+
313
+ it('should process subtasks', async () => {
314
+ const project = createProject({
315
+ name: 'Subtask Project',
316
+ tasks: [
317
+ task('Parent', {
318
+ subtasks: [
319
+ task('Child 1'),
320
+ task('Child 2'),
321
+ ],
322
+ }),
323
+ ],
324
+ })
325
+
326
+ const { tasks } = await materializeProject(project)
327
+
328
+ // Should have parent + 2 children
329
+ expect(tasks).toHaveLength(3)
330
+ // Children should have parentId set
331
+ const parentId = tasks[0].id
332
+ expect(tasks[1].parentId).toBe(parentId)
333
+ expect(tasks[2].parentId).toBe(parentId)
334
+ })
335
+ })
336
+
337
+ describe('Dependency Graph Utilities', () => {
338
+ const createTestTasks = (): AnyTask[] => [
339
+ {
340
+ id: 'task_1',
341
+ function: { type: 'generative', name: 'Task 1', args: {}, output: 'string' },
342
+ status: 'queued',
343
+ priority: 'normal',
344
+ createdAt: new Date(),
345
+ events: [],
346
+ },
347
+ {
348
+ id: 'task_2',
349
+ function: { type: 'generative', name: 'Task 2', args: {}, output: 'string' },
350
+ status: 'queued',
351
+ priority: 'normal',
352
+ dependencies: [{ type: 'blocked_by', taskId: 'task_1', satisfied: false }],
353
+ createdAt: new Date(),
354
+ events: [],
355
+ },
356
+ {
357
+ id: 'task_3',
358
+ function: { type: 'generative', name: 'Task 3', args: {}, output: 'string' },
359
+ status: 'queued',
360
+ priority: 'normal',
361
+ dependencies: [{ type: 'blocked_by', taskId: 'task_2', satisfied: false }],
362
+ createdAt: new Date(),
363
+ events: [],
364
+ },
365
+ ]
366
+
367
+ describe('getDependants()', () => {
368
+ it('should return tasks that depend on a given task', () => {
369
+ const tasks = createTestTasks()
370
+ const dependants = getDependants('task_1', tasks)
371
+
372
+ expect(dependants).toHaveLength(1)
373
+ expect(dependants[0].id).toBe('task_2')
374
+ })
375
+
376
+ it('should return empty array for task with no dependants', () => {
377
+ const tasks = createTestTasks()
378
+ const dependants = getDependants('task_3', tasks)
379
+
380
+ expect(dependants).toHaveLength(0)
381
+ })
382
+ })
383
+
384
+ describe('getDependencies()', () => {
385
+ it('should return tasks that a given task depends on', () => {
386
+ const tasks = createTestTasks()
387
+ const deps = getDependencies(tasks[1], tasks)
388
+
389
+ expect(deps).toHaveLength(1)
390
+ expect(deps[0].id).toBe('task_1')
391
+ })
392
+
393
+ it('should return empty array for task with no dependencies', () => {
394
+ const tasks = createTestTasks()
395
+ const deps = getDependencies(tasks[0], tasks)
396
+
397
+ expect(deps).toHaveLength(0)
398
+ })
399
+ })
400
+
401
+ describe('getReadyTasks()', () => {
402
+ it('should return tasks with no unsatisfied dependencies', () => {
403
+ const tasks = createTestTasks()
404
+ const ready = getReadyTasks(tasks)
405
+
406
+ expect(ready).toHaveLength(1)
407
+ expect(ready[0].id).toBe('task_1')
408
+ })
409
+
410
+ it('should include tasks with all dependencies satisfied', () => {
411
+ const tasks = createTestTasks()
412
+ tasks[1].dependencies![0].satisfied = true
413
+
414
+ const ready = getReadyTasks(tasks)
415
+
416
+ expect(ready).toHaveLength(2)
417
+ expect(ready.map(t => t.id)).toContain('task_1')
418
+ expect(ready.map(t => t.id)).toContain('task_2')
419
+ })
420
+
421
+ it('should exclude non-queued tasks', () => {
422
+ const tasks = createTestTasks()
423
+ tasks[0].status = 'in_progress'
424
+
425
+ const ready = getReadyTasks(tasks)
426
+
427
+ expect(ready).toHaveLength(0)
428
+ })
429
+ })
430
+
431
+ describe('hasCycles()', () => {
432
+ it('should return false for DAG', () => {
433
+ const tasks = createTestTasks()
434
+ expect(hasCycles(tasks)).toBe(false)
435
+ })
436
+
437
+ it('should return true for cyclic graph', () => {
438
+ const tasks: AnyTask[] = [
439
+ {
440
+ id: 'cycle_1',
441
+ function: { type: 'generative', name: 'Cycle 1', args: {}, output: 'string' },
442
+ status: 'queued',
443
+ priority: 'normal',
444
+ dependencies: [{ type: 'blocked_by', taskId: 'cycle_2', satisfied: false }],
445
+ createdAt: new Date(),
446
+ events: [],
447
+ },
448
+ {
449
+ id: 'cycle_2',
450
+ function: { type: 'generative', name: 'Cycle 2', args: {}, output: 'string' },
451
+ status: 'queued',
452
+ priority: 'normal',
453
+ dependencies: [{ type: 'blocked_by', taskId: 'cycle_1', satisfied: false }],
454
+ createdAt: new Date(),
455
+ events: [],
456
+ },
457
+ ]
458
+
459
+ expect(hasCycles(tasks)).toBe(true)
460
+ })
461
+
462
+ it('should return false for empty task list', () => {
463
+ expect(hasCycles([])).toBe(false)
464
+ })
465
+ })
466
+
467
+ describe('sortTasks()', () => {
468
+ it('should return tasks in topological order', () => {
469
+ const tasks = createTestTasks()
470
+ const sorted = sortTasks(tasks)
471
+
472
+ expect(sorted).toHaveLength(3)
473
+ expect(sorted[0].id).toBe('task_1')
474
+ expect(sorted[1].id).toBe('task_2')
475
+ expect(sorted[2].id).toBe('task_3')
476
+ })
477
+
478
+ it('should handle tasks with no dependencies', () => {
479
+ const tasks: AnyTask[] = [
480
+ {
481
+ id: 'independent_1',
482
+ function: { type: 'generative', name: 'Ind 1', args: {}, output: 'string' },
483
+ status: 'queued',
484
+ priority: 'normal',
485
+ createdAt: new Date(),
486
+ events: [],
487
+ },
488
+ {
489
+ id: 'independent_2',
490
+ function: { type: 'generative', name: 'Ind 2', args: {}, output: 'string' },
491
+ status: 'queued',
492
+ priority: 'normal',
493
+ createdAt: new Date(),
494
+ events: [],
495
+ },
496
+ ]
497
+
498
+ const sorted = sortTasks(tasks)
499
+ expect(sorted).toHaveLength(2)
500
+ })
501
+
502
+ it('should handle complex dependency graph', () => {
503
+ // A
504
+ // / \
505
+ // B C
506
+ // \ /
507
+ // D
508
+ const tasks: AnyTask[] = [
509
+ {
510
+ id: 'A',
511
+ function: { type: 'generative', name: 'A', args: {}, output: 'string' },
512
+ status: 'queued',
513
+ priority: 'normal',
514
+ createdAt: new Date(),
515
+ events: [],
516
+ },
517
+ {
518
+ id: 'B',
519
+ function: { type: 'generative', name: 'B', args: {}, output: 'string' },
520
+ status: 'queued',
521
+ priority: 'normal',
522
+ dependencies: [{ type: 'blocked_by', taskId: 'A', satisfied: false }],
523
+ createdAt: new Date(),
524
+ events: [],
525
+ },
526
+ {
527
+ id: 'C',
528
+ function: { type: 'generative', name: 'C', args: {}, output: 'string' },
529
+ status: 'queued',
530
+ priority: 'normal',
531
+ dependencies: [{ type: 'blocked_by', taskId: 'A', satisfied: false }],
532
+ createdAt: new Date(),
533
+ events: [],
534
+ },
535
+ {
536
+ id: 'D',
537
+ function: { type: 'generative', name: 'D', args: {}, output: 'string' },
538
+ status: 'queued',
539
+ priority: 'normal',
540
+ dependencies: [
541
+ { type: 'blocked_by', taskId: 'B', satisfied: false },
542
+ { type: 'blocked_by', taskId: 'C', satisfied: false },
543
+ ],
544
+ createdAt: new Date(),
545
+ events: [],
546
+ },
547
+ ]
548
+
549
+ const sorted = sortTasks(tasks)
550
+
551
+ // A must come first
552
+ expect(sorted[0].id).toBe('A')
553
+ // D must come last
554
+ expect(sorted[3].id).toBe('D')
555
+ // B and C can be in any order between A and D
556
+ const bcIds = [sorted[1].id, sorted[2].id]
557
+ expect(bcIds).toContain('B')
558
+ expect(bcIds).toContain('C')
559
+ })
560
+ })
561
+ })
562
+ })