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