@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.
@@ -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: BaseCommandOptions = {},
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: 1, workbases: [first] })
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 () => {