@markjaquith/agency 2.50.0 → 2.52.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.
Files changed (36) hide show
  1. package/README.md +34 -8
  2. package/cli.ts +33 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +1 -0
  5. package/src/cli-parser.test.ts +19 -0
  6. package/src/cli-parser.ts +23 -0
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/vcs.test.ts +75 -0
  10. package/src/commands/vcs.ts +72 -0
  11. package/src/commands/worktree.ts +1 -1
  12. package/src/graph-schema.test.ts +1 -1
  13. package/src/graph-schema.ts +1 -0
  14. package/src/services/ArchiveService.ts +23 -0
  15. package/src/services/ContextService.ts +62 -13
  16. package/src/services/DoctorService.ts +13 -6
  17. package/src/services/GraphService.ts +1 -0
  18. package/src/services/PhaseService.ts +191 -70
  19. package/src/services/PullRequestService.ts +14 -30
  20. package/src/services/RepositoryService.test.ts +31 -1
  21. package/src/services/RepositoryService.ts +63 -31
  22. package/src/services/ReviewService.ts +23 -0
  23. package/src/services/SyncService.test.ts +67 -0
  24. package/src/services/SyncService.ts +51 -84
  25. package/src/services/TaskPhaseService.test.ts +80 -0
  26. package/src/services/VcsMigrationService.test.ts +245 -0
  27. package/src/services/VcsMigrationService.ts +812 -0
  28. package/src/services/VersionControlService.test.ts +100 -0
  29. package/src/services/VersionControlService.ts +479 -0
  30. package/src/services/WorkbaseService.ts +5 -1
  31. package/src/services/WorktreeService.test.ts +72 -1
  32. package/src/services/WorktreeService.ts +808 -308
  33. package/src/test-utils.ts +10 -0
  34. package/src/workbase/AGENTS.md +2 -2
  35. package/src/workbase/schemas.ts +1 -0
  36. package/src/workbase/version-control.ts +5 -0
