@markjaquith/agency 2.49.0 → 2.51.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 (50) hide show
  1. package/README.md +56 -9
  2. package/cli.ts +56 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +142 -38
  5. package/src/cli-parser.test.ts +81 -0
  6. package/src/cli-parser.ts +78 -1
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/review.ts +38 -0
  10. package/src/commands/task.ts +27 -5
  11. package/src/commands/vcs.test.ts +75 -0
  12. package/src/commands/vcs.ts +72 -0
  13. package/src/commands/work.test.ts +2 -0
  14. package/src/commands/work.ts +2 -2
  15. package/src/commands/worktree.ts +1 -1
  16. package/src/graph-schema.test.ts +1 -1
  17. package/src/graph-schema.ts +36 -13
  18. package/src/protocol.ts +1 -0
  19. package/src/services/ArchiveService.test.ts +2 -2
  20. package/src/services/ArchiveService.ts +24 -0
  21. package/src/services/ClaimService.ts +3 -2
  22. package/src/services/ContextService.ts +118 -17
  23. package/src/services/DoctorService.ts +58 -7
  24. package/src/services/GraphMutationService.ts +5 -0
  25. package/src/services/GraphService.ts +119 -54
  26. package/src/services/PhaseService.ts +196 -70
  27. package/src/services/PullRequestService.test.ts +2 -2
  28. package/src/services/PullRequestService.ts +37 -33
  29. package/src/services/ReadinessService.ts +7 -3
  30. package/src/services/RepositoryService.test.ts +31 -1
  31. package/src/services/RepositoryService.ts +63 -31
  32. package/src/services/ReviewService.test.ts +472 -0
  33. package/src/services/ReviewService.ts +427 -0
  34. package/src/services/SyncService.test.ts +69 -2
  35. package/src/services/SyncService.ts +161 -90
  36. package/src/services/TaskPhaseService.test.ts +80 -0
  37. package/src/services/TaskService.ts +82 -9
  38. package/src/services/VcsMigrationService.test.ts +211 -0
  39. package/src/services/VcsMigrationService.ts +816 -0
  40. package/src/services/VersionControlService.test.ts +100 -0
  41. package/src/services/VersionControlService.ts +479 -0
  42. package/src/services/WorkbaseService.ts +7 -2
  43. package/src/services/WorktreeService.test.ts +88 -17
  44. package/src/services/WorktreeService.ts +865 -329
  45. package/src/test-utils.ts +12 -0
  46. package/src/work-view.ts +14 -6
  47. package/src/workbase/AGENTS.md +2 -2
  48. package/src/workbase/schemas.test.ts +57 -0
  49. package/src/workbase/schemas.ts +57 -0
  50. package/src/workbase/version-control.ts +5 -0
