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