@markjaquith/agency 2.10.0 → 2.12.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.
@@ -0,0 +1,860 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join, relative } from "node:path"
4
+ import {
5
+ GRAPH_VERSION,
6
+ type AgencyGraph,
7
+ type GraphBlocker,
8
+ type GraphEdge,
9
+ type GraphExecutionGit,
10
+ type GraphExecutionWorkspace,
11
+ type GraphInclude,
12
+ type GraphNode,
13
+ type GraphNodeKind,
14
+ type GraphPr,
15
+ type GraphRepositoryGit,
16
+ } from "../graph-schema"
17
+ import {
18
+ aggregateProgress,
19
+ isDependencySatisfied,
20
+ readinessState,
21
+ } from "../readiness"
22
+ import { parseFrontmatter } from "../workbase/frontmatter"
23
+ import {
24
+ EpicFrontmatter,
25
+ PhaseFrontmatter,
26
+ TaskFrontmatter,
27
+ type Dependency,
28
+ type EpicFrontmatter as EpicData,
29
+ type PhaseFrontmatter as PhaseData,
30
+ type TaskFrontmatter as TaskData,
31
+ type WorkStatus,
32
+ } from "../workbase/schemas"
33
+ import { FileSystemService } from "./FileSystemService"
34
+ import { WorkbaseService } from "./WorkbaseService"
35
+
36
+ class GraphError extends Data.TaggedError("GraphError")<{
37
+ readonly message: string
38
+ readonly path?: string
39
+ }> {}
40
+
41
+ interface Document<T> {
42
+ readonly id: string
43
+ readonly path: string
44
+ readonly sha256: string
45
+ readonly data: T
46
+ readonly body: string
47
+ }
48
+
49
+ interface RepositoryRecord {
50
+ readonly alias: string
51
+ readonly path: string
52
+ readonly target: string | null
53
+ }
54
+
55
+ type ExecutionData = PhaseData | Extract<TaskData, { readonly repo: string }>
56
+
57
+ export interface GraphOptions {
58
+ readonly cwd?: string
59
+ readonly ready?: boolean
60
+ readonly blocked?: boolean
61
+ readonly statuses?: readonly WorkStatus[]
62
+ readonly repositories?: readonly string[]
63
+ readonly kinds?: readonly GraphNodeKind[]
64
+ readonly include?: readonly GraphInclude[]
65
+ }
66
+
67
+ const epicNodeId = (id: string) => `epic:${id}`
68
+ const taskNodeId = (id: string) => `task:${id}`
69
+ const phaseNodeId = (taskId: string, phaseId: string) =>
70
+ `phase:${taskId}/${phaseId}`
71
+ const repositoryNodeId = (alias: string) => `repository:${alias}`
72
+ const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}`
73
+ const phaseExecutionNodeId = (taskId: string, phaseId: string) =>
74
+ `execution-unit:phase/${taskId}/${phaseId}`
75
+
76
+ const hash = (content: string) =>
77
+ new Bun.CryptoHasher("sha256").update(content).digest("hex")
78
+
79
+ const decode = <S extends Schema.Schema.AnyNoContext>(
80
+ schema: S,
81
+ input: unknown,
82
+ ) => {
83
+ const result = Schema.decodeUnknownEither(schema, {
84
+ errors: "all",
85
+ onExcessProperty: "error",
86
+ })(input)
87
+ return Either.isLeft(result)
88
+ ? { ok: false as const, error: TreeFormatter.formatErrorSync(result.left) }
89
+ : { ok: true as const, value: result.right }
90
+ }
91
+
92
+ const run = (fs: FileSystemService, args: readonly string[], cwd?: string) =>
93
+ fs.runCommand(args, { cwd, captureOutput: true }).pipe(
94
+ Effect.map((result) =>
95
+ result.exitCode === 0 ? result.stdout.trim() || null : null,
96
+ ),
97
+ Effect.catchAll(() => Effect.succeed(null)),
98
+ )
99
+
100
+ const runText = (
101
+ fs: FileSystemService,
102
+ args: readonly string[],
103
+ cwd?: string,
104
+ ) =>
105
+ fs.runCommand(args, { cwd, captureOutput: true }).pipe(
106
+ Effect.map((result) =>
107
+ result.exitCode === 0 ? result.stdout.trim() : null,
108
+ ),
109
+ Effect.catchAll(() => Effect.succeed(null)),
110
+ )
111
+
112
+ const edge = (
113
+ kind: GraphEdge["kind"],
114
+ from: string,
115
+ to: string,
116
+ ): GraphEdge => ({ id: `${kind}:${from}->${to}`, kind, from, to })
117
+
118
+ export class GraphService extends Effect.Service<GraphService>()(
119
+ "GraphService",
120
+ {
121
+ sync: () => ({
122
+ get: (options: GraphOptions = {}) =>
123
+ Effect.gen(function* () {
124
+ const fs = yield* FileSystemService
125
+ const workbase = yield* WorkbaseService
126
+ const { root, config } = yield* workbase.loadConfig(options.cwd)
127
+ const includes = [...new Set(options.include ?? [])].sort()
128
+ const include = new Set(includes)
129
+ const epics = new Map<string, Document<EpicData>>()
130
+ const tasks = new Map<string, Document<TaskData>>()
131
+ const phases = new Map<string, Document<PhaseData>>()
132
+
133
+ const directories = (path: string) =>
134
+ Effect.gen(function* () {
135
+ if (!(yield* fs.isDirectory(path))) return []
136
+ return (yield* fs.readDirectory(path))
137
+ .filter((entry) => entry.isDirectory)
138
+ .map((entry) => entry.name)
139
+ .sort()
140
+ })
141
+
142
+ const readDocument = <S extends Schema.Schema.AnyNoContext>(
143
+ id: string,
144
+ path: string,
145
+ schema: S,
146
+ ) =>
147
+ Effect.gen(function* () {
148
+ const content = yield* fs.readFile(path)
149
+ const parsed = yield* parseFrontmatter(content, path).pipe(
150
+ Effect.mapError(
151
+ (error) => new GraphError({ path, message: error.message }),
152
+ ),
153
+ )
154
+ const decoded = decode(schema, parsed.data)
155
+ if (!decoded.ok) {
156
+ return yield* new GraphError({
157
+ path,
158
+ message: `Invalid graph document ${path}:\n${decoded.error}`,
159
+ })
160
+ }
161
+ return {
162
+ id,
163
+ path,
164
+ sha256: hash(content),
165
+ data: decoded.value,
166
+ body: parsed.body,
167
+ } satisfies Document<Schema.Schema.Type<S>>
168
+ })
169
+
170
+ for (const id of yield* directories(join(root, "epics"))) {
171
+ const path = join(root, "epics", id, "EPIC.md")
172
+ if (yield* fs.exists(path)) {
173
+ epics.set(id, yield* readDocument(id, path, EpicFrontmatter))
174
+ }
175
+ }
176
+ for (const id of yield* directories(join(root, "tasks"))) {
177
+ const path = join(root, "tasks", id, "TASK.md")
178
+ if (yield* fs.exists(path)) {
179
+ tasks.set(id, yield* readDocument(id, path, TaskFrontmatter))
180
+ }
181
+ for (const phaseId of yield* directories(
182
+ join(root, "tasks", id, "phases"),
183
+ )) {
184
+ const phasePath = join(
185
+ root,
186
+ "tasks",
187
+ id,
188
+ "phases",
189
+ phaseId,
190
+ "PHASE.md",
191
+ )
192
+ if (yield* fs.exists(phasePath)) {
193
+ phases.set(
194
+ `${id}/${phaseId}`,
195
+ yield* readDocument(phaseId, phasePath, PhaseFrontmatter),
196
+ )
197
+ }
198
+ }
199
+ }
200
+
201
+ const repositoryRecords: RepositoryRecord[] = []
202
+ const reposPath = join(root, "repos")
203
+ if (yield* fs.isDirectory(reposPath)) {
204
+ for (const entry of (yield* fs.readDirectory(reposPath))
205
+ .filter((item) => item.isDirectory || item.isSymlink)
206
+ .sort((a, b) => a.name.localeCompare(b.name))) {
207
+ repositoryRecords.push({
208
+ alias: entry.name,
209
+ path: join(reposPath, entry.name),
210
+ target: entry.isSymlink
211
+ ? yield* fs.readSymlinkTarget(join(reposPath, entry.name))
212
+ : null,
213
+ })
214
+ }
215
+ }
216
+
217
+ const taskDeclarations = new Map<string, Dependency>()
218
+ for (const epic of epics.values()) {
219
+ for (const declaration of epic.data.tasks) {
220
+ taskDeclarations.set(declaration.id, declaration)
221
+ }
222
+ }
223
+ const taskDependencies = (taskId: string) =>
224
+ taskDeclarations.get(taskId)?.dependsOn ?? []
225
+ const taskLeafStatuses = (taskId: string): WorkStatus[] => {
226
+ const task = tasks.get(taskId)
227
+ if (!task) return []
228
+ return "phases" in task.data
229
+ ? task.data.phases.map(
230
+ (item) =>
231
+ phases.get(`${taskId}/${item.id}`)?.data.status ?? "open",
232
+ )
233
+ : [task.data.status]
234
+ }
235
+ const taskStatus = (taskId: string) =>
236
+ aggregateProgress(taskLeafStatuses(taskId)).status
237
+ const dependencyBlockers = (
238
+ dependencies: readonly string[],
239
+ toId: (id: string) => string,
240
+ status: (id: string) => WorkStatus | undefined,
241
+ label: string,
242
+ ): GraphBlocker[] =>
243
+ dependencies.flatMap((dependency) => {
244
+ const value = status(dependency)
245
+ return isDependencySatisfied(value)
246
+ ? []
247
+ : [
248
+ {
249
+ kind: "dependency" as const,
250
+ id: toId(dependency),
251
+ ...(value ? { status: value } : {}),
252
+ reason: value
253
+ ? `${label} dependency is ${value}`
254
+ : `${label} dependency is missing`,
255
+ },
256
+ ]
257
+ })
258
+
259
+ const validation = yield* workbase.validate(root)
260
+ const validationBlockers = (
261
+ paths: readonly string[],
262
+ ): GraphBlocker[] =>
263
+ validation.issues
264
+ .filter((issue) => paths.includes(issue.path))
265
+ .map((issue) => ({
266
+ kind: "validation" as const,
267
+ id: issue.path,
268
+ reason: issue.message,
269
+ }))
270
+ const uniqueBlockers = (blockers: readonly GraphBlocker[]) => [
271
+ ...new Map(
272
+ blockers.map((item) => [
273
+ `${item.kind}:${item.id}:${item.reason}`,
274
+ item,
275
+ ]),
276
+ ).values(),
277
+ ]
278
+
279
+ const phaseState = (taskId: string, phaseId: string) => {
280
+ const task = tasks.get(taskId)
281
+ const phase = phases.get(`${taskId}/${phaseId}`)
282
+ const declaration =
283
+ task && "phases" in task.data
284
+ ? task.data.phases.find((item) => item.id === phaseId)
285
+ : undefined
286
+ const blockers = [
287
+ ...dependencyBlockers(
288
+ declaration?.dependsOn ?? [],
289
+ (id) => phaseNodeId(taskId, id),
290
+ (id) => phases.get(`${taskId}/${id}`)?.data.status,
291
+ "Phase",
292
+ ),
293
+ ...dependencyBlockers(
294
+ taskDependencies(taskId),
295
+ taskNodeId,
296
+ (id) => (tasks.has(id) ? taskStatus(id) : undefined),
297
+ "Parent task",
298
+ ),
299
+ ...validationBlockers(
300
+ [phase?.path, task?.path]
301
+ .filter((path): path is string => Boolean(path))
302
+ .map((path) => relative(root, path)),
303
+ ),
304
+ ]
305
+ const status = phase?.data.status ?? "open"
306
+ if (status !== "open") {
307
+ blockers.push({
308
+ kind: "status",
309
+ id: phaseNodeId(taskId, phaseId),
310
+ status,
311
+ reason: `Phase status is ${status}`,
312
+ })
313
+ }
314
+ return {
315
+ status,
316
+ aggregate: aggregateProgress([status]),
317
+ readiness: {
318
+ ...readinessState(status, blockers),
319
+ blockers: uniqueBlockers(blockers),
320
+ },
321
+ }
322
+ }
323
+
324
+ const taskState = (taskId: string) => {
325
+ const task = tasks.get(taskId)
326
+ const statuses = taskLeafStatuses(taskId)
327
+ const aggregate = aggregateProgress(statuses)
328
+ const descendantPaths = task
329
+ ? [
330
+ relative(root, task.path),
331
+ ...[...phases.entries()]
332
+ .filter(([key]) => key.startsWith(`${taskId}/`))
333
+ .map(([, phase]) => relative(root, phase.path)),
334
+ ]
335
+ : []
336
+ const blockers = [
337
+ ...dependencyBlockers(
338
+ taskDependencies(taskId),
339
+ taskNodeId,
340
+ (id) => (tasks.has(id) ? taskStatus(id) : undefined),
341
+ "Task",
342
+ ),
343
+ ...validationBlockers(descendantPaths),
344
+ ]
345
+ let ready = false
346
+ if (task && "phases" in task.data) {
347
+ ready =
348
+ blockers.every((item) => item.kind !== "validation") &&
349
+ taskDependencies(taskId).every(
350
+ (id) => taskStatus(id) === "done",
351
+ ) &&
352
+ task.data.phases.some(
353
+ (item) => phaseState(taskId, item.id).readiness.ready,
354
+ )
355
+ for (const item of task.data.phases) {
356
+ const state = phaseState(taskId, item.id)
357
+ blockers.push(
358
+ ...state.readiness.blockers.filter(
359
+ (blocker) =>
360
+ blocker.kind === "dependency" ||
361
+ blocker.kind === "validation",
362
+ ),
363
+ )
364
+ if (state.status === "dropped") {
365
+ blockers.push({
366
+ kind: "status",
367
+ id: phaseNodeId(taskId, item.id),
368
+ status: "dropped",
369
+ reason: "Child phase is dropped",
370
+ })
371
+ }
372
+ }
373
+ } else if (task && "status" in task.data) {
374
+ ready = task.data.status === "open" && blockers.length === 0
375
+ if (task.data.status !== "open") {
376
+ blockers.push({
377
+ kind: "status",
378
+ id: taskNodeId(taskId),
379
+ status: task.data.status,
380
+ reason: `Task status is ${task.data.status}`,
381
+ })
382
+ }
383
+ }
384
+ const taskBlockers = uniqueBlockers(blockers)
385
+ return {
386
+ status: aggregate.status,
387
+ aggregate,
388
+ readiness: {
389
+ ...readinessState(aggregate.status, taskBlockers, ready),
390
+ blockers: taskBlockers,
391
+ },
392
+ }
393
+ }
394
+
395
+ const epicState = (epicId: string) => {
396
+ const epic = epics.get(epicId)
397
+ const statuses =
398
+ epic?.data.tasks.flatMap((item) => taskLeafStatuses(item.id)) ??
399
+ []
400
+ const aggregate = aggregateProgress(statuses)
401
+ const paths = epic
402
+ ? [
403
+ relative(root, epic.path),
404
+ ...epic.data.tasks.flatMap((item) => {
405
+ const task = tasks.get(item.id)
406
+ return [
407
+ ...(task ? [relative(root, task.path)] : []),
408
+ ...[...phases.entries()]
409
+ .filter(([key]) => key.startsWith(`${item.id}/`))
410
+ .map(([, phase]) => relative(root, phase.path)),
411
+ ]
412
+ }),
413
+ ]
414
+ : []
415
+ const blockers = validationBlockers(paths)
416
+ for (const item of epic?.data.tasks ?? []) {
417
+ const childState = taskState(item.id)
418
+ blockers.push(
419
+ ...childState.readiness.blockers.filter(
420
+ (blocker) =>
421
+ blocker.kind !== "status" || blocker.status === "dropped",
422
+ ),
423
+ )
424
+ if (taskStatus(item.id) === "dropped") {
425
+ blockers.push({
426
+ kind: "status",
427
+ id: taskNodeId(item.id),
428
+ status: "dropped",
429
+ reason: "Child task is dropped",
430
+ })
431
+ }
432
+ }
433
+ const ready =
434
+ !blockers.some((item) => item.kind === "validation") &&
435
+ Boolean(
436
+ epic?.data.tasks.some(
437
+ (item) => taskState(item.id).readiness.ready,
438
+ ),
439
+ )
440
+ const epicBlockers = uniqueBlockers(blockers)
441
+ return {
442
+ status: aggregate.status,
443
+ aggregate,
444
+ readiness: {
445
+ ...readinessState(aggregate.status, epicBlockers, ready),
446
+ blockers: epicBlockers,
447
+ },
448
+ }
449
+ }
450
+
451
+ const edges: GraphEdge[] = []
452
+ for (const epic of epics.values()) {
453
+ for (const item of epic.data.tasks) {
454
+ edges.push(edge("owns", epicNodeId(epic.id), taskNodeId(item.id)))
455
+ for (const dependency of item.dependsOn ?? []) {
456
+ edges.push(
457
+ edge(
458
+ "depends_on",
459
+ taskNodeId(item.id),
460
+ taskNodeId(dependency),
461
+ ),
462
+ )
463
+ }
464
+ }
465
+ }
466
+ for (const task of tasks.values()) {
467
+ if ("phases" in task.data) {
468
+ for (const item of task.data.phases) {
469
+ edges.push(
470
+ edge(
471
+ "owns",
472
+ taskNodeId(task.id),
473
+ phaseNodeId(task.id, item.id),
474
+ ),
475
+ )
476
+ for (const dependency of item.dependsOn ?? []) {
477
+ edges.push(
478
+ edge(
479
+ "depends_on",
480
+ phaseNodeId(task.id, item.id),
481
+ phaseNodeId(task.id, dependency),
482
+ ),
483
+ )
484
+ }
485
+ }
486
+ } else {
487
+ edges.push(
488
+ edge("owns", taskNodeId(task.id), taskExecutionNodeId(task.id)),
489
+ )
490
+ }
491
+ }
492
+ for (const [key, phase] of phases) {
493
+ const taskId = key.slice(0, key.indexOf("/"))
494
+ edges.push(
495
+ edge(
496
+ "owns",
497
+ phaseNodeId(taskId, phase.id),
498
+ phaseExecutionNodeId(taskId, phase.id),
499
+ ),
500
+ )
501
+ }
502
+
503
+ const executionRepositories = (
504
+ data: TaskData | PhaseData,
505
+ ): readonly string[] =>
506
+ "repo" in data
507
+ ? [data.repo, ...(data.repos ?? []).map((item) => item.repo)]
508
+ : []
509
+ const executionEdges = (id: string, data: TaskData | PhaseData) => {
510
+ if (!("repo" in data)) return
511
+ edges.push(edge("writes", id, repositoryNodeId(data.repo)))
512
+ for (const reference of data.repos ?? []) {
513
+ edges.push(
514
+ edge("references", id, repositoryNodeId(reference.repo)),
515
+ )
516
+ }
517
+ }
518
+ for (const epic of epics.values()) {
519
+ for (const reference of epic.data.repos) {
520
+ edges.push(
521
+ edge(
522
+ "references",
523
+ epicNodeId(epic.id),
524
+ repositoryNodeId(reference.repo),
525
+ ),
526
+ )
527
+ }
528
+ }
529
+ for (const task of tasks.values()) {
530
+ if (!("phases" in task.data)) {
531
+ executionEdges(taskExecutionNodeId(task.id), task.data)
532
+ }
533
+ }
534
+ for (const [key, phase] of phases) {
535
+ const taskId = key.slice(0, key.indexOf("/"))
536
+ executionEdges(phaseExecutionNodeId(taskId, phase.id), phase.data)
537
+ }
538
+
539
+ const reverseDependencies = new Map<string, string[]>()
540
+ for (const item of edges.filter(
541
+ (value) => value.kind === "depends_on",
542
+ )) {
543
+ const dependents = reverseDependencies.get(item.to) ?? []
544
+ dependents.push(item.from)
545
+ reverseDependencies.set(item.to, dependents)
546
+ }
547
+ const dependents = (id: string) =>
548
+ [...(reverseDependencies.get(id) ?? [])].sort()
549
+
550
+ const documentDetails = <T extends Record<string, unknown>>(
551
+ document: Document<T>,
552
+ ) => ({
553
+ data: { ...document.data, sha256: document.sha256 },
554
+ ...(include.has("bodies") ? { body: document.body } : {}),
555
+ ...(include.has("workspace")
556
+ ? {
557
+ workspace: {
558
+ documentPath: document.path,
559
+ directory: document.path.replace(
560
+ /\/(?:EPIC|TASK|PHASE)\.md$/,
561
+ "",
562
+ ),
563
+ },
564
+ }
565
+ : {}),
566
+ })
567
+
568
+ const inspectGit = (path: string) =>
569
+ Effect.gen(function* () {
570
+ const bare = yield* run(fs, [
571
+ "git",
572
+ "-C",
573
+ path,
574
+ "rev-parse",
575
+ "--is-bare-repository",
576
+ ])
577
+ return {
578
+ kind:
579
+ bare === null
580
+ ? null
581
+ : bare === "true"
582
+ ? "bare"
583
+ : "repository",
584
+ remote: yield* run(fs, [
585
+ "git",
586
+ "-C",
587
+ path,
588
+ "remote",
589
+ "get-url",
590
+ "origin",
591
+ ]),
592
+ head: yield* run(fs, ["git", "-C", path, "rev-parse", "HEAD"]),
593
+ branch: yield* run(fs, [
594
+ "git",
595
+ "-C",
596
+ path,
597
+ "symbolic-ref",
598
+ "--quiet",
599
+ "--short",
600
+ "HEAD",
601
+ ]),
602
+ } satisfies GraphRepositoryGit
603
+ })
604
+
605
+ const executionDetails = (entityPath: string, data: ExecutionData) =>
606
+ Effect.gen(function* () {
607
+ const directory = entityPath.replace(/\/(?:TASK|PHASE)\.md$/, "")
608
+ const checkoutPath = join(directory, "code", data.repo)
609
+ const repositoryPath = join(root, "repos", data.repo)
610
+ const materialized = yield* fs.isDirectory(checkoutPath)
611
+ const result: {
612
+ workspace?: GraphExecutionWorkspace
613
+ git?: GraphExecutionGit
614
+ pr?: GraphPr
615
+ } = {}
616
+ if (include.has("workspace")) {
617
+ result.workspace = {
618
+ codePath: join(directory, "code"),
619
+ checkoutPath,
620
+ materialized,
621
+ }
622
+ }
623
+ if (include.has("git")) {
624
+ result.git = {
625
+ branch: data.branch,
626
+ base: data.base,
627
+ branchCommit: yield* run(fs, [
628
+ "git",
629
+ "-C",
630
+ repositoryPath,
631
+ "rev-parse",
632
+ `${data.branch}^{commit}`,
633
+ ]),
634
+ baseCommit: yield* run(fs, [
635
+ "git",
636
+ "-C",
637
+ repositoryPath,
638
+ "rev-parse",
639
+ `${data.base}^{commit}`,
640
+ ]),
641
+ checkoutCommit: materialized
642
+ ? yield* run(fs, [
643
+ "git",
644
+ "-C",
645
+ checkoutPath,
646
+ "rev-parse",
647
+ "HEAD",
648
+ ])
649
+ : null,
650
+ checkoutBranch: materialized
651
+ ? yield* run(fs, [
652
+ "git",
653
+ "-C",
654
+ checkoutPath,
655
+ "symbolic-ref",
656
+ "--quiet",
657
+ "--short",
658
+ "HEAD",
659
+ ])
660
+ : null,
661
+ dirty: materialized
662
+ ? ((status) =>
663
+ status === null ? null : status.length > 0)(
664
+ yield* runText(fs, [
665
+ "git",
666
+ "-C",
667
+ checkoutPath,
668
+ "status",
669
+ "--porcelain",
670
+ ]),
671
+ )
672
+ : null,
673
+ }
674
+ }
675
+ if (include.has("pr")) {
676
+ if (!data.pr) {
677
+ result.pr = { url: null, state: "none" }
678
+ } else {
679
+ const detail = yield* run(fs, [
680
+ "gh",
681
+ "pr",
682
+ "view",
683
+ data.pr,
684
+ "--json",
685
+ "number,state,title,isDraft,headRefName,baseRefName,url",
686
+ ])
687
+ result.pr = detail
688
+ ? { ...JSON.parse(detail), recordedUrl: data.pr }
689
+ : { url: data.pr, state: "unavailable" }
690
+ }
691
+ }
692
+ return result
693
+ })
694
+
695
+ const nodes: GraphNode[] = []
696
+ for (const epic of epics.values()) {
697
+ const state = epicState(epic.id)
698
+ nodes.push({
699
+ id: epicNodeId(epic.id),
700
+ kind: "epic",
701
+ key: epic.id,
702
+ ...state,
703
+ dependents: dependents(epicNodeId(epic.id)),
704
+ repositories: epic.data.repos.map((item) => item.repo).sort(),
705
+ ...documentDetails(epic),
706
+ })
707
+ }
708
+ for (const task of tasks.values()) {
709
+ const state = taskState(task.id)
710
+ const repositories =
711
+ "phases" in task.data
712
+ ? [
713
+ ...new Set(
714
+ task.data.phases.flatMap((item) => {
715
+ const phase = phases.get(`${task.id}/${item.id}`)
716
+ return phase ? executionRepositories(phase.data) : []
717
+ }),
718
+ ),
719
+ ].sort()
720
+ : [...executionRepositories(task.data)].sort()
721
+ nodes.push({
722
+ id: taskNodeId(task.id),
723
+ kind: "task",
724
+ key: task.id,
725
+ ...state,
726
+ dependents: dependents(taskNodeId(task.id)),
727
+ repositories,
728
+ ...documentDetails(task),
729
+ })
730
+ if (!("phases" in task.data)) {
731
+ nodes.push({
732
+ id: taskExecutionNodeId(task.id),
733
+ kind: "execution-unit",
734
+ key: `task/${task.id}`,
735
+ ...state,
736
+ dependents: dependents(taskNodeId(task.id)),
737
+ repositories,
738
+ data: { taskId: task.id, ...task.data },
739
+ ...(yield* executionDetails(task.path, task.data)),
740
+ })
741
+ }
742
+ }
743
+ for (const [key, phase] of phases) {
744
+ const taskId = key.slice(0, key.indexOf("/"))
745
+ const state = phaseState(taskId, phase.id)
746
+ const repositories = [...executionRepositories(phase.data)].sort()
747
+ nodes.push({
748
+ id: phaseNodeId(taskId, phase.id),
749
+ kind: "phase",
750
+ key,
751
+ ...state,
752
+ dependents: dependents(phaseNodeId(taskId, phase.id)),
753
+ repositories,
754
+ ...documentDetails(phase),
755
+ })
756
+ nodes.push({
757
+ id: phaseExecutionNodeId(taskId, phase.id),
758
+ kind: "execution-unit",
759
+ key: `phase/${key}`,
760
+ ...state,
761
+ dependents: dependents(phaseNodeId(taskId, phase.id)),
762
+ repositories,
763
+ data: { taskId, phaseId: phase.id, ...phase.data },
764
+ ...(yield* executionDetails(phase.path, phase.data)),
765
+ })
766
+ }
767
+ for (const repository of repositoryRecords) {
768
+ nodes.push({
769
+ id: repositoryNodeId(repository.alias),
770
+ kind: "repository",
771
+ key: repository.alias,
772
+ status: null,
773
+ readiness: null,
774
+ aggregate: null,
775
+ dependents: [],
776
+ repositories: [repository.alias],
777
+ data: { alias: repository.alias },
778
+ ...(include.has("workspace")
779
+ ? {
780
+ workspace: {
781
+ path: repository.path,
782
+ target: repository.target,
783
+ },
784
+ }
785
+ : {}),
786
+ ...(include.has("git")
787
+ ? { git: yield* inspectGit(repository.path) }
788
+ : {}),
789
+ })
790
+ }
791
+
792
+ const filters = {
793
+ ready: options.ready ?? null,
794
+ blocked: options.blocked ?? null,
795
+ statuses: [...(options.statuses ?? [])].sort(),
796
+ repositories: [...(options.repositories ?? [])].sort(),
797
+ kinds: [...(options.kinds ?? [])].sort(),
798
+ }
799
+ const filteredNodes = nodes
800
+ .filter(
801
+ (node) =>
802
+ options.ready === undefined ||
803
+ node.readiness?.ready === options.ready,
804
+ )
805
+ .filter(
806
+ (node) =>
807
+ options.blocked === undefined ||
808
+ node.readiness?.blocked === options.blocked,
809
+ )
810
+ .filter(
811
+ (node) =>
812
+ !options.statuses?.length ||
813
+ (node.status !== null &&
814
+ options.statuses.includes(node.status)),
815
+ )
816
+ .filter(
817
+ (node) =>
818
+ !options.repositories?.length ||
819
+ node.repositories.some((alias) =>
820
+ options.repositories!.includes(alias),
821
+ ),
822
+ )
823
+ .filter(
824
+ (node) =>
825
+ !options.kinds?.length || options.kinds.includes(node.kind),
826
+ )
827
+ .sort((a, b) => a.id.localeCompare(b.id))
828
+ const nodeIds = new Set(filteredNodes.map((node) => node.id))
829
+ const filteredEdges = edges
830
+ .filter((item) => nodeIds.has(item.from) && nodeIds.has(item.to))
831
+ .sort((a, b) => a.id.localeCompare(b.id))
832
+ const leafStatuses = [
833
+ ...tasks
834
+ .values()
835
+ .flatMap((task) =>
836
+ "phases" in task.data ? [] : [task.data.status],
837
+ ),
838
+ ...phases.values().map((phase) => phase.data.status),
839
+ ]
840
+
841
+ return {
842
+ version: GRAPH_VERSION,
843
+ workbase: {
844
+ version: config.version,
845
+ ...(include.has("workspace") ? { root } : {}),
846
+ },
847
+ filters,
848
+ includes,
849
+ nodes: filteredNodes,
850
+ edges: filteredEdges,
851
+ summary: aggregateProgress(leafStatuses),
852
+ validation: {
853
+ valid: validation.valid,
854
+ issues: validation.issues,
855
+ },
856
+ } satisfies AgencyGraph
857
+ }),
858
+ }),
859
+ },
860
+ ) {}