@markjaquith/agency 2.16.0 → 2.18.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 +33 -4
- package/cli.ts +93 -8
- package/package.json +1 -1
- package/skills/agency/SKILL.md +13 -6
- package/src/cli-parser.test.ts +93 -0
- package/src/cli-parser.ts +229 -29
- package/src/cli.test.ts +144 -4
- package/src/commands/context.ts +3 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/next.ts +64 -0
- package/src/commands/pr.ts +2 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/validate.test.ts +2 -0
- package/src/commands/work.test.ts +85 -0
- package/src/commands/work.ts +40 -9
- package/src/commands/workbase.test.ts +63 -2
- package/src/commands/workbase.ts +77 -10
- package/src/protocol.ts +6 -0
- package/src/services/GraphService.test.ts +36 -0
- package/src/services/GraphService.ts +8 -1
- package/src/services/PullRequestService.test.ts +20 -1
- package/src/services/PullRequestService.ts +14 -1
- package/src/services/ReadinessService.test.ts +223 -0
- package/src/services/ReadinessService.ts +230 -0
- package/src/services/WorkbaseService.test.ts +147 -4
- package/src/services/WorkbaseService.ts +214 -12
- package/src/services/WorktreeService.test.ts +12 -0
- package/src/services/WorktreeService.ts +6 -2
- package/src/test-utils.ts +2 -0
- package/src/workbase/schemas.test.ts +17 -6
- package/src/workbase/schemas.ts +16 -1
- package/src/workbase/workbase-choice.ts +2 -0
|
@@ -5,6 +5,7 @@ import { WorktreeService } from "./WorktreeService"
|
|
|
5
5
|
import type { BaseCommandOptions } from "../utils/command"
|
|
6
6
|
import { TaskService } from "./TaskService"
|
|
7
7
|
import { PhaseService } from "./PhaseService"
|
|
8
|
+
import { ReadinessService } from "./ReadinessService"
|
|
8
9
|
import {
|
|
9
10
|
formatMarkdownDocument,
|
|
10
11
|
parseFrontmatter,
|
|
@@ -16,6 +17,10 @@ class PullRequestError extends Data.TaggedError("PullRequestError")<{
|
|
|
16
17
|
|
|
17
18
|
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
18
19
|
|
|
20
|
+
interface PullRequestOptions extends BaseCommandOptions {
|
|
21
|
+
readonly force?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
20
25
|
"PullRequestService",
|
|
21
26
|
{
|
|
@@ -60,7 +65,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
60
65
|
phaseId?: string,
|
|
61
66
|
draft = false,
|
|
62
67
|
startPath: string = process.cwd(),
|
|
63
|
-
options:
|
|
68
|
+
options: PullRequestOptions = {},
|
|
64
69
|
) =>
|
|
65
70
|
Effect.gen(function* () {
|
|
66
71
|
const service = yield* PullRequestService
|
|
@@ -68,6 +73,14 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
68
73
|
const tasks = yield* TaskService
|
|
69
74
|
const phases = yield* PhaseService
|
|
70
75
|
const worktrees = yield* WorktreeService
|
|
76
|
+
const readiness = yield* ReadinessService
|
|
77
|
+
yield* readiness.guard(
|
|
78
|
+
"pr",
|
|
79
|
+
taskId,
|
|
80
|
+
phaseId,
|
|
81
|
+
startPath,
|
|
82
|
+
options.force,
|
|
83
|
+
)
|
|
71
84
|
const workspace = yield* worktrees.materialize(
|
|
72
85
|
taskId,
|
|
73
86
|
phaseId,
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, 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 { ReadinessService } from "./ReadinessService"
|
|
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
|
+
const execution = (status: string, branch: string) => `---
|
|
15
|
+
repo: agency
|
|
16
|
+
branch: ${branch}
|
|
17
|
+
base: main
|
|
18
|
+
pr: null
|
|
19
|
+
status: ${status}
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Execution
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const createWorkbase = async () => {
|
|
26
|
+
const root = await createTempDir()
|
|
27
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
28
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
29
|
+
await write(
|
|
30
|
+
root,
|
|
31
|
+
"epics/delivery/EPIC.md",
|
|
32
|
+
`---
|
|
33
|
+
ticketUrl: https://example.com/delivery
|
|
34
|
+
repos:
|
|
35
|
+
- repo: agency
|
|
36
|
+
ref: main
|
|
37
|
+
tasks:
|
|
38
|
+
- id: prepare
|
|
39
|
+
- id: ship
|
|
40
|
+
dependsOn: [prepare]
|
|
41
|
+
- id: deploy
|
|
42
|
+
dependsOn: [ship]
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Delivery
|
|
46
|
+
`,
|
|
47
|
+
)
|
|
48
|
+
await write(
|
|
49
|
+
root,
|
|
50
|
+
"tasks/prepare/TASK.md",
|
|
51
|
+
`---
|
|
52
|
+
ticketUrl: null
|
|
53
|
+
epic: delivery
|
|
54
|
+
repo: agency
|
|
55
|
+
branch: feat/prepare
|
|
56
|
+
base: main
|
|
57
|
+
pr: null
|
|
58
|
+
status: done
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# Prepare
|
|
62
|
+
`,
|
|
63
|
+
)
|
|
64
|
+
await write(
|
|
65
|
+
root,
|
|
66
|
+
"tasks/ship/TASK.md",
|
|
67
|
+
`---
|
|
68
|
+
ticketUrl: null
|
|
69
|
+
description: Ship the feature.
|
|
70
|
+
epic: delivery
|
|
71
|
+
phases:
|
|
72
|
+
- id: implement
|
|
73
|
+
- id: verify
|
|
74
|
+
dependsOn: [implement]
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Ship
|
|
78
|
+
`,
|
|
79
|
+
)
|
|
80
|
+
await write(
|
|
81
|
+
root,
|
|
82
|
+
"tasks/ship/phases/implement/PHASE.md",
|
|
83
|
+
execution("open", "feat/implement"),
|
|
84
|
+
)
|
|
85
|
+
await write(
|
|
86
|
+
root,
|
|
87
|
+
"tasks/ship/phases/verify/PHASE.md",
|
|
88
|
+
execution("open", "feat/verify"),
|
|
89
|
+
)
|
|
90
|
+
await write(
|
|
91
|
+
root,
|
|
92
|
+
"tasks/abandoned/TASK.md",
|
|
93
|
+
`---
|
|
94
|
+
ticketUrl: null
|
|
95
|
+
repo: agency
|
|
96
|
+
branch: feat/abandoned
|
|
97
|
+
base: main
|
|
98
|
+
pr: null
|
|
99
|
+
status: dropped
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
# Abandoned
|
|
103
|
+
`,
|
|
104
|
+
)
|
|
105
|
+
await write(
|
|
106
|
+
root,
|
|
107
|
+
"tasks/deploy/TASK.md",
|
|
108
|
+
`---
|
|
109
|
+
ticketUrl: null
|
|
110
|
+
epic: delivery
|
|
111
|
+
repo: agency
|
|
112
|
+
branch: feat/deploy
|
|
113
|
+
base: main
|
|
114
|
+
pr: null
|
|
115
|
+
status: open
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
# Deploy
|
|
119
|
+
`,
|
|
120
|
+
)
|
|
121
|
+
return root
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const service = <A>(
|
|
125
|
+
run: (readiness: ReadinessService) => Effect.Effect<A, unknown, any>,
|
|
126
|
+
) => runTestEffect(ReadinessService.pipe(Effect.flatMap(run)))
|
|
127
|
+
|
|
128
|
+
describe("ReadinessService", () => {
|
|
129
|
+
const roots: string[] = []
|
|
130
|
+
|
|
131
|
+
afterEach(async () => {
|
|
132
|
+
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("ranks ready work and explains every excluded execution unit", async () => {
|
|
136
|
+
const root = await createWorkbase()
|
|
137
|
+
roots.push(root)
|
|
138
|
+
|
|
139
|
+
const result = await service((readiness) => readiness.getNext(root, true))
|
|
140
|
+
|
|
141
|
+
expect(result.ready.map((item) => item.key)).toEqual([
|
|
142
|
+
"phase/ship/implement",
|
|
143
|
+
])
|
|
144
|
+
expect(result.selected).toMatchObject({
|
|
145
|
+
key: "phase/ship/implement",
|
|
146
|
+
parent: { epicId: "delivery", taskId: "ship" },
|
|
147
|
+
priority: { dependentCount: 1 },
|
|
148
|
+
})
|
|
149
|
+
expect(result.excluded.map((item) => item.key)).toEqual([
|
|
150
|
+
"task/prepare",
|
|
151
|
+
"phase/ship/verify",
|
|
152
|
+
"task/abandoned",
|
|
153
|
+
"task/deploy",
|
|
154
|
+
])
|
|
155
|
+
expect(
|
|
156
|
+
result.excluded.find((item) => item.key === "phase/ship/verify"),
|
|
157
|
+
).toMatchObject({
|
|
158
|
+
ready: false,
|
|
159
|
+
terminal: false,
|
|
160
|
+
blockedBy: ["phase:ship/implement"],
|
|
161
|
+
blockers: [
|
|
162
|
+
{
|
|
163
|
+
kind: "dependency",
|
|
164
|
+
status: "open",
|
|
165
|
+
reason: "Phase dependency is open",
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
})
|
|
169
|
+
expect(
|
|
170
|
+
result.excluded.find((item) => item.key === "task/abandoned"),
|
|
171
|
+
).toMatchObject({ status: "dropped", terminal: true })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("includes cross-task unlocks in final-phase priority", async () => {
|
|
175
|
+
const root = await createWorkbase()
|
|
176
|
+
roots.push(root)
|
|
177
|
+
await Bun.write(
|
|
178
|
+
join(root, "tasks/ship/phases/implement/PHASE.md"),
|
|
179
|
+
execution("done", "feat/implement"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const result = await service((readiness) => readiness.getNext(root, true))
|
|
183
|
+
|
|
184
|
+
expect(result.selected).toMatchObject({
|
|
185
|
+
key: "phase/ship/verify",
|
|
186
|
+
priority: { dependentCount: 1 },
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("uses the same readiness for work and PR guards", async () => {
|
|
191
|
+
const root = await createWorkbase()
|
|
192
|
+
roots.push(root)
|
|
193
|
+
|
|
194
|
+
await service((readiness) =>
|
|
195
|
+
readiness.guard("work", "ship", "implement", root),
|
|
196
|
+
)
|
|
197
|
+
await expect(
|
|
198
|
+
service((readiness) => readiness.guard("work", "ship", "verify", root)),
|
|
199
|
+
).rejects.toThrow("Phase dependency is open")
|
|
200
|
+
await expect(
|
|
201
|
+
service((readiness) => readiness.guard("pr", "ship", "verify", root)),
|
|
202
|
+
).rejects.toThrow("Phase dependency is open")
|
|
203
|
+
await service((readiness) =>
|
|
204
|
+
readiness.guard("work", "ship", "verify", root, true),
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("allows active PR targets but rejects terminal outcomes", async () => {
|
|
209
|
+
const root = await createWorkbase()
|
|
210
|
+
roots.push(root)
|
|
211
|
+
const path = join(root, "tasks/ship/phases/implement/PHASE.md")
|
|
212
|
+
await Bun.write(path, execution("working", "feat/implement"))
|
|
213
|
+
|
|
214
|
+
await service((readiness) =>
|
|
215
|
+
readiness.guard("pr", "ship", "implement", root),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
await Bun.write(path, execution("done", "feat/implement"))
|
|
219
|
+
await expect(
|
|
220
|
+
service((readiness) => readiness.guard("pr", "ship", "implement", root)),
|
|
221
|
+
).rejects.toThrow("Phase status is done")
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { AgencyGraph, GraphBlocker, GraphNode } from "../graph-schema"
|
|
3
|
+
import type { WorkStatus } from "../workbase/schemas"
|
|
4
|
+
import { GraphService } from "./GraphService"
|
|
5
|
+
|
|
6
|
+
type ExecutionNode = Extract<GraphNode, { readonly kind: "execution-unit" }>
|
|
7
|
+
|
|
8
|
+
interface NextItem {
|
|
9
|
+
readonly rank: number
|
|
10
|
+
readonly key: string
|
|
11
|
+
readonly taskId: string
|
|
12
|
+
readonly phaseId?: string
|
|
13
|
+
readonly description?: string
|
|
14
|
+
readonly parent: {
|
|
15
|
+
readonly taskId?: string
|
|
16
|
+
readonly epicId?: string
|
|
17
|
+
}
|
|
18
|
+
readonly status: WorkStatus
|
|
19
|
+
readonly repositories: readonly string[]
|
|
20
|
+
readonly priority: {
|
|
21
|
+
readonly dependentCount: number
|
|
22
|
+
}
|
|
23
|
+
readonly ready: boolean
|
|
24
|
+
readonly terminal: boolean
|
|
25
|
+
readonly blockedBy: readonly string[]
|
|
26
|
+
readonly blockers: readonly GraphBlocker[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface NextResult {
|
|
30
|
+
readonly ready: readonly NextItem[]
|
|
31
|
+
readonly excluded: readonly NextItem[]
|
|
32
|
+
readonly selected?: NextItem
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ExecutionGuardError extends Data.TaggedError("ExecutionGuardError")<{
|
|
36
|
+
readonly message: string
|
|
37
|
+
readonly action: "work" | "pr"
|
|
38
|
+
readonly target: string
|
|
39
|
+
readonly status: WorkStatus
|
|
40
|
+
readonly blockedBy: readonly string[]
|
|
41
|
+
readonly blockers: readonly GraphBlocker[]
|
|
42
|
+
}> {}
|
|
43
|
+
|
|
44
|
+
const executionNodeId = (taskId: string, phaseId?: string) =>
|
|
45
|
+
phaseId
|
|
46
|
+
? `execution-unit:phase/${taskId}/${phaseId}`
|
|
47
|
+
: `execution-unit:task/${taskId}`
|
|
48
|
+
|
|
49
|
+
const itemFor = (
|
|
50
|
+
node: ExecutionNode,
|
|
51
|
+
graph: AgencyGraph,
|
|
52
|
+
rank: number,
|
|
53
|
+
): NextItem => {
|
|
54
|
+
const task = graph.nodes.find(
|
|
55
|
+
(candidate) =>
|
|
56
|
+
candidate.kind === "task" && candidate.key === node.data.taskId,
|
|
57
|
+
)
|
|
58
|
+
const epicId =
|
|
59
|
+
task?.kind === "task" && typeof task.data.epic === "string"
|
|
60
|
+
? task.data.epic
|
|
61
|
+
: undefined
|
|
62
|
+
const dependentIds = new Set(node.dependents)
|
|
63
|
+
if (node.data.phaseId) {
|
|
64
|
+
const siblings = graph.nodes.filter(
|
|
65
|
+
(candidate): candidate is ExecutionNode =>
|
|
66
|
+
candidate.kind === "execution-unit" &&
|
|
67
|
+
candidate.data.taskId === node.data.taskId &&
|
|
68
|
+
candidate.id !== node.id,
|
|
69
|
+
)
|
|
70
|
+
if (siblings.every((sibling) => sibling.status === "done")) {
|
|
71
|
+
for (const dependent of task?.dependents ?? [])
|
|
72
|
+
dependentIds.add(dependent)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
rank,
|
|
77
|
+
key: node.key,
|
|
78
|
+
taskId: node.data.taskId,
|
|
79
|
+
...(node.data.phaseId ? { phaseId: node.data.phaseId } : {}),
|
|
80
|
+
...(node.data.description ? { description: node.data.description } : {}),
|
|
81
|
+
parent: {
|
|
82
|
+
...(node.data.phaseId ? { taskId: node.data.taskId } : {}),
|
|
83
|
+
...(epicId ? { epicId } : {}),
|
|
84
|
+
},
|
|
85
|
+
status: node.status,
|
|
86
|
+
repositories: node.repositories,
|
|
87
|
+
priority: { dependentCount: dependentIds.size },
|
|
88
|
+
ready: node.readiness.ready,
|
|
89
|
+
terminal: node.readiness.terminal,
|
|
90
|
+
blockedBy: node.readiness.blockedBy,
|
|
91
|
+
blockers: node.readiness.blockers,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rankedItems = (graph: AgencyGraph) =>
|
|
96
|
+
graph.nodes
|
|
97
|
+
.filter((node): node is ExecutionNode => node.kind === "execution-unit")
|
|
98
|
+
.map((node) => itemFor(node, graph, 0))
|
|
99
|
+
.sort(
|
|
100
|
+
(left, right) =>
|
|
101
|
+
right.priority.dependentCount - left.priority.dependentCount ||
|
|
102
|
+
left.key.localeCompare(right.key),
|
|
103
|
+
)
|
|
104
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
105
|
+
|
|
106
|
+
const guardMessage = (
|
|
107
|
+
action: "work" | "pr",
|
|
108
|
+
item: Pick<NextItem, "key" | "status" | "blockers">,
|
|
109
|
+
) => {
|
|
110
|
+
const reasons = item.blockers.map((blocker) => blocker.reason)
|
|
111
|
+
return `Cannot ${action === "work" ? "work on" : "create a pull request for"} '${item.key}': ${reasons.length > 0 ? reasons.join("; ") : `status is ${item.status}`}. Use --force to override.`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ReadinessService extends Effect.Service<ReadinessService>()(
|
|
115
|
+
"ReadinessService",
|
|
116
|
+
{
|
|
117
|
+
sync: () => ({
|
|
118
|
+
getReadyWorkTargetIds: (cwd: string = process.cwd()) =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const graphs = yield* GraphService
|
|
121
|
+
const graph = yield* graphs.get({ cwd })
|
|
122
|
+
return new Set(
|
|
123
|
+
graph.nodes
|
|
124
|
+
.filter((node) => node.readiness?.ready)
|
|
125
|
+
.map((node) => node.id),
|
|
126
|
+
)
|
|
127
|
+
}),
|
|
128
|
+
|
|
129
|
+
getNext: (cwd: string = process.cwd(), select = false) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const graphs = yield* GraphService
|
|
132
|
+
const graph = yield* graphs.get({ cwd })
|
|
133
|
+
const items = rankedItems(graph)
|
|
134
|
+
const ready = items
|
|
135
|
+
.filter((item) => item.ready)
|
|
136
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
137
|
+
const excluded = items
|
|
138
|
+
.filter((item) => !item.ready)
|
|
139
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
140
|
+
return {
|
|
141
|
+
ready,
|
|
142
|
+
excluded,
|
|
143
|
+
...(select && ready[0] ? { selected: ready[0] } : {}),
|
|
144
|
+
} satisfies NextResult
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
guardWorkTarget: (
|
|
148
|
+
target: string,
|
|
149
|
+
cwd: string = process.cwd(),
|
|
150
|
+
override = false,
|
|
151
|
+
) =>
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
if (override) return
|
|
154
|
+
const graphs = yield* GraphService
|
|
155
|
+
const graph = yield* graphs.get({ cwd })
|
|
156
|
+
const node = graph.nodes.find((candidate) => candidate.id === target)
|
|
157
|
+
if (!node || !node.readiness) {
|
|
158
|
+
return yield* new ExecutionGuardError({
|
|
159
|
+
message: `Work target '${target}' was not found in the work graph.`,
|
|
160
|
+
action: "work",
|
|
161
|
+
target,
|
|
162
|
+
status: "open",
|
|
163
|
+
blockedBy: [],
|
|
164
|
+
blockers: [],
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
if (!node.readiness.ready) {
|
|
168
|
+
const item = {
|
|
169
|
+
key: node.key,
|
|
170
|
+
status: node.status!,
|
|
171
|
+
blockers: node.readiness.blockers,
|
|
172
|
+
}
|
|
173
|
+
return yield* new ExecutionGuardError({
|
|
174
|
+
message: guardMessage("work", item),
|
|
175
|
+
action: "work",
|
|
176
|
+
target,
|
|
177
|
+
status: node.status!,
|
|
178
|
+
blockedBy: node.readiness.blockedBy,
|
|
179
|
+
blockers: node.readiness.blockers,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
guard: (
|
|
185
|
+
action: "work" | "pr",
|
|
186
|
+
taskId: string,
|
|
187
|
+
phaseId?: string,
|
|
188
|
+
cwd: string = process.cwd(),
|
|
189
|
+
override = false,
|
|
190
|
+
) =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
if (override) return
|
|
193
|
+
const graphs = yield* GraphService
|
|
194
|
+
const graph = yield* graphs.get({ cwd })
|
|
195
|
+
const items = rankedItems(graph)
|
|
196
|
+
const key = phaseId ? `phase/${taskId}/${phaseId}` : `task/${taskId}`
|
|
197
|
+
const item = items.find((candidate) => candidate.key === key)
|
|
198
|
+
if (!item) {
|
|
199
|
+
return yield* new ExecutionGuardError({
|
|
200
|
+
message: `Execution unit '${key}' was not found in the work graph.`,
|
|
201
|
+
action,
|
|
202
|
+
target: executionNodeId(taskId, phaseId),
|
|
203
|
+
status: "open",
|
|
204
|
+
blockedBy: [],
|
|
205
|
+
blockers: [],
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const actionable =
|
|
209
|
+
action === "work"
|
|
210
|
+
? item.ready
|
|
211
|
+
: !item.terminal &&
|
|
212
|
+
!item.blockers.some(
|
|
213
|
+
(blocker) =>
|
|
214
|
+
blocker.kind === "dependency" ||
|
|
215
|
+
blocker.kind === "validation",
|
|
216
|
+
)
|
|
217
|
+
if (!actionable) {
|
|
218
|
+
return yield* new ExecutionGuardError({
|
|
219
|
+
message: guardMessage(action, item),
|
|
220
|
+
action,
|
|
221
|
+
target: executionNodeId(taskId, phaseId),
|
|
222
|
+
status: item.status,
|
|
223
|
+
blockedBy: item.blockedBy,
|
|
224
|
+
blockers: item.blockers,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
}),
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
) {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir, realpath } from "node:fs/promises"
|
|
3
|
+
import { mkdir, realpath, rm, symlink } from "node:fs/promises"
|
|
4
4
|
import { dirname, join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
@@ -66,11 +66,154 @@ describe("WorkbaseService", () => {
|
|
|
66
66
|
),
|
|
67
67
|
)
|
|
68
68
|
|
|
69
|
-
expect(first).toBe(await realpath(workbaseRoot))
|
|
70
|
-
expect(registered).toEqual([first])
|
|
69
|
+
expect(first.path).toBe(await realpath(workbaseRoot))
|
|
70
|
+
expect(registered).toEqual([first.path])
|
|
71
71
|
expect(
|
|
72
72
|
await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
|
|
73
|
-
).toEqual({ version:
|
|
73
|
+
).toEqual({ version: 2, workbases: [first] })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("resolves names and stable IDs and manages the default", async () => {
|
|
77
|
+
const workbaseRoot = join(root, "workbase")
|
|
78
|
+
const configDirectory = join(root, "config")
|
|
79
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
80
|
+
|
|
81
|
+
const registered = await runTestEffect(
|
|
82
|
+
WorkbaseService.pipe(
|
|
83
|
+
Effect.flatMap((service) =>
|
|
84
|
+
service.register(workbaseRoot, configDirectory, "primary"),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
const result = await runTestEffect(
|
|
89
|
+
WorkbaseService.pipe(
|
|
90
|
+
Effect.flatMap((service) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
yield* service.setDefault("primary", configDirectory)
|
|
93
|
+
return {
|
|
94
|
+
byName: yield* service.resolveRegistered(
|
|
95
|
+
"primary",
|
|
96
|
+
configDirectory,
|
|
97
|
+
),
|
|
98
|
+
byId: yield* service.resolveRegistered(
|
|
99
|
+
registered.id,
|
|
100
|
+
configDirectory,
|
|
101
|
+
),
|
|
102
|
+
defaultWorkbase: yield* service.getDefault(configDirectory),
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
expect(result.byName).toBe(registered.path)
|
|
110
|
+
expect(result.byId).toBe(registered.path)
|
|
111
|
+
expect(result.defaultWorkbase).toEqual(registered)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("rejects names that collide with stable IDs", async () => {
|
|
115
|
+
const firstRoot = join(root, "first")
|
|
116
|
+
const secondRoot = join(root, "second")
|
|
117
|
+
const configDirectory = join(root, "config")
|
|
118
|
+
await write(firstRoot, "agency.json", '{"version":2}\n')
|
|
119
|
+
await write(secondRoot, "agency.json", '{"version":2}\n')
|
|
120
|
+
const first = await runTestEffect(
|
|
121
|
+
WorkbaseService.pipe(
|
|
122
|
+
Effect.flatMap((service) =>
|
|
123
|
+
service.register(firstRoot, configDirectory),
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
await expect(
|
|
129
|
+
runTestEffect(
|
|
130
|
+
WorkbaseService.pipe(
|
|
131
|
+
Effect.flatMap((service) =>
|
|
132
|
+
service.register(secondRoot, configDirectory, first.id),
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
).rejects.toThrow("already registered")
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("rejects invalid workbase names before writing the registry", async () => {
|
|
140
|
+
const workbaseRoot = join(root, "workbase")
|
|
141
|
+
const configDirectory = join(root, "config")
|
|
142
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
143
|
+
|
|
144
|
+
for (const name of ["bad/name", ""]) {
|
|
145
|
+
await expect(
|
|
146
|
+
runTestEffect(
|
|
147
|
+
WorkbaseService.pipe(
|
|
148
|
+
Effect.flatMap((service) =>
|
|
149
|
+
service.register(workbaseRoot, configDirectory, name),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
).rejects.toThrow("Invalid workbase name")
|
|
154
|
+
}
|
|
155
|
+
expect(
|
|
156
|
+
await Bun.file(join(configDirectory, "agency/workbases.json")).exists(),
|
|
157
|
+
).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("removes a registration by an equivalent symlink path", async () => {
|
|
161
|
+
const workbaseRoot = join(root, "workbase")
|
|
162
|
+
const linkedRoot = join(root, "linked")
|
|
163
|
+
const configDirectory = join(root, "config")
|
|
164
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
165
|
+
await symlink(workbaseRoot, linkedRoot)
|
|
166
|
+
await runTestEffect(
|
|
167
|
+
WorkbaseService.pipe(
|
|
168
|
+
Effect.flatMap((service) =>
|
|
169
|
+
service.register(workbaseRoot, configDirectory),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const removed = await runTestEffect(
|
|
175
|
+
WorkbaseService.pipe(
|
|
176
|
+
Effect.flatMap((service) =>
|
|
177
|
+
service.removeRegistered(linkedRoot, configDirectory),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
expect(removed.path).toBe(await realpath(workbaseRoot))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("migrates legacy registrations and prunes stale paths", async () => {
|
|
185
|
+
const workbaseRoot = join(root, "workbase")
|
|
186
|
+
const staleRoot = join(root, "stale")
|
|
187
|
+
const configDirectory = join(root, "config")
|
|
188
|
+
const registryPath = join(configDirectory, "agency/workbases.json")
|
|
189
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
190
|
+
await write(staleRoot, "agency.json", '{"version":2}\n')
|
|
191
|
+
await write(
|
|
192
|
+
configDirectory,
|
|
193
|
+
"agency/workbases.json",
|
|
194
|
+
JSON.stringify({ version: 1, workbases: [workbaseRoot, staleRoot] }),
|
|
195
|
+
)
|
|
196
|
+
await rm(staleRoot, { recursive: true })
|
|
197
|
+
|
|
198
|
+
const result = await runTestEffect(
|
|
199
|
+
WorkbaseService.pipe(
|
|
200
|
+
Effect.flatMap((service) =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const before = yield* service.listRegistrations(configDirectory)
|
|
203
|
+
const removed = yield* service.pruneRegistered(configDirectory)
|
|
204
|
+
return { before, removed }
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(result.before.workbases).toHaveLength(2)
|
|
211
|
+
expect(result.before.workbases[0]?.id).toStartWith("wb-")
|
|
212
|
+
expect(result.removed.map((entry) => entry.path)).toEqual([staleRoot])
|
|
213
|
+
expect(await Bun.file(registryPath).json()).toEqual({
|
|
214
|
+
version: 2,
|
|
215
|
+
workbases: [result.before.workbases[0]],
|
|
216
|
+
})
|
|
74
217
|
})
|
|
75
218
|
|
|
76
219
|
test("rejects an invalid worktree command template", async () => {
|