@jackchen_me/open-multi-agent 1.0.0 → 1.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.
Files changed (80) hide show
  1. package/package.json +8 -2
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
  4. package/.github/pull_request_template.md +0 -14
  5. package/.github/workflows/ci.yml +0 -23
  6. package/CLAUDE.md +0 -80
  7. package/CODE_OF_CONDUCT.md +0 -48
  8. package/CONTRIBUTING.md +0 -72
  9. package/DECISIONS.md +0 -43
  10. package/README_zh.md +0 -277
  11. package/SECURITY.md +0 -17
  12. package/examples/01-single-agent.ts +0 -131
  13. package/examples/02-team-collaboration.ts +0 -167
  14. package/examples/03-task-pipeline.ts +0 -201
  15. package/examples/04-multi-model-team.ts +0 -261
  16. package/examples/05-copilot-test.ts +0 -49
  17. package/examples/06-local-model.ts +0 -200
  18. package/examples/07-fan-out-aggregate.ts +0 -209
  19. package/examples/08-gemma4-local.ts +0 -192
  20. package/examples/09-structured-output.ts +0 -73
  21. package/examples/10-task-retry.ts +0 -132
  22. package/examples/11-trace-observability.ts +0 -133
  23. package/examples/12-grok.ts +0 -154
  24. package/examples/13-gemini.ts +0 -48
  25. package/src/agent/agent.ts +0 -622
  26. package/src/agent/loop-detector.ts +0 -137
  27. package/src/agent/pool.ts +0 -285
  28. package/src/agent/runner.ts +0 -542
  29. package/src/agent/structured-output.ts +0 -126
  30. package/src/index.ts +0 -182
  31. package/src/llm/adapter.ts +0 -98
  32. package/src/llm/anthropic.ts +0 -389
  33. package/src/llm/copilot.ts +0 -552
  34. package/src/llm/gemini.ts +0 -378
  35. package/src/llm/grok.ts +0 -29
  36. package/src/llm/openai-common.ts +0 -294
  37. package/src/llm/openai.ts +0 -292
  38. package/src/memory/shared.ts +0 -181
  39. package/src/memory/store.ts +0 -124
  40. package/src/orchestrator/orchestrator.ts +0 -1071
  41. package/src/orchestrator/scheduler.ts +0 -352
  42. package/src/task/queue.ts +0 -464
  43. package/src/task/task.ts +0 -239
  44. package/src/team/messaging.ts +0 -232
  45. package/src/team/team.ts +0 -334
  46. package/src/tool/built-in/bash.ts +0 -187
  47. package/src/tool/built-in/file-edit.ts +0 -154
  48. package/src/tool/built-in/file-read.ts +0 -105
  49. package/src/tool/built-in/file-write.ts +0 -81
  50. package/src/tool/built-in/grep.ts +0 -362
  51. package/src/tool/built-in/index.ts +0 -50
  52. package/src/tool/executor.ts +0 -178
  53. package/src/tool/framework.ts +0 -557
  54. package/src/tool/text-tool-extractor.ts +0 -219
  55. package/src/types.ts +0 -542
  56. package/src/utils/semaphore.ts +0 -89
  57. package/src/utils/trace.ts +0 -34
  58. package/tests/agent-hooks.test.ts +0 -473
  59. package/tests/agent-pool.test.ts +0 -212
  60. package/tests/approval.test.ts +0 -464
  61. package/tests/built-in-tools.test.ts +0 -393
  62. package/tests/gemini-adapter.test.ts +0 -97
  63. package/tests/grok-adapter.test.ts +0 -74
  64. package/tests/llm-adapters.test.ts +0 -357
  65. package/tests/loop-detection.test.ts +0 -456
  66. package/tests/openai-fallback.test.ts +0 -159
  67. package/tests/orchestrator.test.ts +0 -281
  68. package/tests/scheduler.test.ts +0 -221
  69. package/tests/semaphore.test.ts +0 -57
  70. package/tests/shared-memory.test.ts +0 -122
  71. package/tests/structured-output.test.ts +0 -331
  72. package/tests/task-queue.test.ts +0 -244
  73. package/tests/task-retry.test.ts +0 -368
  74. package/tests/task-utils.test.ts +0 -155
  75. package/tests/team-messaging.test.ts +0 -329
  76. package/tests/text-tool-extractor.test.ts +0 -170
  77. package/tests/tool-executor.test.ts +0 -193
  78. package/tests/trace.test.ts +0 -453
  79. package/tsconfig.json +0 -25
  80. package/vitest.config.ts +0 -9
