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