@markjaquith/agency 2.15.0 → 2.17.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.
@@ -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
+ ) {}
@@ -0,0 +1,364 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { chmod, mkdir, rm } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { ClaimService } from "./ClaimService"
7
+ import { PullRequestService } from "./PullRequestService"
8
+ import { SyncService } from "./SyncService"
9
+ import { TaskService } from "./TaskService"
10
+ import { WorktreeService } from "./WorktreeService"
11
+
12
+ const git = async (args: string[], cwd?: string) => {
13
+ const process = Bun.spawn(["git", ...args], {
14
+ cwd,
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ })
18
+ await process.exited
19
+ if (process.exitCode !== 0) {
20
+ throw new Error(await new Response(process.stderr).text())
21
+ }
22
+ }
23
+
24
+ describe("SyncService", () => {
25
+ let root: string
26
+ let originalPath: string | undefined
27
+
28
+ beforeEach(async () => {
29
+ root = await createTempDir()
30
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
31
+ const source = join(root, "source")
32
+ await mkdir(source, { recursive: true })
33
+ await git(["init", "--initial-branch=main"], source)
34
+ await git(["config", "user.email", "test@example.com"], source)
35
+ await git(["config", "user.name", "Test"], source)
36
+ await Bun.write(join(source, "README.md"), "example\n")
37
+ await git(["add", "README.md"], source)
38
+ await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
39
+ await mkdir(join(root, "repos"), { recursive: true })
40
+ await git(["clone", "--bare", source, join(root, "repos/agency")])
41
+ await git(["clone", "--bare", source, join(root, "repos/reference")])
42
+
43
+ const bin = join(root, "bin")
44
+ await mkdir(bin)
45
+ const gh = join(bin, "gh")
46
+ await Bun.write(
47
+ gh,
48
+ `#!/bin/sh
49
+ if [ "$2" = "view" ]; then
50
+ cat <<'JSON'
51
+ {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}
52
+ JSON
53
+ exit 0
54
+ fi
55
+ cat <<'JSON'
56
+ [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}]
57
+ JSON
58
+ `,
59
+ )
60
+ await chmod(gh, 0o755)
61
+ originalPath = process.env.PATH
62
+ process.env.PATH = `${bin}:${originalPath}`
63
+ })
64
+
65
+ afterEach(async () => {
66
+ if (originalPath === undefined) delete process.env.PATH
67
+ else process.env.PATH = originalPath
68
+ await cleanupTempDir(root)
69
+ })
70
+
71
+ test("observes drift without mutation and applies only safe transitions", async () => {
72
+ await runTestEffect(
73
+ TaskService.pipe(
74
+ Effect.flatMap((service) =>
75
+ service.create(
76
+ {
77
+ id: "example",
78
+ ticketUrl: null,
79
+ repo: "agency",
80
+ repos: [{ repo: "reference", ref: "main" }],
81
+ branch: "feat/example",
82
+ base: "main",
83
+ },
84
+ root,
85
+ ),
86
+ ),
87
+ ),
88
+ )
89
+ const workspace = await runTestEffect(
90
+ WorktreeService.pipe(
91
+ Effect.flatMap((service) =>
92
+ service.materialize("example", undefined, root),
93
+ ),
94
+ ),
95
+ )
96
+ await git(
97
+ ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
98
+ join(root, "repos/agency"),
99
+ )
100
+ const inspected = await runTestEffect(
101
+ ClaimService.pipe(
102
+ Effect.flatMap((service) =>
103
+ service.inspect("example", undefined, root),
104
+ ),
105
+ ),
106
+ )
107
+ await runTestEffect(
108
+ ClaimService.pipe(
109
+ Effect.flatMap((service) =>
110
+ service.claim(
111
+ {
112
+ taskId: "example",
113
+ claimant: "orchestrator",
114
+ runner: "agent",
115
+ sessionId: "session-1",
116
+ revision: inspected.revision,
117
+ expiresAt: "2099-01-01T00:00:00.000Z",
118
+ },
119
+ root,
120
+ ),
121
+ ),
122
+ ),
123
+ )
124
+ await Bun.write(
125
+ join(workspace.codePath, "reference", "LOCAL.md"),
126
+ "dirty\n",
127
+ )
128
+
129
+ const taskPath = join(root, "tasks/example/TASK.md")
130
+ const before = await Bun.file(taskPath).text()
131
+ const observed = await runTestEffect(
132
+ SyncService.pipe(
133
+ Effect.flatMap((service) =>
134
+ service.reconcile({ cwd: root, now: new Date("2100-01-02") }),
135
+ ),
136
+ ),
137
+ )
138
+
139
+ expect(observed.mode).toBe("dry-run")
140
+ expect(observed.warnings).toContainEqual(
141
+ expect.objectContaining({
142
+ kind: "dirty-reference",
143
+ target: "task:example",
144
+ }),
145
+ )
146
+ expect(observed.changes.map((change) => change.kind)).toEqual([
147
+ "release-stale-claim",
148
+ "record-pr",
149
+ "mark-done",
150
+ ])
151
+ expect(await Bun.file(taskPath).text()).toBe(before)
152
+
153
+ const applied = await runTestEffect(
154
+ SyncService.pipe(
155
+ Effect.flatMap((service) =>
156
+ service.reconcile({
157
+ cwd: root,
158
+ apply: true,
159
+ now: new Date("2100-01-02"),
160
+ }),
161
+ ),
162
+ ),
163
+ )
164
+ expect(applied.changes.map((change) => change.kind)).toEqual([
165
+ "release-stale-claim",
166
+ "record-pr",
167
+ "mark-done",
168
+ ])
169
+ expect(applied.changes.every((change) => change.status === "applied")).toBe(
170
+ true,
171
+ )
172
+ const task = await runTestEffect(
173
+ TaskService.pipe(
174
+ Effect.flatMap((service) => service.show("example", root)),
175
+ ),
176
+ )
177
+ expect(task.data).toMatchObject({
178
+ status: "done",
179
+ pr: "https://github.com/example/agency/pull/42",
180
+ claim: { state: "released", sessionId: "session-1" },
181
+ })
182
+ })
183
+
184
+ test("materializes missing workspaces but leaves branch conflicts unresolved", async () => {
185
+ for (const [id, branch] of [
186
+ ["missing", "feat/missing"],
187
+ ["conflict", "feat/conflict"],
188
+ ] as const) {
189
+ await runTestEffect(
190
+ TaskService.pipe(
191
+ Effect.flatMap((service) =>
192
+ service.create(
193
+ { id, ticketUrl: null, repo: "agency", branch, base: "main" },
194
+ root,
195
+ ),
196
+ ),
197
+ ),
198
+ )
199
+ }
200
+ const repository = join(root, "repos/agency")
201
+ await git(["branch", "feat/conflict", "main"], repository)
202
+ await git(
203
+ ["worktree", "add", join(root, "external-conflict"), "feat/conflict"],
204
+ repository,
205
+ )
206
+
207
+ const observed = await runTestEffect(
208
+ SyncService.pipe(
209
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
210
+ ),
211
+ )
212
+ expect(observed.changes).toContainEqual(
213
+ expect.objectContaining({
214
+ kind: "materialize-workspace",
215
+ target: "task:missing",
216
+ status: "planned",
217
+ }),
218
+ )
219
+ expect(observed.unresolved).toContainEqual(
220
+ expect.objectContaining({
221
+ kind: "branch-conflict",
222
+ target: "task:conflict",
223
+ }),
224
+ )
225
+ expect(
226
+ await Bun.file(
227
+ join(root, "tasks/missing/code/agency/README.md"),
228
+ ).exists(),
229
+ ).toBe(false)
230
+
231
+ const applied = await runTestEffect(
232
+ SyncService.pipe(
233
+ Effect.flatMap((service) =>
234
+ service.reconcile({ cwd: root, apply: true }),
235
+ ),
236
+ ),
237
+ )
238
+ expect(applied.changes).toContainEqual(
239
+ expect.objectContaining({
240
+ kind: "materialize-workspace",
241
+ target: "task:missing",
242
+ status: "applied",
243
+ }),
244
+ )
245
+ expect(
246
+ applied.executions.find((item) => item.target === "task:missing")
247
+ ?.checkouts[0],
248
+ ).toMatchObject({ exists: true, registered: true, dirty: false })
249
+ expect(
250
+ await Bun.file(join(root, "tasks/missing/code/agency/README.md")).text(),
251
+ ).toBe("example\n")
252
+ expect(
253
+ await Bun.file(
254
+ join(root, "tasks/conflict/code/agency/README.md"),
255
+ ).exists(),
256
+ ).toBe(false)
257
+ })
258
+
259
+ test("leaves a missing checkout registration unresolved", async () => {
260
+ await runTestEffect(
261
+ TaskService.pipe(
262
+ Effect.flatMap((service) =>
263
+ service.create(
264
+ {
265
+ id: "stale",
266
+ ticketUrl: null,
267
+ repo: "agency",
268
+ branch: "feat/stale",
269
+ base: "main",
270
+ },
271
+ root,
272
+ ),
273
+ ),
274
+ ),
275
+ )
276
+ const workspace = await runTestEffect(
277
+ WorktreeService.pipe(
278
+ Effect.flatMap((service) =>
279
+ service.materialize("stale", undefined, root),
280
+ ),
281
+ ),
282
+ )
283
+ await rm(workspace.writablePath, { recursive: true, force: true })
284
+
285
+ const observed = await runTestEffect(
286
+ SyncService.pipe(
287
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
288
+ ),
289
+ )
290
+ expect(observed.changes).toEqual([])
291
+ expect(observed.unresolved).toContainEqual(
292
+ expect.objectContaining({
293
+ kind: "stale-registration",
294
+ target: "task:stale",
295
+ }),
296
+ )
297
+
298
+ const applied = await runTestEffect(
299
+ SyncService.pipe(
300
+ Effect.flatMap((service) =>
301
+ service.reconcile({ cwd: root, apply: true }),
302
+ ),
303
+ ),
304
+ )
305
+ expect(applied.changes).toEqual([])
306
+ expect(
307
+ await Bun.file(join(workspace.writablePath, "README.md")).exists(),
308
+ ).toBe(false)
309
+ })
310
+
311
+ test("does not trust a recorded PR from another repository", async () => {
312
+ await runTestEffect(
313
+ TaskService.pipe(
314
+ Effect.flatMap((service) =>
315
+ service.create(
316
+ {
317
+ id: "example",
318
+ ticketUrl: null,
319
+ repo: "agency",
320
+ branch: "feat/example",
321
+ base: "main",
322
+ },
323
+ root,
324
+ ),
325
+ ),
326
+ ),
327
+ )
328
+ await runTestEffect(
329
+ PullRequestService.pipe(
330
+ Effect.flatMap((service) =>
331
+ service.setUrl(
332
+ "example",
333
+ undefined,
334
+ "https://github.com/other/repository/pull/42",
335
+ root,
336
+ ),
337
+ ),
338
+ ),
339
+ )
340
+
341
+ const applied = await runTestEffect(
342
+ SyncService.pipe(
343
+ Effect.flatMap((service) =>
344
+ service.reconcile({ cwd: root, apply: true }),
345
+ ),
346
+ ),
347
+ )
348
+ expect(applied.unresolved).toContainEqual(
349
+ expect.objectContaining({
350
+ kind: "pr-repository-conflict",
351
+ target: "task:example",
352
+ }),
353
+ )
354
+ expect(applied.changes.some((change) => change.kind === "mark-done")).toBe(
355
+ false,
356
+ )
357
+ const task = await runTestEffect(
358
+ TaskService.pipe(
359
+ Effect.flatMap((service) => service.show("example", root)),
360
+ ),
361
+ )
362
+ expect(task.data).toMatchObject({ status: "open" })
363
+ })
364
+ })