package/src/task/queue.ts DELETED
@@ -1,464 +0,0 @@
1
- /**
2
- * @fileoverview Dependency-aware task queue.
3
- *
4
- * {@link TaskQueue} owns the mutable lifecycle of every task it holds.
5
- * Completing a task automatically unblocks dependents and fires events so
6
- * orchestrators can react without polling.
7
- */
8
-
9
- import type { Task, TaskStatus } from '../types.js'
10
- import { isTaskReady } from './task.js'
11
-
12
- // ---------------------------------------------------------------------------
13
- // Event types
14
- // ---------------------------------------------------------------------------
15
-
16
- /** Named event types emitted by {@link TaskQueue}. */
17
- export type TaskQueueEvent =
18
- | 'task:ready'
19
- | 'task:complete'
20
- | 'task:failed'
21
- | 'task:skipped'
22
- | 'all:complete'
23
-
24
- /** Handler for `'task:ready' | 'task:complete' | 'task:failed'` events. */
25
- type TaskHandler = (task: Task) => void
26
- /** Handler for `'all:complete'` (no task argument). */
27
- type AllCompleteHandler = () => void
28
-
29
- type HandlerFor<E extends TaskQueueEvent> = E extends 'all:complete'
30
- ? AllCompleteHandler
31
- : TaskHandler
32
-
33
- // ---------------------------------------------------------------------------
34
- // TaskQueue
35
- // ---------------------------------------------------------------------------
36
-
37
- /**
38
- * Mutable, event-driven queue with topological dependency resolution.
39
- *
40
- * Tasks enter in `'pending'` state. The queue promotes them to `'blocked'`
41
- * when unresolved dependencies exist, and back to `'pending'` (firing
42
- * `'task:ready'`) when those dependencies complete. Callers drive execution by
43
- * calling {@link next} / {@link nextAvailable} and updating task state via
44
- * {@link complete} or {@link fail}.
45
- *
46
- * @example
47
- * ```ts
48
- * const queue = new TaskQueue()
49
- * queue.on('task:ready', (task) => scheduleExecution(task))
50
- * queue.on('all:complete', () => shutdown())
51
- *
52
- * queue.addBatch(tasks)
53
- * ```
54
- */
55
- export class TaskQueue {
56
- private readonly tasks = new Map<string, Task>()
57
-
58
- /** Listeners keyed by event type, stored as symbol → handler pairs. */
59
- private readonly listeners = new Map<
60
- TaskQueueEvent,
61
- Map<symbol, TaskHandler | AllCompleteHandler>
62
- >()
63
-
64
- // ---------------------------------------------------------------------------
65
- // Mutation: add
66
- // ---------------------------------------------------------------------------
67
-
68
- /**
69
- * Adds a single task.
70
- *
71
- * If the task has unresolved dependencies it is immediately promoted to
72
- * `'blocked'`; otherwise it stays `'pending'` and `'task:ready'` fires.
73
- */
74
- add(task: Task): void {
75
- const resolved = this.resolveInitialStatus(task)
76
- this.tasks.set(resolved.id, resolved)
77
- if (resolved.status === 'pending') {
78
- this.emit('task:ready', resolved)
79
- }
80
- }
81
-
82
- /**
83
- * Adds multiple tasks at once.
84
- *
85
- * Processing each task re-evaluates the current map state, so inserting a
86
- * batch where some tasks satisfy others' dependencies produces correct initial
87
- * statuses when the dependencies appear first in the array. Use
88
- * {@link getTaskDependencyOrder} from `task.ts` to pre-sort if needed.
89
- */
90
- addBatch(tasks: Task[]): void {
91
- for (const task of tasks) {
92
- this.add(task)
93
- }
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // Mutation: update / complete / fail
98
- // ---------------------------------------------------------------------------
99
-
100
- /**
101
- * Applies a partial update to an existing task.
102
- *
103
- * Only `status`, `result`, and `assignee` are accepted to keep the update
104
- * surface narrow. Use {@link complete} and {@link fail} for terminal states.
105
- *
106
- * @throws {Error} when `taskId` is not found.
107
- */
108
- update(
109
- taskId: string,
110
- update: Partial<Pick<Task, 'status' | 'result' | 'assignee'>>,
111
- ): Task {
112
- const task = this.requireTask(taskId)
113
- const updated: Task = {
114
- ...task,
115
- ...update,
116
- updatedAt: new Date(),
117
- }
118
- this.tasks.set(taskId, updated)
119
- return updated
120
- }
121
-
122
- /**
123
- * Marks `taskId` as `'completed'`, records an optional `result` string, and
124
- * unblocks any dependents that are now ready to run.
125
- *
126
- * Fires `'task:complete'`, then `'task:ready'` for each newly-unblocked task,
127
- * then `'all:complete'` when the queue is fully resolved.
128
- *
129
- * @throws {Error} when `taskId` is not found.
130
- */
131
- complete(taskId: string, result?: string): Task {
132
- const completed = this.update(taskId, { status: 'completed', result })
133
- this.emit('task:complete', completed)
134
- this.unblockDependents(taskId)
135
- if (this.isComplete()) {
136
- this.emitAllComplete()
137
- }
138
- return completed
139
- }
140
-
141
- /**
142
- * Marks `taskId` as `'failed'` and records `error` in the `result` field.
143
- *
144
- * Fires `'task:failed'` for the failed task and for every downstream task
145
- * that transitively depended on it (cascade failure). This prevents blocked
146
- * tasks from remaining stuck indefinitely when an upstream dependency fails.
147
- *
148
- * @throws {Error} when `taskId` is not found.
149
- */
150
- fail(taskId: string, error: string): Task {
151
- const failed = this.update(taskId, { status: 'failed', result: error })
152
- this.emit('task:failed', failed)
153
- this.cascadeFailure(taskId)
154
- if (this.isComplete()) {
155
- this.emitAllComplete()
156
- }
157
- return failed
158
- }
159
-
160
- /**
161
- * Marks `taskId` as `'skipped'` and records `reason` in the `result` field.
162
- *
163
- * Fires `'task:skipped'` for the skipped task and cascades to every
164
- * downstream task that transitively depended on it — even if the dependent
165
- * has other dependencies that are still pending or completed. A skipped
166
- * upstream is treated as permanently unsatisfiable, mirroring `fail()`.
167
- *
168
- * @throws {Error} when `taskId` is not found.
169
- */
170
- skip(taskId: string, reason: string): Task {
171
- const skipped = this.update(taskId, { status: 'skipped', result: reason })
172
- this.emit('task:skipped', skipped)
173
- this.cascadeSkip(taskId)
174
- if (this.isComplete()) {
175
- this.emitAllComplete()
176
- }
177
- return skipped
178
- }
179
-
180
- /**
181
- * Marks all non-terminal tasks as `'skipped'`.
182
- *
183
- * Used when an approval gate rejects continuation — every pending, blocked,
184
- * or in-progress task is skipped with the given reason.
185
- *
186
- * **Important:** Call only when no tasks are actively executing. The
187
- * orchestrator invokes this after `await Promise.all()`, so no tasks are
188
- * in-flight. Calling while agents are running may mark an in-progress task
189
- * as skipped while its agent continues executing.
190
- */
191
- skipRemaining(reason = 'Skipped: approval rejected.'): void {
192
- // Snapshot first — update() mutates the live map, which is unsafe to
193
- // iterate over during modification.
194
- const snapshot = Array.from(this.tasks.values())
195
- for (const task of snapshot) {
196
- if (task.status === 'completed' || task.status === 'failed' || task.status === 'skipped') continue
197
- const skipped = this.update(task.id, { status: 'skipped', result: reason })
198
- this.emit('task:skipped', skipped)
199
- }
200
- if (this.isComplete()) {
201
- this.emitAllComplete()
202
- }
203
- }
204
-
205
- /**
206
- * Recursively marks all tasks that (transitively) depend on `failedTaskId`
207
- * as `'failed'` with an informative message, firing `'task:failed'` for each.
208
- *
209
- * Only tasks in `'blocked'` or `'pending'` state are affected; tasks already
210
- * in a terminal state are left untouched.
211
- */
212
- private cascadeFailure(failedTaskId: string): void {
213
- for (const task of this.tasks.values()) {
214
- if (task.status !== 'blocked' && task.status !== 'pending') continue
215
- if (!task.dependsOn?.includes(failedTaskId)) continue
216
-
217
- const cascaded = this.update(task.id, {
218
- status: 'failed',
219
- result: `Cancelled: dependency "${failedTaskId}" failed.`,
220
- })
221
- this.emit('task:failed', cascaded)
222
- // Recurse to handle transitive dependents.
223
- this.cascadeFailure(task.id)
224
- }
225
- }
226
-
227
- /**
228
- * Recursively marks all tasks that (transitively) depend on `skippedTaskId`
229
- * as `'skipped'`, firing `'task:skipped'` for each.
230
- */
231
- private cascadeSkip(skippedTaskId: string): void {
232
- for (const task of this.tasks.values()) {
233
- if (task.status !== 'blocked' && task.status !== 'pending') continue
234
- if (!task.dependsOn?.includes(skippedTaskId)) continue
235
-
236
- const cascaded = this.update(task.id, {
237
- status: 'skipped',
238
- result: `Skipped: dependency "${skippedTaskId}" was skipped.`,
239
- })
240
- this.emit('task:skipped', cascaded)
241
- this.cascadeSkip(task.id)
242
- }
243
- }
244
-
245
- // ---------------------------------------------------------------------------
246
- // Queries
247
- // ---------------------------------------------------------------------------
248
-
249
- /**
250
- * Returns the next `'pending'` task for `assignee` (matched against
251
- * `task.assignee`), or `undefined` if none exists.
252
- *
253
- * If `assignee` is omitted, behaves like {@link nextAvailable}.
254
- */
255
- next(assignee?: string): Task | undefined {
256
- if (assignee === undefined) return this.nextAvailable()
257
-
258
- for (const task of this.tasks.values()) {
259
- if (task.status === 'pending' && task.assignee === assignee) {
260
- return task
261
- }
262
- }
263
- return undefined
264
- }
265
-
266
- /**
267
- * Returns the next `'pending'` task that has no `assignee` restriction, or
268
- * the first `'pending'` task overall when all pending tasks have an assignee.
269
- */
270
- nextAvailable(): Task | undefined {
271
- let fallback: Task | undefined
272
-
273
- for (const task of this.tasks.values()) {
274
- if (task.status !== 'pending') continue
275
- if (!task.assignee) return task
276
- if (!fallback) fallback = task
277
- }
278
-
279
- return fallback
280
- }
281
-
282
- /** Returns a snapshot array of all tasks (any status). */
283
- list(): Task[] {
284
- return Array.from(this.tasks.values())
285
- }
286
-
287
- /** Returns all tasks whose `status` matches `status`. */
288
- getByStatus(status: TaskStatus): Task[] {
289
- return this.list().filter((t) => t.status === status)
290
- }
291
-
292
- /**
293
- * Returns `true` when every task in the queue has reached a terminal state
294
- * (`'completed'`, `'failed'`, or `'skipped'`), **or** the queue is empty.
295
- */
296
- isComplete(): boolean {
297
- for (const task of this.tasks.values()) {
298
- if (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'skipped') return false
299
- }
300
- return true
301
- }
302
-
303
- /**
304
- * Returns a progress snapshot.
305
- *
306
- * @example
307
- * ```ts
308
- * const { completed, total } = queue.getProgress()
309
- * console.log(`${completed}/${total} tasks done`)
310
- * ```
311
- */
312
- getProgress(): {
313
- total: number
314
- completed: number
315
- failed: number
316
- skipped: number
317
- inProgress: number
318
- pending: number
319
- blocked: number
320
- } {
321
- let completed = 0
322
- let failed = 0
323
- let skipped = 0
324
- let inProgress = 0
325
- let pending = 0
326
- let blocked = 0
327
-
328
- for (const task of this.tasks.values()) {
329
- switch (task.status) {
330
- case 'completed':
331
- completed++
332
- break
333
- case 'failed':
334
- failed++
335
- break
336
- case 'skipped':
337
- skipped++
338
- break
339
- case 'in_progress':
340
- inProgress++
341
- break
342
- case 'pending':
343
- pending++
344
- break
345
- case 'blocked':
346
- blocked++
347
- break
348
- }
349
- }
350
-
351
- return {
352
- total: this.tasks.size,
353
- completed,
354
- failed,
355
- skipped,
356
- inProgress,
357
- pending,
358
- blocked,
359
- }
360
- }
361
-
362
- // ---------------------------------------------------------------------------
363
- // Events
364
- // ---------------------------------------------------------------------------
365
-
366
- /**
367
- * Subscribes to a queue event.
368
- *
369
- * @returns An unsubscribe function. Calling it is idempotent.
370
- *
371
- * @example
372
- * ```ts
373
- * const off = queue.on('task:ready', (task) => execute(task))
374
- * // later…
375
- * off()
376
- * ```
377
- */
378
- on<E extends TaskQueueEvent>(
379
- event: E,
380
- handler: HandlerFor<E>,
381
- ): () => void {
382
- let map = this.listeners.get(event)
383
- if (!map) {
384
- map = new Map()
385
- this.listeners.set(event, map)
386
- }
387
- const id = Symbol()
388
- map.set(id, handler as TaskHandler | AllCompleteHandler)
389
- return () => {
390
- map!.delete(id)
391
- }
392
- }
393
-
394
- // ---------------------------------------------------------------------------
395
- // Private helpers
396
- // ---------------------------------------------------------------------------
397
-
398
- /**
399
- * Evaluates whether `task` should start as `'blocked'` based on the tasks
400
- * already registered in the queue.
401
- */
402
- private resolveInitialStatus(task: Task): Task {
403
- if (!task.dependsOn || task.dependsOn.length === 0) return task
404
-
405
- const allCurrent = Array.from(this.tasks.values())
406
- const ready = isTaskReady(task, allCurrent)
407
- if (ready) return task
408
-
409
- return { ...task, status: 'blocked', updatedAt: new Date() }
410
- }
411
-
412
- /**
413
- * After a task completes, scan all `'blocked'` tasks and promote any that are
414
- * now fully satisfied to `'pending'`, firing `'task:ready'` for each.
415
- *
416
- * The task array and lookup map are built once for the entire scan to keep
417
- * the operation O(n) rather than O(n²).
418
- */
419
- private unblockDependents(completedId: string): void {
420
- const allTasks = Array.from(this.tasks.values())
421
- const taskById = new Map<string, Task>(allTasks.map((t) => [t.id, t]))
422
-
423
- for (const task of allTasks) {
424
- if (task.status !== 'blocked') continue
425
- if (!task.dependsOn?.includes(completedId)) continue
426
-
427
- // Re-check against the current state of the whole task set.
428
- // Pass the pre-built map to avoid rebuilding it for every candidate task.
429
- if (isTaskReady({ ...task, status: 'pending' }, allTasks, taskById)) {
430
- const unblocked: Task = {
431
- ...task,
432
- status: 'pending',
433
- updatedAt: new Date(),
434
- }
435
- this.tasks.set(task.id, unblocked)
436
- // Update the map so subsequent iterations in the same call see the new status.
437
- taskById.set(task.id, unblocked)
438
- this.emit('task:ready', unblocked)
439
- }
440
- }
441
- }
442
-
443
- private emit(event: 'task:ready' | 'task:complete' | 'task:failed' | 'task:skipped', task: Task): void {
444
- const map = this.listeners.get(event)
445
- if (!map) return
446
- for (const handler of map.values()) {
447
- ;(handler as TaskHandler)(task)
448
- }
449
- }
450
-
451
- private emitAllComplete(): void {
452
- const map = this.listeners.get('all:complete')
453
- if (!map) return
454
- for (const handler of map.values()) {
455
- ;(handler as AllCompleteHandler)()
456
- }
457
- }
458
-
459
- private requireTask(taskId: string): Task {
460
- const task = this.tasks.get(taskId)
461
- if (!task) throw new Error(`TaskQueue: task "${taskId}" not found.`)
462
- return task
463
- }
464
- }
package/src/task/task.ts DELETED
@@ -1,239 +0,0 @@
1
- /**
2
- * @fileoverview Pure task utility functions.
3
- *
4
- * These helpers operate on plain {@link Task} values without any mutable
5
- * state, making them safe to use in reducers, tests, and reactive pipelines.
6
- * Stateful orchestration belongs in {@link TaskQueue}.
7
- */
8
-
9
- import { randomUUID } from 'node:crypto'
10
- import type { Task, TaskStatus } from '../types.js'
11
-
12
- // ---------------------------------------------------------------------------
13
- // Factory
14
- // ---------------------------------------------------------------------------
15
-
16
- /**
17
- * Creates a new {@link Task} with a generated UUID, `'pending'` status, and
18
- * `createdAt`/`updatedAt` timestamps set to the current instant.
19
- *
20
- * @example
21
- * ```ts
22
- * const task = createTask({
23
- * title: 'Research competitors',
24
- * description: 'Identify the top 5 competitors and their pricing',
25
- * assignee: 'researcher',
26
- * })
27
- * ```
28
- */
29
- export function createTask(input: {
30
- title: string
31
- description: string
32
- assignee?: string
33
- dependsOn?: string[]
34
- maxRetries?: number
35
- retryDelayMs?: number
36
- retryBackoff?: number
37
- }): Task {
38
- const now = new Date()
39
- return {
40
- id: randomUUID(),
41
- title: input.title,
42
- description: input.description,
43
- status: 'pending' as TaskStatus,
44
- assignee: input.assignee,
45
- dependsOn: input.dependsOn ? [...input.dependsOn] : undefined,
46
- result: undefined,
47
- createdAt: now,
48
- updatedAt: now,
49
- maxRetries: input.maxRetries,
50
- retryDelayMs: input.retryDelayMs,
51
- retryBackoff: input.retryBackoff,
52
- }
53
- }
54
-
55
- // ---------------------------------------------------------------------------
56
- // Readiness
57
- // ---------------------------------------------------------------------------
58
-
59
- /**
60
- * Returns `true` when `task` can be started immediately.
61
- *
62
- * A task is considered ready when:
63
- * 1. Its status is `'pending'`.
64
- * 2. Every task listed in `task.dependsOn` has status `'completed'`.
65
- *
66
- * Tasks whose dependencies are missing from `allTasks` are treated as
67
- * unresolvable and therefore **not** ready.
68
- *
69
- * @param task - The task to evaluate.
70
- * @param allTasks - The full collection of tasks in the current queue/plan.
71
- * @param taskById - Optional pre-built id→task map. When provided the function
72
- * skips rebuilding the map, reducing the complexity of
73
- * call-sites that invoke `isTaskReady` inside a loop from
74
- * O(n²) to O(n).
75
- */
76
- export function isTaskReady(
77
- task: Task,
78
- allTasks: Task[],
79
- taskById?: Map<string, Task>,
80
- ): boolean {
81
- if (task.status !== 'pending') return false
82
- if (!task.dependsOn || task.dependsOn.length === 0) return true
83
-
84
- const map = taskById ?? new Map<string, Task>(allTasks.map((t) => [t.id, t]))
85
-
86
- for (const depId of task.dependsOn) {
87
- const dep = map.get(depId)
88
- if (!dep || dep.status !== 'completed') return false
89
- }
90
-
91
- return true
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Topological sort
96
- // ---------------------------------------------------------------------------
97
-
98
- /**
99
- * Returns `tasks` sorted so that each task appears after all of its
100
- * dependencies — a standard topological (Kahn's algorithm) ordering.
101
- *
102
- * Tasks with no dependencies come first. If the graph contains a cycle the
103
- * function returns a partial result containing only the tasks that could be
104
- * ordered; use {@link validateTaskDependencies} to detect cycles before calling
105
- * this function in production paths.
106
- *
107
- * @example
108
- * ```ts
109
- * const ordered = getTaskDependencyOrder(tasks)
110
- * for (const task of ordered) {
111
- * await run(task)
112
- * }
113
- * ```
114
- */
115
- export function getTaskDependencyOrder(tasks: Task[]): Task[] {
116
- if (tasks.length === 0) return []
117
-
118
- const taskById = new Map<string, Task>(tasks.map((t) => [t.id, t]))
119
-
120
- // Build adjacency: dependsOn edges become "predecessors" for in-degree count.
121
- const inDegree = new Map<string, number>()
122
- // successors[id] = list of task IDs that depend on `id`
123
- const successors = new Map<string, string[]>()
124
-
125
- for (const task of tasks) {
126
- if (!inDegree.has(task.id)) inDegree.set(task.id, 0)
127
- if (!successors.has(task.id)) successors.set(task.id, [])
128
-
129
- for (const depId of task.dependsOn ?? []) {
130
- // Only count dependencies that exist in this task set.
131
- if (taskById.has(depId)) {
132
- inDegree.set(task.id, (inDegree.get(task.id) ?? 0) + 1)
133
- const deps = successors.get(depId) ?? []
134
- deps.push(task.id)
135
- successors.set(depId, deps)
136
- }
137
- }
138
- }
139
-
140
- // Kahn's algorithm: start with all nodes of in-degree 0.
141
- const queue: string[] = []
142
- for (const [id, degree] of inDegree) {
143
- if (degree === 0) queue.push(id)
144
- }
145
-
146
- const ordered: Task[] = []
147
- while (queue.length > 0) {
148
- const id = queue.shift()!
149
- const task = taskById.get(id)
150
- if (task) ordered.push(task)
151
-
152
- for (const successorId of successors.get(id) ?? []) {
153
- const newDegree = (inDegree.get(successorId) ?? 0) - 1
154
- inDegree.set(successorId, newDegree)
155
- if (newDegree === 0) queue.push(successorId)
156
- }
157
- }
158
-
159
- return ordered
160
- }
161
-
162
- // ---------------------------------------------------------------------------
163
- // Validation
164
- // ---------------------------------------------------------------------------
165
-
166
- /**
167
- * Validates the dependency graph of a task collection.
168
- *
169
- * Checks for:
170
- * - References to unknown task IDs in `dependsOn`.
171
- * - Cycles (a task depending on itself, directly or transitively).
172
- * - Self-dependencies (`task.dependsOn` includes its own `id`).
173
- *
174
- * @returns An object with `valid: true` when no issues were found, or
175
- * `valid: false` with a non-empty `errors` array describing each
176
- * problem.
177
- *
178
- * @example
179
- * ```ts
180
- * const { valid, errors } = validateTaskDependencies(tasks)
181
- * if (!valid) throw new Error(errors.join('\n'))
182
- * ```
183
- */
184
- export function validateTaskDependencies(tasks: Task[]): {
185
- valid: boolean
186
- errors: string[]
187
- } {
188
- const errors: string[] = []
189
- const taskById = new Map<string, Task>(tasks.map((t) => [t.id, t]))
190
-
191
- // Pass 1: check for unknown references and self-dependencies.
192
- for (const task of tasks) {
193
- for (const depId of task.dependsOn ?? []) {
194
- if (depId === task.id) {
195
- errors.push(
196
- `Task "${task.title}" (${task.id}) depends on itself.`,
197
- )
198
- continue
199
- }
200
- if (!taskById.has(depId)) {
201
- errors.push(
202
- `Task "${task.title}" (${task.id}) references unknown dependency "${depId}".`,
203
- )
204
- }
205
- }
206
- }
207
-
208
- // Pass 2: cycle detection via DFS colouring (white=0, grey=1, black=2).
209
- const colour = new Map<string, 0 | 1 | 2>()
210
- for (const task of tasks) colour.set(task.id, 0)
211
-
212
- const visit = (id: string, path: string[]): void => {
213
- if (colour.get(id) === 2) return // Already fully explored.
214
- if (colour.get(id) === 1) {
215
- // Found a back-edge — cycle.
216
- const cycleStart = path.indexOf(id)
217
- const cycle = path.slice(cycleStart).concat(id)
218
- errors.push(`Cyclic dependency detected: ${cycle.join(' -> ')}`)
219
- return
220
- }
221
-
222
- colour.set(id, 1)
223
- const task = taskById.get(id)
224
- for (const depId of task?.dependsOn ?? []) {
225
- if (taskById.has(depId)) {
226
- visit(depId, [...path, id])
227
- }
228
- }
229
- colour.set(id, 2)
230
- }
231
-
232
- for (const task of tasks) {
233
- if (colour.get(task.id) === 0) {
234
- visit(task.id, [])
235
- }
236
- }
237
-
238
- return { valid: errors.length === 0, errors }
239
- }