@@ -0,0 +1,427 @@
1
+ import { Data, Effect, Layer } from "effect"
2
+ import { randomUUID } from "node:crypto"
3
+ import { lstat, mkdir } from "node:fs/promises"
4
+ import { dirname } from "node:path"
5
+ import { FileSystemService } from "./FileSystemService"
6
+ import { PhaseService } from "./PhaseService"
7
+ import { RepositoryService } from "./RepositoryService"
8
+ import { TaskService } from "./TaskService"
9
+ import { WorkbaseService } from "./WorkbaseService"
10
+ import {
11
+ WorktreeService,
12
+ type WorktreeRemovalSnapshot,
13
+ } from "./WorktreeService"
14
+ import {
15
+ GitVersionControlService,
16
+ JjVersionControlService,
17
+ VersionControlService,
18
+ } from "./VersionControlService"
19
+ import { withWorktreeLocks } from "./WorktreeLock"
20
+ import {
21
+ documentWriteStep,
22
+ runLifecycleTransaction,
23
+ type TransactionStep,
24
+ } from "./LifecycleTransaction"
25
+ import { RevisionConflictError } from "../workbase/document-revision"
26
+ import {
27
+ formatMarkdownDocument,
28
+ parseFrontmatter,
29
+ } from "../workbase/frontmatter"
30
+ import type { ReviewRecord, ReviewSource } from "../workbase/schemas"
31
+
32
+ class ReviewError extends Data.TaggedError("ReviewError")<{
33
+ readonly message: string
34
+ readonly cause?: unknown
35
+ }> {}
36
+
37
+ const githubRepository = (remote: string) => {
38
+ const match = remote
39
+ .replace(/\.git\/?$/, "")
40
+ .match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i)
41
+ return match?.[1]?.toLowerCase() ?? null
42
+ }
43
+
44
+ const pinRef = (taskId: string) =>
45
+ `refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
46
+
47
+ const runGit = async (args: readonly string[]) => {
48
+ const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
49
+ const [exitCode, stdout, stderr] = await Promise.all([
50
+ child.exited,
51
+ new Response(child.stdout).text(),
52
+ new Response(child.stderr).text(),
53
+ ])
54
+ if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
55
+ return stdout.trim()
56
+ }
57
+
58
+ const WorktreeLayer = Layer.mergeAll(
59
+ FileSystemService.Default,
60
+ WorkbaseService.Default,
61
+ GitVersionControlService.Default,
62
+ JjVersionControlService.Default,
63
+ VersionControlService.Default,
64
+ TaskService.Default,
65
+ PhaseService.Default,
66
+ WorktreeService.Default,
67
+ )
68
+
69
+ const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
70
+ Effect.runPromise(
71
+ effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
72
+ )
73
+
74
+ const restoreSnapshots = async (
75
+ snapshots: readonly WorktreeRemovalSnapshot[],
76
+ ) => {
77
+ for (const snapshot of snapshots) {
78
+ try {
79
+ await lstat(snapshot.path)
80
+ continue
81
+ } catch {}
82
+ await mkdir(dirname(snapshot.path), { recursive: true })
83
+ if (snapshot.vcs === "jj") {
84
+ await runGit([
85
+ "jj",
86
+ "-R",
87
+ snapshot.repositoryPath,
88
+ "workspace",
89
+ "add",
90
+ "--name",
91
+ snapshot.workspaceName!,
92
+ "-r",
93
+ snapshot.head,
94
+ snapshot.path,
95
+ ])
96
+ continue
97
+ }
98
+ await runGit(
99
+ snapshot.branch
100
+ ? [
101
+ "git",
102
+ "-C",
103
+ snapshot.repositoryPath,
104
+ "worktree",
105
+ "add",
106
+ snapshot.path,
107
+ snapshot.branch,
108
+ ]
109
+ : [
110
+ "git",
111
+ "-C",
112
+ snapshot.repositoryPath,
113
+ "worktree",
114
+ "add",
115
+ "--detach",
116
+ snapshot.path,
117
+ snapshot.head,
118
+ ],
119
+ )
120
+ }
121
+ }
122
+
123
+ const normalizeBranch = (input: string, repositoryPath: string) =>
124
+ Effect.gen(function* () {
125
+ const fs = yield* FileSystemService
126
+ if (
127
+ input.startsWith("refs/") &&
128
+ !input.startsWith("refs/heads/") &&
129
+ !input.startsWith("refs/remotes/origin/")
130
+ ) {
131
+ return yield* new ReviewError({
132
+ message: `Invalid review branch '${input}'`,
133
+ })
134
+ }
135
+ const name = input
136
+ .replace(/^refs\/remotes\/origin\//, "")
137
+ .replace(/^origin\//, "")
138
+ .replace(/^refs\/heads\//, "")
139
+ if (
140
+ !name ||
141
+ name === "HEAD" ||
142
+ name.startsWith("-") ||
143
+ /[\s:*?\[\\^~]/.test(name) ||
144
+ name.includes("..") ||
145
+ name.includes("@{")
146
+ ) {
147
+ return yield* new ReviewError({
148
+ message: `Invalid review branch '${input}'`,
149
+ })
150
+ }
151
+ const checked = yield* fs.runCommand(
152
+ ["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
153
+ { captureOutput: true },
154
+ )
155
+ if (checked.exitCode !== 0) {
156
+ return yield* new ReviewError({
157
+ message: `Invalid review branch '${input}'`,
158
+ })
159
+ }
160
+ return `refs/heads/${name}`
161
+ })
162
+
163
+ const fetchCommit = (repoPath: string, sourceRef: string) =>
164
+ Effect.gen(function* () {
165
+ const fs = yield* FileSystemService
166
+ const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
167
+ const fetched = yield* fs.runCommand(
168
+ [
169
+ "git",
170
+ "-C",
171
+ repoPath,
172
+ "fetch",
173
+ "--no-tags",
174
+ "origin",
175
+ `+${sourceRef}:${temporaryRef}`,
176
+ ],
177
+ { captureOutput: true },
178
+ )
179
+ if (fetched.exitCode !== 0) {
180
+ const cleanup = yield* fs.runCommand(
181
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
182
+ { captureOutput: true },
183
+ )
184
+ return yield* new ReviewError({
185
+ message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
186
+ })
187
+ }
188
+ const resolved = yield* fs.runCommand(
189
+ [
190
+ "git",
191
+ "-C",
192
+ repoPath,
193
+ "rev-parse",
194
+ "--verify",
195
+ `${temporaryRef}^{commit}`,
196
+ ],
197
+ { captureOutput: true },
198
+ )
199
+ const cleanup = yield* fs.runCommand(
200
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
201
+ { captureOutput: true },
202
+ )
203
+ if (cleanup.exitCode !== 0) {
204
+ return yield* new ReviewError({
205
+ message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
206
+ })
207
+ }
208
+ const commit = resolved.stdout.trim()
209
+ if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
210
+ return yield* new ReviewError({
211
+ message: `Review source '${sourceRef}' did not resolve to a commit`,
212
+ })
213
+ }
214
+ return commit
215
+ })
216
+
217
+ export class ReviewService extends Effect.Service<ReviewService>()(
218
+ "ReviewService",
219
+ {
220
+ sync: () => ({
221
+ resolve: (
222
+ repo: string,
223
+ input: { readonly pullRequest?: string; readonly ref?: string },
224
+ startPath: string = process.cwd(),
225
+ ) =>
226
+ Effect.gen(function* () {
227
+ const repositories = yield* RepositoryService
228
+ const repository = yield* repositories.show(repo, startPath)
229
+ if (!repository.remote || repository.states.includes("missing")) {
230
+ return yield* new ReviewError({
231
+ message: `Repository alias '${repo}' must be materialized with an origin remote`,
232
+ })
233
+ }
234
+ let source: ReviewSource
235
+ let sourceRef: string
236
+ if (input.pullRequest) {
237
+ const urlMatch = input.pullRequest.match(
238
+ /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/i,
239
+ )
240
+ const identifier =
241
+ urlMatch?.[2] ??
242
+ (/^\d+$/.test(input.pullRequest) ? input.pullRequest : null)
243
+ if (!identifier) {
244
+ return yield* new ReviewError({
245
+ message: `Invalid GitHub pull request '${input.pullRequest}'`,
246
+ })
247
+ }
248
+ const originRepository = githubRepository(repository.remote)
249
+ if (!originRepository) {
250
+ return yield* new ReviewError({
251
+ message: `Repository alias '${repo}' does not use a GitHub origin`,
252
+ })
253
+ }
254
+ if (urlMatch && urlMatch[1]!.toLowerCase() !== originRepository) {
255
+ return yield* new ReviewError({
256
+ message: `Pull request repository '${urlMatch[1]}' does not match alias '${repo}' origin '${originRepository}'`,
257
+ })
258
+ }
259
+ sourceRef = `refs/pull/${identifier}/head`
260
+ source = {
261
+ kind: "pull-request",
262
+ provider: "github",
263
+ repository: originRepository,
264
+ identifier,
265
+ url: `https://github.com/${originRepository}/pull/${identifier}`,
266
+ fetchRef: sourceRef,
267
+ }
268
+ } else if (input.ref) {
269
+ sourceRef = yield* normalizeBranch(input.ref, repository.path)
270
+ source = { kind: "branch", ref: sourceRef }
271
+ } else {
272
+ return yield* new ReviewError({
273
+ message: "Exactly one review source is required",
274
+ })
275
+ }
276
+ const commit = yield* fetchCommit(repository.path, sourceRef)
277
+ return {
278
+ repo,
279
+ source,
280
+ commit,
281
+ refreshedAt: new Date().toISOString(),
282
+ } satisfies ReviewRecord
283
+ }),
284
+
285
+ refresh: (
286
+ taskId: string,
287
+ startPath: string = process.cwd(),
288
+ ifRevision?: string,
289
+ ) =>
290
+ Effect.gen(function* () {
291
+ const workbase = yield* WorkbaseService
292
+ const tasks = yield* TaskService
293
+ const worktrees = yield* WorktreeService
294
+ const service = yield* ReviewService
295
+ const repositories = yield* RepositoryService
296
+ const root = yield* workbase.discover(startPath)
297
+ return yield* withWorktreeLocks(
298
+ root,
299
+ [{ taskId }],
300
+ Effect.gen(function* () {
301
+ const task = yield* tasks.show(taskId, root)
302
+ if (!("review" in task.data)) {
303
+ return yield* new ReviewError({
304
+ message: `Task '${taskId}' is not a review task`,
305
+ })
306
+ }
307
+ const previousReview = task.data.review
308
+ if (task.data.claim?.state === "active") {
309
+ return yield* new ReviewError({
310
+ message: `Review task '${taskId}' has an active claim; release or finish it before refreshing`,
311
+ })
312
+ }
313
+ if (ifRevision && task.revision !== ifRevision) {
314
+ return yield* new RevisionConflictError({
315
+ path: task.path,
316
+ target: `task '${taskId}'`,
317
+ expectedRevision: ifRevision,
318
+ currentRevision: task.revision,
319
+ message: `Revision conflict for task '${taskId}'`,
320
+ })
321
+ }
322
+ const inspection = yield* worktrees.inspect(
323
+ taskId,
324
+ undefined,
325
+ root,
326
+ )
327
+ if (
328
+ inspection.conflicts.length ||
329
+ inspection.checkouts.some((checkout) => checkout.dirty)
330
+ ) {
331
+ return yield* new ReviewError({
332
+ message: `Cannot refresh review task '${taskId}'; its checkout is dirty or structurally unexpected`,
333
+ })
334
+ }
335
+ const latest = yield* service.resolve(
336
+ task.data.review.repo,
337
+ task.data.review.source.kind === "pull-request"
338
+ ? { pullRequest: task.data.review.source.url }
339
+ : { ref: task.data.review.source.ref },
340
+ root,
341
+ )
342
+ const parsed = yield* parseFrontmatter(task.content, task.path)
343
+ const content = formatMarkdownDocument(
344
+ { ...task.data, review: latest },
345
+ parsed.body,
346
+ )
347
+ const repository = yield* repositories.show(
348
+ task.data.review.repo,
349
+ root,
350
+ )
351
+ const hadCheckout = inspection.checkouts.some(
352
+ (checkout) => checkout.exists || checkout.registered,
353
+ )
354
+ const snapshots: WorktreeRemovalSnapshot[] = []
355
+ const steps: TransactionStep[] = []
356
+ if (hadCheckout) {
357
+ steps.push({
358
+ label: `remove review checkout for ${taskId}`,
359
+ apply: () =>
360
+ runWorktreeEffect(
361
+ worktrees.remove(taskId, undefined, root, {
362
+ snapshots,
363
+ lockHeld: true,
364
+ }),
365
+ ).then(() => undefined),
366
+ rollback: () => restoreSnapshots(snapshots),
367
+ manualRecovery: `Restore the detached checkout under ${inspection.codePath}`,
368
+ })
369
+ }
370
+ steps.push(
371
+ documentWriteStep(root, [{ path: task.path, content }]),
372
+ )
373
+ steps.push({
374
+ label: `advance review pin for ${taskId}`,
375
+ apply: () =>
376
+ runGit([
377
+ "git",
378
+ "-C",
379
+ repository.path,
380
+ "update-ref",
381
+ pinRef(taskId),
382
+ latest.commit,
383
+ previousReview.commit,
384
+ ]).then(() => undefined),
385
+ rollback: () =>
386
+ runGit([
387
+ "git",
388
+ "-C",
389
+ repository.path,
390
+ "update-ref",
391
+ pinRef(taskId),
392
+ previousReview.commit,
393
+ latest.commit,
394
+ ]).then(() => undefined),
395
+ manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
396
+ })
397
+ if (hadCheckout) {
398
+ steps.push({
399
+ label: `create refreshed review checkout for ${taskId}`,
400
+ apply: () =>
401
+ runWorktreeEffect(
402
+ worktrees.materialize(taskId, undefined, root, {
403
+ lockHeld: true,
404
+ }),
405
+ ).then(() => undefined),
406
+ manualRecovery: `Run agency work prepare for review task '${taskId}'`,
407
+ })
408
+ }
409
+ yield* runLifecycleTransaction({
410
+ root,
411
+ preconditions: [{ path: task.path, revision: task.revision }],
412
+ steps,
413
+ })
414
+ return {
415
+ taskId,
416
+ previousCommit: task.data.review.commit,
417
+ commit: latest.commit,
418
+ changed: latest.commit !== task.data.review.commit,
419
+ refreshedAt: latest.refreshedAt,
420
+ revision: (yield* tasks.show(taskId, root)).revision,
421
+ }
422
+ }),
423
+ )
424
+ }),
425
+ }),
426
+ },
427
+ ) {}
@@ -22,6 +22,18 @@ const git = async (args: string[], cwd?: string) => {
22
22
  }
