@markjaquith/agency 2.14.0 → 2.16.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 +43 -2
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +15 -0
- package/src/cli-parser.ts +15 -0
- package/src/commands/sync.ts +36 -0
- package/src/commands/task.ts +31 -34
- package/src/commands/validate.ts +1 -6
- package/src/commands/work.test.ts +20 -12
- package/src/commands/work.ts +4 -14
- package/src/protocol.ts +5 -0
- package/src/services/ClaimService.ts +94 -0
- package/src/services/SyncService.test.ts +364 -0
- package/src/services/SyncService.ts +738 -0
- package/src/test-utils.ts +2 -0
- package/src/utils/chooser.test.ts +108 -0
- package/src/utils/chooser.ts +222 -0
- package/src/workbase/schemas.test.ts +12 -0
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/work-target.test.ts +10 -0
- package/src/workbase/work-target.ts +45 -35
- package/src/workbase/workbase-choice.ts +12 -37
|
@@ -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
|
+
})
|