@@ -0,0 +1,245 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { lstat, mkdir } 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 { TaskService } from "./TaskService"
8
+ import { VcsMigrationService } from "./VcsMigrationService"
9
+ import { WorktreeService } from "./WorktreeService"
10
+
11
+ const run = async (args: string[], cwd?: string) => {
12
+ const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
13
+ const exitCode = await child.exited
14
+ if (exitCode !== 0) throw new Error(await new Response(child.stderr).text())
15
+ return new Response(child.stdout).text()
16
+ }
17
+
18
+ const exists = async (path: string) => {
19
+ try {
20
+ await lstat(path)
21
+ return true
22
+ } catch {
23
+ return false
24
+ }
25
+ }
26
+
27
+ describe("VcsMigrationService", () => {
28
+ let root: string
29
+
30
+ beforeEach(async () => {
31
+ root = await createTempDir()
32
+ await Bun.write(
33
+ join(root, "agency.json"),
34
+ JSON.stringify({ version: 2, vcs: "git" }),
35
+ )
36
+ const source = join(root, "source")
37
+ await mkdir(source)
38
+ await run(["git", "init", "--initial-branch=main"], source)
39
+ await run(["git", "config", "user.email", "test@example.com"], source)
40
+ await run(["git", "config", "user.name", "Test"], source)
41
+ await Bun.write(join(source, "README.md"), "example\n")
42
+ await run(["git", "add", "README.md"], source)
43
+ await run(["git", "commit", "-m", "initial"], source)
44
+ await mkdir(join(root, "repos"))
45
+ await run(["git", "clone", "--bare", source, join(root, "repos/agency")])
46
+ await run(["git", "clone", "--bare", source, join(root, "repos/effect")])
47
+ await runTestEffect(
48
+ TaskService.pipe(
49
+ Effect.flatMap((service) =>
50
+ service.create(
51
+ {
52
+ id: "example",
53
+ ticketUrl: null,
54
+ repo: "agency",
55
+ repos: [{ repo: "effect", ref: "main" }],
56
+ branch: "task/example",
57
+ base: "main",
58
+ },
59
+ root,
60
+ ),
61
+ ),
62
+ ),
63
+ )
64
+ await runTestEffect(
65
+ WorktreeService.pipe(
66
+ Effect.flatMap((service) =>
67
+ service.materialize("example", undefined, root),
68
+ ),
69
+ ),
70
+ )
71
+ })
72
+
73
+ afterEach(async () => cleanupTempDir(root))
74
+
75
+ test("migrates Git worktrees to jj workspaces and back", async () => {
76
+ if (!Bun.which("jj")) return
77
+ const checkout = join(root, "tasks/example/code/agency")
78
+ const reference = join(root, "tasks/example/code/effect")
79
+ const dryRun = await runTestEffect(
80
+ VcsMigrationService.pipe(
81
+ Effect.flatMap((service) => service.migrate("jj", root)),
82
+ ),
83
+ )
84
+ expect(dryRun).toMatchObject({
85
+ source: "git",
86
+ target: "jj",
87
+ mode: "dry-run",
88
+ workspaceCount: 2,
89
+ })
90
+ expect((await Bun.file(join(root, "agency.json")).json()).vcs).toBe("git")
91
+
92
+ const migrated = await runTestEffect(
93
+ VcsMigrationService.pipe(
94
+ Effect.flatMap((service) =>
95
+ service.migrate("jj", root, { apply: true }),
96
+ ),
97
+ ),
98
+ )
99
+ expect(migrated.mode).toBe("apply")
100
+ expect((await Bun.file(join(root, "agency.json")).json()).vcs).toBe("jj")
101
+ expect(await exists(join(root, "repos/agency/.jj"))).toBe(true)
102
+ expect(await exists(join(root, "repos/effect/.jj"))).toBe(true)
103
+ expect(await exists(join(checkout, ".jj"))).toBe(true)
104
+ expect(await exists(join(reference, ".jj"))).toBe(true)
105
+ expect(
106
+ await run(["jj", "-R", checkout, "log", "--no-graph", "-r", "@-"]),
107
+ ).toContain("initial")
108
+ expect(
109
+ (
110
+ await runTestEffect(
111
+ WorktreeService.pipe(
112
+ Effect.flatMap((service) =>
113
+ service.inspect("example", undefined, root),
114
+ ),
115
+ ),
116
+ )
117
+ ).conflicts,
118
+ ).toEqual([])
119
+
120
+ await runTestEffect(
121
+ VcsMigrationService.pipe(
122
+ Effect.flatMap((service) =>
123
+ service.migrate("git", root, { apply: true }),
124
+ ),
125
+ ),
126
+ )
127
+ expect((await Bun.file(join(root, "agency.json")).json()).vcs).toBe("git")
128
+ expect(await exists(join(root, "repos/agency/.jj"))).toBe(false)
129
+ expect(await exists(join(root, "repos/effect/.jj"))).toBe(false)
130
+ expect(await exists(join(checkout, ".git"))).toBe(true)
131
+ expect(await exists(join(reference, ".git"))).toBe(true)
132
+ expect(
133
+ (await run(["git", "-C", checkout, "branch", "--show-current"])).trim(),
134
+ ).toBe("task/example")
135
+ expect(
136
+ (
137
+ await run(["git", "-C", reference, "rev-parse", "--abbrev-ref", "HEAD"])
138
+ ).trim(),
139
+ ).toBe("HEAD")
140
+ expect(
141
+ (
142
+ await runTestEffect(
143
+ WorktreeService.pipe(
144
+ Effect.flatMap((service) =>
145
+ service.inspect("example", undefined, root),
146
+ ),
147
+ ),
148
+ )
149
+ ).conflicts,
150
+ ).toEqual([])
151
+ })
152
+
153
+ test("allows clean unclaimed working workspaces", async () => {
154
+ if (!Bun.which("jj")) return
155
+ await runTestEffect(
156
+ TaskService.pipe(
157
+ Effect.flatMap((service) =>
158
+ service.setStatus("example", "working", root),
159
+ ),
160
+ ),
161
+ )
162
+ const migrated = await runTestEffect(
163
+ VcsMigrationService.pipe(
164
+ Effect.flatMap((service) =>
165
+ service.migrate("jj", root, { apply: true }),
166
+ ),
167
+ ),
168
+ )
169
+ expect(migrated.mode).toBe("apply")
170
+ })
171
+
172
+ test("blocks active claims", async () => {
173
+ if (!Bun.which("jj")) return
174
+ const inspected = await runTestEffect(
175
+ ClaimService.pipe(
176
+ Effect.flatMap((service) =>
177
+ service.inspect("example", undefined, root),
178
+ ),
179
+ ),
180
+ )
181
+ await runTestEffect(
182
+ ClaimService.pipe(
183
+ Effect.flatMap((service) =>
184
+ service.claim(
185
+ {
186
+ taskId: "example",
187
+ claimant: "orchestrator",
188
+ runner: "agent",
189
+ sessionId: "session-1",
190
+ revision: inspected.revision,
191
+ },
192
+ root,
193
+ ),
194
+ ),
195
+ ),
196
+ )
197
+ await expect(
198
+ runTestEffect(
199
+ VcsMigrationService.pipe(
200
+ Effect.flatMap((service) =>
201
+ service.migrate("jj", root, { apply: true }),
202
+ ),
203
+ ),
204
+ ),
205
+ ).rejects.toThrow("is active")
206
+ })
207
+
208
+ test("blocks dirty workspaces", async () => {
209
+ if (!Bun.which("jj")) return
210
+ await Bun.write(join(root, "tasks/example/code/agency/DIRTY.md"), "dirty\n")
211
+ await expect(
212
+ runTestEffect(
213
+ VcsMigrationService.pipe(
214
+ Effect.flatMap((service) =>
215
+ service.migrate("jj", root, { apply: true }),
216
+ ),
217
+ ),
218
+ ),
219
+ ).rejects.toThrow("must be clean")
220
+ })
221
+
222
+ test("blocks jj-only heads when migrating to Git", async () => {
223
+ if (!Bun.which("jj")) return
224
+ await runTestEffect(
225
+ VcsMigrationService.pipe(
226
+ Effect.flatMap((service) =>
227
+ service.migrate("jj", root, { apply: true }),
228
+ ),
229
+ ),
230
+ )
231
+ const repository = join(root, "repos/agency")
232
+ await run(["jj", "-R", repository, "new", "main", "-m", "hidden head"])
233
+ await run(["jj", "-R", repository, "new", "main"])
234
+
235
+ await expect(
236
+ runTestEffect(
237
+ VcsMigrationService.pipe(
238
+ Effect.flatMap((service) =>
239
+ service.migrate("git", root, { apply: true }),
240
+ ),
241
+ ),
242
+ ),
243
+ ).rejects.toThrow("jj-only heads")
244
+ })
245
+ })