23
23
  }
24
24
 
25
+ const jj = async (args: string[], cwd?: string) => {
26
+ const process = Bun.spawn(["jj", ...args], {
27
+ cwd,
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ })
31
+ await process.exited
32
+ if (process.exitCode !== 0) {
33
+ throw new Error(await new Response(process.stderr).text())
34
+ }
35
+ }
36
+
25
37
  describe("SyncService", () => {
26
38
  let root: string
27
39
  let originalPath: string | undefined
@@ -109,6 +121,61 @@ pr: null
109
121
  expect(await Bun.file(join(root, "repos/agency")).exists()).toBe(false)
110
122
  })
111
123
 
124
+ test("reconciles jj workspaces through the jj backend", async () => {
125
+ if (!Bun.which("jj")) return
126
+ const repository = join(root, "repos/agency")
127
+ await rm(repository, { recursive: true, force: true })
128
+ await git(["clone", join(root, "source"), repository])
129
+ await jj(["git", "init", "--colocate", repository])
130
+ await Bun.write(
131
+ join(root, "agency.json"),
132
+ JSON.stringify({ version: 2, vcs: "jj" }),
133
+ )
134
+ await runTestEffect(
135
+ TaskService.pipe(
136
+ Effect.flatMap((service) =>
137
+ service.create(
138
+ {
139
+ id: "jj-sync",
140
+ ticketUrl: null,
141
+ repo: "agency",
142
+ branch: "task/jj-sync",
143
+ base: "main",
144
+ },
145
+ root,
146
+ ),
147
+ ),
148
+ ),
149
+ )
150
+
151
+ const planned = await runTestEffect(
152
+ SyncService.pipe(
153
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
154
+ ),
155
+ )
156
+ expect(planned.changes).toContainEqual(
157
+ expect.objectContaining({
158
+ kind: "materialize-workspace",
159
+ target: "task:jj-sync",
160
+ status: "planned",
161
+ }),
162
+ )
163
+
164
+ const applied = await runTestEffect(
165
+ SyncService.pipe(
166
+ Effect.flatMap((service) =>
167
+ service.reconcile({ cwd: root, apply: true }),
168
+ ),
169
+ ),
170
+ )
171
+ expect(applied.executions[0]?.checkouts[0]).toMatchObject({
172
+ exists: true,
173
+ registered: true,
174
+ branch: "task/jj-sync",
175
+ dirty: false,
176
+ })
177
+ })
178
+
112
179
  test("observes drift without mutation and applies only safe transitions", async () => {
113
180
  await runTestEffect(
114
181
  TaskService.pipe(
@@ -583,7 +650,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
583
650
  ),
584
651
  ),
585
652
  )
586
- await rm(workspace.writablePath, { recursive: true, force: true })
653
+ await rm(workspace.writablePath!, { recursive: true, force: true })
587
654
 
588
655
  const observed = await runTestEffect(
589
656
  SyncService.pipe(
@@ -607,7 +674,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
607
674
  )
608
675
  expect(applied.changes).toEqual([])
609
676
  expect(
610
- await Bun.file(join(workspace.writablePath, "README.md")).exists(),
677
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
611
678
  ).toBe(false)
612
679
  })
613
680