@markjaquith/agency 2.18.0 → 2.20.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 +55 -9
- package/cli.ts +22 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +4 -1
- package/src/cli-parser.test.ts +53 -2
- package/src/cli-parser.ts +78 -14
- package/src/cli.test.ts +11 -1
- package/src/commands/epic.test.ts +22 -0
- package/src/commands/epic.ts +38 -2
- package/src/commands/phase.ts +52 -2
- package/src/commands/status.test.ts +44 -0
- package/src/commands/status.ts +51 -2
- package/src/commands/task-phase.test.ts +20 -0
- package/src/commands/task.ts +49 -2
- package/src/commands/work.test.ts +105 -16
- package/src/commands/work.ts +99 -28
- package/src/services/WorkbaseService.test.ts +19 -0
- package/src/services/WorkbaseService.ts +12 -0
- package/src/utils/table.ts +19 -0
- package/src/work-view.test.ts +151 -0
- package/src/work-view.ts +253 -0
- package/src/workbase/runner-command.test.ts +79 -0
- package/src/workbase/runner-command.ts +118 -0
- package/src/workbase/schemas.test.ts +26 -0
- package/src/workbase/schemas.ts +15 -0
package/src/commands/work.ts
CHANGED
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
resolveWorkbase,
|
|
24
24
|
type PickWorkbase,
|
|
25
25
|
} from "../workbase/workbase-choice"
|
|
26
|
+
import {
|
|
27
|
+
printableEnvironment,
|
|
28
|
+
resolveRunnerCommand,
|
|
29
|
+
runnerEnvironment,
|
|
30
|
+
} from "../workbase/runner-command"
|
|
26
31
|
|
|
27
32
|
interface WorkOptions extends BaseCommandOptions {
|
|
28
33
|
readonly directory?: string
|
|
@@ -31,10 +36,17 @@ interface WorkOptions extends BaseCommandOptions {
|
|
|
31
36
|
readonly epicId?: string
|
|
32
37
|
readonly opencode?: boolean
|
|
33
38
|
readonly claude?: boolean
|
|
39
|
+
readonly runner?: string
|
|
40
|
+
readonly printCommand?: boolean
|
|
34
41
|
readonly force?: boolean
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
type LaunchAgent = (
|
|
44
|
+
type LaunchAgent = (
|
|
45
|
+
cli: string,
|
|
46
|
+
args: readonly string[],
|
|
47
|
+
cwd: string,
|
|
48
|
+
environment: Readonly<Record<string, string>>,
|
|
49
|
+
) => void
|
|
38
50
|
|
|
39
51
|
const formatCommand = (args: readonly string[]) =>
|
|
40
52
|
args
|
|
@@ -68,13 +80,15 @@ export const work = (
|
|
|
68
80
|
pickBase: PickWorkbase = pickWorkbase,
|
|
69
81
|
) =>
|
|
70
82
|
Effect.gen(function* () {
|
|
71
|
-
if (
|
|
83
|
+
if (
|
|
84
|
+
(options.opencode && options.claude) ||
|
|
85
|
+
(options.runner && (options.opencode || options.claude))
|
|
86
|
+
) {
|
|
72
87
|
return yield* Effect.fail(
|
|
73
|
-
new Error("Cannot
|
|
88
|
+
new Error("Cannot combine --runner, --opencode, and --claude"),
|
|
74
89
|
)
|
|
75
90
|
}
|
|
76
|
-
const
|
|
77
|
-
const previousClaimRevision = process.env.AGENCY_CLAIM_REVISION
|
|
91
|
+
const previousEnvironment = { ...process.env }
|
|
78
92
|
if (
|
|
79
93
|
options.epicId &&
|
|
80
94
|
(options.directory || options.taskId || options.phaseId)
|
|
@@ -104,6 +118,7 @@ export const work = (
|
|
|
104
118
|
const inputAllowed = options.inputAllowed ?? true
|
|
105
119
|
const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
|
|
106
120
|
if (!root) return
|
|
121
|
+
const { config } = yield* workbase.loadConfig(root)
|
|
107
122
|
|
|
108
123
|
let target: WorkTarget | null = null
|
|
109
124
|
if (options.epicId) {
|
|
@@ -199,7 +214,6 @@ export const work = (
|
|
|
199
214
|
new Error("No ready work targets found in this workbase"),
|
|
200
215
|
)
|
|
201
216
|
}
|
|
202
|
-
const { config } = yield* workbase.loadConfig(root)
|
|
203
217
|
target = yield* pick(choices, config.chooserCommand)
|
|
204
218
|
if (!target) return
|
|
205
219
|
}
|
|
@@ -233,13 +247,49 @@ export const work = (
|
|
|
233
247
|
launchPath = workspace.writablePath
|
|
234
248
|
}
|
|
235
249
|
|
|
236
|
-
const
|
|
237
|
-
|
|
250
|
+
const explicitlyRequested = Boolean(
|
|
251
|
+
options.runner ||
|
|
252
|
+
options.opencode ||
|
|
253
|
+
options.claude ||
|
|
254
|
+
process.env.AGENCY_RUNNER,
|
|
255
|
+
)
|
|
256
|
+
let runner =
|
|
257
|
+
options.runner ??
|
|
258
|
+
process.env.AGENCY_RUNNER ??
|
|
259
|
+
(options.claude ? "claude" : "opencode")
|
|
260
|
+
const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
|
|
261
|
+
const sessionId =
|
|
262
|
+
process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
|
|
263
|
+
const resume = process.env.AGENCY_SESSION_ID !== undefined
|
|
264
|
+
let claimRevision = ""
|
|
265
|
+
let variables = {
|
|
266
|
+
prompt,
|
|
267
|
+
workbase: root,
|
|
268
|
+
target: targetNodeId(target),
|
|
269
|
+
task: target.kind === "epic" ? "" : target.taskId,
|
|
270
|
+
phase: target.kind === "phase" ? target.phaseId : "",
|
|
271
|
+
claimant,
|
|
272
|
+
sessionId,
|
|
273
|
+
claimRevision,
|
|
274
|
+
}
|
|
275
|
+
let resolved = resolveRunnerCommand(
|
|
276
|
+
runner,
|
|
277
|
+
config.runners,
|
|
278
|
+
variables,
|
|
279
|
+
resume,
|
|
280
|
+
)
|
|
281
|
+
let cli = resolved.argv[0]!
|
|
238
282
|
let available = yield* fs.runCommand(["which", cli], {
|
|
239
283
|
captureOutput: true,
|
|
240
284
|
})
|
|
241
|
-
if (
|
|
242
|
-
|
|
285
|
+
if (
|
|
286
|
+
available.exitCode !== 0 &&
|
|
287
|
+
!explicitlyRequested &&
|
|
288
|
+
runner === "opencode"
|
|
289
|
+
) {
|
|
290
|
+
runner = "claude"
|
|
291
|
+
resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
|
|
292
|
+
cli = resolved.argv[0]!
|
|
243
293
|
available = yield* fs.runCommand(["which", cli], { captureOutput: true })
|
|
244
294
|
}
|
|
245
295
|
if (available.exitCode !== 0) {
|
|
@@ -251,36 +301,55 @@ export const work = (
|
|
|
251
301
|
) {
|
|
252
302
|
const phaseId = target.kind === "phase" ? target.phaseId : undefined
|
|
253
303
|
const current = yield* claims.inspect(target.taskId, phaseId, root)
|
|
254
|
-
const sessionId =
|
|
255
|
-
process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
|
|
256
304
|
const acquired = yield* claims.claim(
|
|
257
305
|
{
|
|
258
306
|
taskId: target.taskId,
|
|
259
307
|
...(phaseId ? { phaseId } : {}),
|
|
260
|
-
claimant
|
|
261
|
-
runner
|
|
308
|
+
claimant,
|
|
309
|
+
runner,
|
|
262
310
|
sessionId,
|
|
263
311
|
revision: current.revision,
|
|
264
312
|
},
|
|
265
313
|
root,
|
|
266
314
|
)
|
|
267
|
-
|
|
268
|
-
process.env.AGENCY_CLAIM_REVISION = acquired.revision
|
|
315
|
+
claimRevision = acquired.revision
|
|
269
316
|
}
|
|
270
317
|
|
|
271
|
-
|
|
272
|
-
|
|
318
|
+
variables = { ...variables, claimRevision }
|
|
319
|
+
resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
|
|
320
|
+
cli = resolved.argv[0]!
|
|
321
|
+
const environment = {
|
|
322
|
+
...resolved.environment,
|
|
323
|
+
...runnerEnvironment(runner, variables),
|
|
324
|
+
}
|
|
325
|
+
if (options.printCommand) {
|
|
326
|
+
log(
|
|
327
|
+
JSON.stringify(
|
|
328
|
+
{
|
|
329
|
+
cwd: launchPath,
|
|
330
|
+
argv: resolved.argv,
|
|
331
|
+
environment: printableEnvironment(environment),
|
|
332
|
+
},
|
|
333
|
+
null,
|
|
334
|
+
2,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
340
|
+
process.env[key] = value
|
|
341
|
+
}
|
|
273
342
|
verboseLog(
|
|
274
|
-
`Launching command: ${formatCommand(
|
|
343
|
+
`Launching command: ${formatCommand(resolved.argv)} (cwd: ${launchPath})`,
|
|
275
344
|
)
|
|
276
345
|
try {
|
|
277
|
-
launch(cli,
|
|
346
|
+
launch(cli, resolved.argv, launchPath, environment)
|
|
278
347
|
} finally {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
348
|
+
for (const key of Object.keys(environment)) {
|
|
349
|
+
const previous = previousEnvironment[key]
|
|
350
|
+
if (previous === undefined) delete process.env[key]
|
|
351
|
+
else process.env[key] = previous
|
|
352
|
+
}
|
|
284
353
|
}
|
|
285
354
|
})
|
|
286
355
|
|
|
@@ -344,7 +413,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
344
413
|
})
|
|
345
414
|
|
|
346
415
|
export const help = `
|
|
347
|
-
Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
|
|
416
|
+
Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>]
|
|
348
417
|
agency work prepare [target] [--dry-run] [--json]
|
|
349
418
|
|
|
350
419
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
@@ -362,8 +431,10 @@ Options:
|
|
|
362
431
|
--phase <id> Work on a phase selected with --task
|
|
363
432
|
--workbase <target> Select a workbase by ID, name, or path
|
|
364
433
|
--cwd <path> Resolve context from a specific directory
|
|
365
|
-
--
|
|
366
|
-
--
|
|
434
|
+
--runner <name> Select a configured runner or built-in preset
|
|
435
|
+
--print-command Print cwd, argv, and non-secret environment without launch
|
|
436
|
+
--opencode Require the OpenCode preset
|
|
437
|
+
--claude Require the Claude Code preset
|
|
367
438
|
--force Override readiness and terminal-state guards
|
|
368
439
|
--no-input Never open an interactive selector
|
|
369
440
|
|
|
@@ -235,6 +235,25 @@ describe("WorkbaseService", () => {
|
|
|
235
235
|
).rejects.toThrow("{worktree}")
|
|
236
236
|
})
|
|
237
237
|
|
|
238
|
+
test("rejects an unknown runner command placeholder", async () => {
|
|
239
|
+
await write(
|
|
240
|
+
root,
|
|
241
|
+
"agency.json",
|
|
242
|
+
JSON.stringify({
|
|
243
|
+
version: 2,
|
|
244
|
+
runners: { custom: { command: ["agent", "{unknown}"] } },
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
await expect(
|
|
249
|
+
runTestEffect(
|
|
250
|
+
WorkbaseService.pipe(
|
|
251
|
+
Effect.flatMap((service) => service.discover(root)),
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
).rejects.toThrow("{unknown}")
|
|
255
|
+
})
|
|
256
|
+
|
|
238
257
|
test("validates a workbase with an epic and multi-phase task", async () => {
|
|
239
258
|
await write(root, "agency.json", '{"version":2}\n')
|
|
240
259
|
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type WorkbaseRegistration,
|
|
22
22
|
} from "../workbase/schemas"
|
|
23
23
|
import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
|
|
24
|
+
import { validateRunners } from "../workbase/runner-command"
|
|
24
25
|
|
|
25
26
|
class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
|
|
26
27
|
readonly message: string
|
|
@@ -277,6 +278,17 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
277
278
|
})
|
|
278
279
|
}
|
|
279
280
|
}
|
|
281
|
+
try {
|
|
282
|
+
validateRunners(decoded.value.runners)
|
|
283
|
+
} catch (cause) {
|
|
284
|
+
return yield* new WorkbaseConfigError({
|
|
285
|
+
path: configPath,
|
|
286
|
+
message:
|
|
287
|
+
cause instanceof Error
|
|
288
|
+
? cause.message
|
|
289
|
+
: "Invalid runner configuration",
|
|
290
|
+
})
|
|
291
|
+
}
|
|
280
292
|
return current
|
|
281
293
|
}
|
|
282
294
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const formatTable = (
|
|
2
|
+
headings: readonly string[],
|
|
3
|
+
rows: readonly (readonly string[])[],
|
|
4
|
+
) => {
|
|
5
|
+
const widths = headings.map((heading, index) =>
|
|
6
|
+
Math.max(heading.length, ...rows.map((row) => row[index]?.length ?? 0)),
|
|
7
|
+
)
|
|
8
|
+
const line = (cells: readonly string[]) =>
|
|
9
|
+
cells
|
|
10
|
+
.map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
|
|
11
|
+
.join(" ")
|
|
12
|
+
.trimEnd()
|
|
13
|
+
|
|
14
|
+
return [
|
|
15
|
+
line(headings),
|
|
16
|
+
line(widths.map((width) => "-".repeat(width))),
|
|
17
|
+
...rows.map(line),
|
|
18
|
+
].join("\n")
|
|
19
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "./test-utils"
|
|
6
|
+
import { getWorkViews } from "./work-view"
|
|
7
|
+
|
|
8
|
+
const write = async (root: string, path: string, content: string) => {
|
|
9
|
+
const fullPath = join(root, path)
|
|
10
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
11
|
+
await Bun.write(fullPath, content)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("work views", () => {
|
|
15
|
+
let root: string
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
root = await createTempDir()
|
|
19
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
20
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
21
|
+
await write(
|
|
22
|
+
root,
|
|
23
|
+
"epics/delivery/EPIC.md",
|
|
24
|
+
`---
|
|
25
|
+
ticketUrl: https://example.com/delivery
|
|
26
|
+
repos:
|
|
27
|
+
- repo: agency
|
|
28
|
+
ref: main
|
|
29
|
+
tasks:
|
|
30
|
+
- id: zeta
|
|
31
|
+
- id: alpha
|
|
32
|
+
---
|
|
33
|
+
`,
|
|
34
|
+
)
|
|
35
|
+
await write(
|
|
36
|
+
root,
|
|
37
|
+
"tasks/zeta/TASK.md",
|
|
38
|
+
`---
|
|
39
|
+
ticketUrl: null
|
|
40
|
+
epic: delivery
|
|
41
|
+
phases:
|
|
42
|
+
- id: verify
|
|
43
|
+
dependsOn: [implement]
|
|
44
|
+
- id: implement
|
|
45
|
+
---
|
|
46
|
+
`,
|
|
47
|
+
)
|
|
48
|
+
await write(
|
|
49
|
+
root,
|
|
50
|
+
"tasks/zeta/phases/verify/PHASE.md",
|
|
51
|
+
`---
|
|
52
|
+
repo: agency
|
|
53
|
+
branch: feat/verify
|
|
54
|
+
base: main
|
|
55
|
+
pr: null
|
|
56
|
+
status: open
|
|
57
|
+
---
|
|
58
|
+
`,
|
|
59
|
+
)
|
|
60
|
+
await write(
|
|
61
|
+
root,
|
|
62
|
+
"tasks/zeta/phases/implement/PHASE.md",
|
|
63
|
+
`---
|
|
64
|
+
repo: agency
|
|
65
|
+
branch: feat/implement
|
|
66
|
+
base: main
|
|
67
|
+
pr: https://github.com/example/agency/pull/1
|
|
68
|
+
status: dropped
|
|
69
|
+
---
|
|
70
|
+
`,
|
|
71
|
+
)
|
|
72
|
+
await mkdir(join(root, "tasks/zeta/phases/implement/code/agency"), {
|
|
73
|
+
recursive: true,
|
|
74
|
+
})
|
|
75
|
+
await write(
|
|
76
|
+
root,
|
|
77
|
+
"tasks/alpha/TASK.md",
|
|
78
|
+
`---
|
|
79
|
+
ticketUrl: null
|
|
80
|
+
epic: delivery
|
|
81
|
+
repo: agency
|
|
82
|
+
branch: feat/alpha
|
|
83
|
+
base: main
|
|
84
|
+
pr: null
|
|
85
|
+
status: open
|
|
86
|
+
---
|
|
87
|
+
`,
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterEach(async () => cleanupTempDir(root))
|
|
92
|
+
|
|
93
|
+
const views = (options: Record<string, unknown> = {}) =>
|
|
94
|
+
runTestEffect(Effect.suspend(() => getWorkViews({ cwd: root, ...options })))
|
|
95
|
+
|
|
96
|
+
test("uses declared graph order and exposes operational state", async () => {
|
|
97
|
+
const result = await views()
|
|
98
|
+
|
|
99
|
+
expect(result.taskRows.map((row) => row.id)).toEqual(["zeta", "alpha"])
|
|
100
|
+
expect(result.phaseRows.map((row) => row.id)).toEqual([
|
|
101
|
+
"verify",
|
|
102
|
+
"implement",
|
|
103
|
+
])
|
|
104
|
+
expect(result.executionRows.map((row) => row.key)).toEqual([
|
|
105
|
+
"zeta/verify",
|
|
106
|
+
"zeta/implement",
|
|
107
|
+
"alpha",
|
|
108
|
+
])
|
|
109
|
+
expect(result.phaseRows[0]).toMatchObject({
|
|
110
|
+
parent: "zeta",
|
|
111
|
+
status: "open",
|
|
112
|
+
readiness: "blocked",
|
|
113
|
+
repositories: "agency",
|
|
114
|
+
branch: "feat/verify",
|
|
115
|
+
pr: "absent",
|
|
116
|
+
worktree: "absent",
|
|
117
|
+
})
|
|
118
|
+
expect(result.phaseRows[1]).toMatchObject({
|
|
119
|
+
readiness: "terminal",
|
|
120
|
+
pr: "present",
|
|
121
|
+
worktree: "materialized",
|
|
122
|
+
})
|
|
123
|
+
expect(result.taskRows[0]).toMatchObject({
|
|
124
|
+
parent: "delivery",
|
|
125
|
+
branch: "multiple",
|
|
126
|
+
pr: "1/2 present",
|
|
127
|
+
worktree: "1/2 materialized",
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("composes lifecycle, repository, readiness, and PR filters", async () => {
|
|
132
|
+
expect(
|
|
133
|
+
(await views({ statuses: ["dropped"] })).executionRows.map(
|
|
134
|
+
(row) => row.key,
|
|
135
|
+
),
|
|
136
|
+
).toEqual(["zeta/implement"])
|
|
137
|
+
expect(
|
|
138
|
+
(await views({ repositories: ["agency"], blocked: true })).phaseRows.map(
|
|
139
|
+
(row) => row.id,
|
|
140
|
+
),
|
|
141
|
+
).toEqual(["verify"])
|
|
142
|
+
expect(
|
|
143
|
+
(await views({ ready: true, pr: false })).executionRows.map(
|
|
144
|
+
(row) => row.key,
|
|
145
|
+
),
|
|
146
|
+
).toEqual(["alpha"])
|
|
147
|
+
expect((await views({ pr: true })).phaseRows.map((row) => row.id)).toEqual([
|
|
148
|
+
"implement",
|
|
149
|
+
])
|
|
150
|
+
})
|
|
151
|
+
})
|
package/src/work-view.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { GraphNode } from "./graph-schema"
|
|
3
|
+
import { GraphService } from "./services/GraphService"
|
|
4
|
+
import type { WorkStatus } from "./workbase/schemas"
|
|
5
|
+
|
|
6
|
+
type WorkNode = Extract<
|
|
7
|
+
GraphNode,
|
|
8
|
+
{ readonly kind: "epic" | "task" | "phase" | "execution-unit" }
|
|
9
|
+
>
|
|
10
|
+
type EntityNode = Exclude<WorkNode, { readonly kind: "execution-unit" }>
|
|
11
|
+
type ExecutionNode = Extract<WorkNode, { readonly kind: "execution-unit" }>
|
|
12
|
+
|
|
13
|
+
export interface WorkViewOptions {
|
|
14
|
+
readonly cwd?: string
|
|
15
|
+
readonly statuses?: readonly string[]
|
|
16
|
+
readonly repositories?: readonly string[]
|
|
17
|
+
readonly ready?: boolean
|
|
18
|
+
readonly blocked?: boolean
|
|
19
|
+
readonly pr?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkViewRow {
|
|
23
|
+
readonly kind: "epic" | "task" | "phase"
|
|
24
|
+
readonly id: string
|
|
25
|
+
readonly key: string
|
|
26
|
+
readonly parent: string
|
|
27
|
+
readonly status: WorkStatus
|
|
28
|
+
readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
|
|
29
|
+
readonly repositories: string
|
|
30
|
+
readonly branch: string
|
|
31
|
+
readonly pr: string
|
|
32
|
+
readonly worktree: string
|
|
33
|
+
readonly hasPr: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const allowedStatuses = new Set<WorkStatus>([
|
|
37
|
+
"open",
|
|
38
|
+
"working",
|
|
39
|
+
"delegated",
|
|
40
|
+
"done",
|
|
41
|
+
"dropped",
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const validatedStatuses = (values: readonly string[] | undefined) =>
|
|
45
|
+
(values ?? []).map((value) => {
|
|
46
|
+
if (!allowedStatuses.has(value as WorkStatus)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid --status value '${value}'. Expected one of: ${[...allowedStatuses].join(", ")}`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return value as WorkStatus
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const readinessLabel = (node: EntityNode): WorkViewRow["readiness"] =>
|
|
55
|
+
node.readiness.ready
|
|
56
|
+
? "ready"
|
|
57
|
+
: node.readiness.terminal
|
|
58
|
+
? "terminal"
|
|
59
|
+
: node.readiness.blocked
|
|
60
|
+
? "blocked"
|
|
61
|
+
: "waiting"
|
|
62
|
+
|
|
63
|
+
const aggregateLabel = (
|
|
64
|
+
executions: readonly ExecutionNode[],
|
|
65
|
+
predicate: (node: ExecutionNode) => boolean,
|
|
66
|
+
singular: readonly [present: string, absent: string],
|
|
67
|
+
) => {
|
|
68
|
+
if (executions.length === 0) return "-"
|
|
69
|
+
const count = executions.filter(predicate).length
|
|
70
|
+
if (executions.length === 1) return count === 1 ? singular[0] : singular[1]
|
|
71
|
+
return `${count}/${executions.length} ${singular[0]}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rowFor = (
|
|
75
|
+
node: EntityNode,
|
|
76
|
+
executions: readonly ExecutionNode[],
|
|
77
|
+
): WorkViewRow => {
|
|
78
|
+
const branches = [
|
|
79
|
+
...new Set(executions.map((execution) => execution.data.branch)),
|
|
80
|
+
]
|
|
81
|
+
const parent =
|
|
82
|
+
node.kind === "task"
|
|
83
|
+
? (node.data.epic ?? "-")
|
|
84
|
+
: node.kind === "phase"
|
|
85
|
+
? node.key.slice(0, node.key.indexOf("/"))
|
|
86
|
+
: "-"
|
|
87
|
+
const id =
|
|
88
|
+
node.kind === "phase" ? node.key.slice(node.key.indexOf("/") + 1) : node.key
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
kind: node.kind,
|
|
92
|
+
id,
|
|
93
|
+
key: node.key,
|
|
94
|
+
parent,
|
|
95
|
+
status: node.status,
|
|
96
|
+
readiness: readinessLabel(node),
|
|
97
|
+
repositories: node.repositories.join(",") || "-",
|
|
98
|
+
branch:
|
|
99
|
+
branches.length === 0
|
|
100
|
+
? "-"
|
|
101
|
+
: branches.length === 1
|
|
102
|
+
? branches[0]!
|
|
103
|
+
: "multiple",
|
|
104
|
+
pr: aggregateLabel(executions, (execution) => Boolean(execution.data.pr), [
|
|
105
|
+
"present",
|
|
106
|
+
"absent",
|
|
107
|
+
]),
|
|
108
|
+
worktree: aggregateLabel(
|
|
109
|
+
executions,
|
|
110
|
+
(execution) => execution.workspace?.materialized === true,
|
|
111
|
+
["materialized", "absent"],
|
|
112
|
+
),
|
|
113
|
+
hasPr: executions.some((execution) => Boolean(execution.data.pr)),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const orderedTasks = (
|
|
118
|
+
epics: readonly Extract<EntityNode, { readonly kind: "epic" }>[],
|
|
119
|
+
tasks: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "task" }>>,
|
|
120
|
+
) => {
|
|
121
|
+
const ordered: Extract<EntityNode, { readonly kind: "task" }>[] = []
|
|
122
|
+
const seen = new Set<string>()
|
|
123
|
+
for (const epic of epics) {
|
|
124
|
+
for (const item of epic.data.tasks) {
|
|
125
|
+
const task = tasks.get(item.id)
|
|
126
|
+
if (task && !seen.has(task.key)) {
|
|
127
|
+
ordered.push(task)
|
|
128
|
+
seen.add(task.key)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const task of [...tasks.values()].sort((a, b) =>
|
|
133
|
+
a.key.localeCompare(b.key),
|
|
134
|
+
)) {
|
|
135
|
+
if (!seen.has(task.key)) ordered.push(task)
|
|
136
|
+
}
|
|
137
|
+
return ordered
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const orderedPhases = (
|
|
141
|
+
task: Extract<EntityNode, { readonly kind: "task" }>,
|
|
142
|
+
phases: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "phase" }>>,
|
|
143
|
+
) => {
|
|
144
|
+
const ordered: Extract<EntityNode, { readonly kind: "phase" }>[] = []
|
|
145
|
+
const seen = new Set<string>()
|
|
146
|
+
if ("phases" in task.data) {
|
|
147
|
+
for (const item of task.data.phases) {
|
|
148
|
+
const phase = phases.get(`${task.key}/${item.id}`)
|
|
149
|
+
if (phase) {
|
|
150
|
+
ordered.push(phase)
|
|
151
|
+
seen.add(phase.key)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const phase of [...phases.values()]
|
|
156
|
+
.filter((value) => value.key.startsWith(`${task.key}/`))
|
|
157
|
+
.sort((a, b) => a.key.localeCompare(b.key))) {
|
|
158
|
+
if (!seen.has(phase.key)) ordered.push(phase)
|
|
159
|
+
}
|
|
160
|
+
return ordered
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const getWorkViews = (options: WorkViewOptions = {}) =>
|
|
164
|
+
Effect.gen(function* () {
|
|
165
|
+
const graphService = yield* GraphService
|
|
166
|
+
const statuses = yield* Effect.sync(() =>
|
|
167
|
+
validatedStatuses(options.statuses),
|
|
168
|
+
)
|
|
169
|
+
const graph = yield* graphService.get({
|
|
170
|
+
cwd: options.cwd,
|
|
171
|
+
include: ["workspace"],
|
|
172
|
+
})
|
|
173
|
+
const workNodes = graph.nodes.filter(
|
|
174
|
+
(node): node is WorkNode => node.kind !== "repository",
|
|
175
|
+
)
|
|
176
|
+
const epics = workNodes
|
|
177
|
+
.filter(
|
|
178
|
+
(node): node is Extract<EntityNode, { readonly kind: "epic" }> =>
|
|
179
|
+
node.kind === "epic",
|
|
180
|
+
)
|
|
181
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
182
|
+
const tasks = new Map(
|
|
183
|
+
workNodes
|
|
184
|
+
.filter(
|
|
185
|
+
(node): node is Extract<EntityNode, { readonly kind: "task" }> =>
|
|
186
|
+
node.kind === "task",
|
|
187
|
+
)
|
|
188
|
+
.map((node) => [node.key, node]),
|
|
189
|
+
)
|
|
190
|
+
const phases = new Map(
|
|
191
|
+
workNodes
|
|
192
|
+
.filter(
|
|
193
|
+
(node): node is Extract<EntityNode, { readonly kind: "phase" }> =>
|
|
194
|
+
node.kind === "phase",
|
|
195
|
+
)
|
|
196
|
+
.map((node) => [node.key, node]),
|
|
197
|
+
)
|
|
198
|
+
const executions = workNodes.filter(
|
|
199
|
+
(node): node is ExecutionNode => node.kind === "execution-unit",
|
|
200
|
+
)
|
|
201
|
+
const taskOrder = orderedTasks(epics, tasks)
|
|
202
|
+
const phaseOrder = taskOrder.flatMap((task) => orderedPhases(task, phases))
|
|
203
|
+
const executionsFor = (node: EntityNode) => {
|
|
204
|
+
if (node.kind === "phase") {
|
|
205
|
+
const separator = node.key.indexOf("/")
|
|
206
|
+
const taskId = node.key.slice(0, separator)
|
|
207
|
+
const phaseId = node.key.slice(separator + 1)
|
|
208
|
+
return executions.filter(
|
|
209
|
+
(execution) =>
|
|
210
|
+
execution.data.taskId === taskId &&
|
|
211
|
+
execution.data.phaseId === phaseId,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
if (node.kind === "task") {
|
|
215
|
+
return executions.filter(
|
|
216
|
+
(execution) => execution.data.taskId === node.key,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
const taskIds = new Set(node.data.tasks.map((item) => item.id))
|
|
220
|
+
return executions.filter((execution) =>
|
|
221
|
+
taskIds.has(execution.data.taskId),
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
const makeRows = (nodes: readonly EntityNode[]) =>
|
|
225
|
+
nodes.map((node) => rowFor(node, executionsFor(node)))
|
|
226
|
+
const filterRows = (rows: readonly WorkViewRow[]) =>
|
|
227
|
+
rows
|
|
228
|
+
.filter((row) => statuses.length === 0 || statuses.includes(row.status))
|
|
229
|
+
.filter(
|
|
230
|
+
(row) =>
|
|
231
|
+
!options.repositories?.length ||
|
|
232
|
+
row.repositories
|
|
233
|
+
.split(",")
|
|
234
|
+
.some((repo) => options.repositories!.includes(repo)),
|
|
235
|
+
)
|
|
236
|
+
.filter((row) => options.ready !== true || row.readiness === "ready")
|
|
237
|
+
.filter(
|
|
238
|
+
(row) => options.blocked !== true || row.readiness === "blocked",
|
|
239
|
+
)
|
|
240
|
+
.filter((row) => options.pr === undefined || row.hasPr === options.pr)
|
|
241
|
+
|
|
242
|
+
const epicRows = filterRows(makeRows(epics))
|
|
243
|
+
const taskRows = filterRows(makeRows(taskOrder))
|
|
244
|
+
const phaseRows = filterRows(makeRows(phaseOrder))
|
|
245
|
+
const executionEntities: EntityNode[] = []
|
|
246
|
+
for (const task of taskOrder) {
|
|
247
|
+
const taskPhases = orderedPhases(task, phases)
|
|
248
|
+
executionEntities.push(...(taskPhases.length > 0 ? taskPhases : [task]))
|
|
249
|
+
}
|
|
250
|
+
const executionRows = filterRows(makeRows(executionEntities))
|
|
251
|
+
|
|
252
|
+
return { epicRows, taskRows, phaseRows, executionRows }
|
|
253
|
+
})
|