@markjaquith/agency 2.49.0 → 2.50.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,404 @@
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 { withWorktreeLocks } from "./WorktreeLock"
15
+ import {
16
+ documentWriteStep,
17
+ runLifecycleTransaction,
18
+ type TransactionStep,
19
+ } from "./LifecycleTransaction"
20
+ import { RevisionConflictError } from "../workbase/document-revision"
21
+ import {
22
+ formatMarkdownDocument,
23
+ parseFrontmatter,
24
+ } from "../workbase/frontmatter"
25
+ import type { ReviewRecord, ReviewSource } from "../workbase/schemas"
26
+
27
+ class ReviewError extends Data.TaggedError("ReviewError")<{
28
+ readonly message: string
29
+ readonly cause?: unknown
30
+ }> {}
31
+
32
+ const githubRepository = (remote: string) => {
33
+ const match = remote
34
+ .replace(/\.git\/?$/, "")
35
+ .match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i)
36
+ return match?.[1]?.toLowerCase() ?? null
37
+ }
38
+
39
+ const pinRef = (taskId: string) =>
40
+ `refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
41
+
42
+ const runGit = async (args: readonly string[]) => {
43
+ const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
44
+ const [exitCode, stdout, stderr] = await Promise.all([
45
+ child.exited,
46
+ new Response(child.stdout).text(),
47
+ new Response(child.stderr).text(),
48
+ ])
49
+ if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
50
+ return stdout.trim()
51
+ }
52
+
53
+ const WorktreeLayer = Layer.mergeAll(
54
+ FileSystemService.Default,
55
+ WorkbaseService.Default,
56
+ TaskService.Default,
57
+ PhaseService.Default,
58
+ WorktreeService.Default,
59
+ )
60
+
61
+ const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
62
+ Effect.runPromise(
63
+ effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
64
+ )
65
+
66
+ const restoreSnapshots = async (
67
+ snapshots: readonly WorktreeRemovalSnapshot[],
68
+ ) => {
69
+ for (const snapshot of snapshots) {
70
+ try {
71
+ await lstat(snapshot.path)
72
+ continue
73
+ } catch {}
74
+ await mkdir(dirname(snapshot.path), { recursive: true })
75
+ await runGit(
76
+ snapshot.branch
77
+ ? [
78
+ "git",
79
+ "-C",
80
+ snapshot.repositoryPath,
81
+ "worktree",
82
+ "add",
83
+ snapshot.path,
84
+ snapshot.branch,
85
+ ]
86
+ : [
87
+ "git",
88
+ "-C",
89
+ snapshot.repositoryPath,
90
+ "worktree",
91
+ "add",
92
+ "--detach",
93
+ snapshot.path,
94
+ snapshot.head,
95
+ ],
96
+ )
97
+ }
98
+ }
99
+
100
+ const normalizeBranch = (input: string, repositoryPath: string) =>
101
+ Effect.gen(function* () {
102
+ const fs = yield* FileSystemService
103
+ if (
104
+ input.startsWith("refs/") &&
105
+ !input.startsWith("refs/heads/") &&
106
+ !input.startsWith("refs/remotes/origin/")
107
+ ) {
108
+ return yield* new ReviewError({
109
+ message: `Invalid review branch '${input}'`,
110
+ })
111
+ }
112
+ const name = input
113
+ .replace(/^refs\/remotes\/origin\//, "")
114
+ .replace(/^origin\//, "")
115
+ .replace(/^refs\/heads\//, "")
116
+ if (
117
+ !name ||
118
+ name === "HEAD" ||
119
+ name.startsWith("-") ||
120
+ /[\s:*?\[\\^~]/.test(name) ||
121
+ name.includes("..") ||
122
+ name.includes("@{")
123
+ ) {
124
+ return yield* new ReviewError({
125
+ message: `Invalid review branch '${input}'`,
126
+ })
127
+ }
128
+ const checked = yield* fs.runCommand(
129
+ ["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
130
+ { captureOutput: true },
131
+ )
132
+ if (checked.exitCode !== 0) {
133
+ return yield* new ReviewError({
134
+ message: `Invalid review branch '${input}'`,
135
+ })
136
+ }
137
+ return `refs/heads/${name}`
138
+ })
139
+
140
+ const fetchCommit = (repoPath: string, sourceRef: string) =>
141
+ Effect.gen(function* () {
142
+ const fs = yield* FileSystemService
143
+ const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
144
+ const fetched = yield* fs.runCommand(
145
+ [
146
+ "git",
147
+ "-C",
148
+ repoPath,
149
+ "fetch",
150
+ "--no-tags",
151
+ "origin",
152
+ `+${sourceRef}:${temporaryRef}`,
153
+ ],
154
+ { captureOutput: true },
155
+ )
156
+ if (fetched.exitCode !== 0) {
157
+ const cleanup = yield* fs.runCommand(
158
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
159
+ { captureOutput: true },
160
+ )
161
+ return yield* new ReviewError({
162
+ message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
163
+ })
164
+ }
165
+ const resolved = yield* fs.runCommand(
166
+ [
167
+ "git",
168
+ "-C",
169
+ repoPath,
170
+ "rev-parse",
171
+ "--verify",
172
+ `${temporaryRef}^{commit}`,
173
+ ],
174
+ { captureOutput: true },
175
+ )
176
+ const cleanup = yield* fs.runCommand(
177
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
178
+ { captureOutput: true },
179
+ )
180
+ if (cleanup.exitCode !== 0) {
181
+ return yield* new ReviewError({
182
+ message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
183
+ })
184
+ }
185
+ const commit = resolved.stdout.trim()
186
+ if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
187
+ return yield* new ReviewError({
188
+ message: `Review source '${sourceRef}' did not resolve to a commit`,
189
+ })
190
+ }
191
+ return commit
192
+ })
193
+
194
+ export class ReviewService extends Effect.Service<ReviewService>()(
195
+ "ReviewService",
196
+ {
197
+ sync: () => ({
198
+ resolve: (
199
+ repo: string,
200
+ input: { readonly pullRequest?: string; readonly ref?: string },
201
+ startPath: string = process.cwd(),
202
+ ) =>
203
+ Effect.gen(function* () {
204
+ const repositories = yield* RepositoryService
205
+ const repository = yield* repositories.show(repo, startPath)
206
+ if (!repository.remote || repository.states.includes("missing")) {
207
+ return yield* new ReviewError({
208
+ message: `Repository alias '${repo}' must be materialized with an origin remote`,
209
+ })
210
+ }
211
+ let source: ReviewSource
212
+ let sourceRef: string
213
+ if (input.pullRequest) {
214
+ const urlMatch = input.pullRequest.match(
215
+ /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/i,
216
+ )
217
+ const identifier =
218
+ urlMatch?.[2] ??
219
+ (/^\d+$/.test(input.pullRequest) ? input.pullRequest : null)
220
+ if (!identifier) {
221
+ return yield* new ReviewError({
222
+ message: `Invalid GitHub pull request '${input.pullRequest}'`,
223
+ })
224
+ }
225
+ const originRepository = githubRepository(repository.remote)
226
+ if (!originRepository) {
227
+ return yield* new ReviewError({
228
+ message: `Repository alias '${repo}' does not use a GitHub origin`,
229
+ })
230
+ }
231
+ if (urlMatch && urlMatch[1]!.toLowerCase() !== originRepository) {
232
+ return yield* new ReviewError({
233
+ message: `Pull request repository '${urlMatch[1]}' does not match alias '${repo}' origin '${originRepository}'`,
234
+ })
235
+ }
236
+ sourceRef = `refs/pull/${identifier}/head`
237
+ source = {
238
+ kind: "pull-request",
239
+ provider: "github",
240
+ repository: originRepository,
241
+ identifier,
242
+ url: `https://github.com/${originRepository}/pull/${identifier}`,
243
+ fetchRef: sourceRef,
244
+ }
245
+ } else if (input.ref) {
246
+ sourceRef = yield* normalizeBranch(input.ref, repository.path)
247
+ source = { kind: "branch", ref: sourceRef }
248
+ } else {
249
+ return yield* new ReviewError({
250
+ message: "Exactly one review source is required",
251
+ })
252
+ }
253
+ const commit = yield* fetchCommit(repository.path, sourceRef)
254
+ return {
255
+ repo,
256
+ source,
257
+ commit,
258
+ refreshedAt: new Date().toISOString(),
259
+ } satisfies ReviewRecord
260
+ }),
261
+
262
+ refresh: (
263
+ taskId: string,
264
+ startPath: string = process.cwd(),
265
+ ifRevision?: string,
266
+ ) =>
267
+ Effect.gen(function* () {
268
+ const workbase = yield* WorkbaseService
269
+ const tasks = yield* TaskService
270
+ const worktrees = yield* WorktreeService
271
+ const service = yield* ReviewService
272
+ const repositories = yield* RepositoryService
273
+ const root = yield* workbase.discover(startPath)
274
+ return yield* withWorktreeLocks(
275
+ root,
276
+ [{ taskId }],
277
+ Effect.gen(function* () {
278
+ const task = yield* tasks.show(taskId, root)
279
+ if (!("review" in task.data)) {
280
+ return yield* new ReviewError({
281
+ message: `Task '${taskId}' is not a review task`,
282
+ })
283
+ }
284
+ const previousReview = task.data.review
285
+ if (task.data.claim?.state === "active") {
286
+ return yield* new ReviewError({
287
+ message: `Review task '${taskId}' has an active claim; release or finish it before refreshing`,
288
+ })
289
+ }
290
+ if (ifRevision && task.revision !== ifRevision) {
291
+ return yield* new RevisionConflictError({
292
+ path: task.path,
293
+ target: `task '${taskId}'`,
294
+ expectedRevision: ifRevision,
295
+ currentRevision: task.revision,
296
+ message: `Revision conflict for task '${taskId}'`,
297
+ })
298
+ }
299
+ const inspection = yield* worktrees.inspect(
300
+ taskId,
301
+ undefined,
302
+ root,
303
+ )
304
+ if (
305
+ inspection.conflicts.length ||
306
+ inspection.checkouts.some((checkout) => checkout.dirty)
307
+ ) {
308
+ return yield* new ReviewError({
309
+ message: `Cannot refresh review task '${taskId}'; its checkout is dirty or structurally unexpected`,
310
+ })
311
+ }
312
+ const latest = yield* service.resolve(
313
+ task.data.review.repo,
314
+ task.data.review.source.kind === "pull-request"
315
+ ? { pullRequest: task.data.review.source.url }
316
+ : { ref: task.data.review.source.ref },
317
+ root,
318
+ )
319
+ const parsed = yield* parseFrontmatter(task.content, task.path)
320
+ const content = formatMarkdownDocument(
321
+ { ...task.data, review: latest },
322
+ parsed.body,
323
+ )
324
+ const repository = yield* repositories.show(
325
+ task.data.review.repo,
326
+ root,
327
+ )
328
+ const hadCheckout = inspection.checkouts.some(
329
+ (checkout) => checkout.exists || checkout.registered,
330
+ )
331
+ const snapshots: WorktreeRemovalSnapshot[] = []
332
+ const steps: TransactionStep[] = []
333
+ if (hadCheckout) {
334
+ steps.push({
335
+ label: `remove review checkout for ${taskId}`,
336
+ apply: () =>
337
+ runWorktreeEffect(
338
+ worktrees.remove(taskId, undefined, root, {
339
+ snapshots,
340
+ lockHeld: true,
341
+ }),
342
+ ).then(() => undefined),
343
+ rollback: () => restoreSnapshots(snapshots),
344
+ manualRecovery: `Restore the detached checkout under ${inspection.codePath}`,
345
+ })
346
+ }
347
+ steps.push(
348
+ documentWriteStep(root, [{ path: task.path, content }]),
349
+ )
350
+ steps.push({
351
+ label: `advance review pin for ${taskId}`,
352
+ apply: () =>
353
+ runGit([
354
+ "git",
355
+ "-C",
356
+ repository.path,
357
+ "update-ref",
358
+ pinRef(taskId),
359
+ latest.commit,
360
+ previousReview.commit,
361
+ ]).then(() => undefined),
362
+ rollback: () =>
363
+ runGit([
364
+ "git",
365
+ "-C",
366
+ repository.path,
367
+ "update-ref",
368
+ pinRef(taskId),
369
+ previousReview.commit,
370
+ latest.commit,
371
+ ]).then(() => undefined),
372
+ manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
373
+ })
374
+ if (hadCheckout) {
375
+ steps.push({
376
+ label: `create refreshed review checkout for ${taskId}`,
377
+ apply: () =>
378
+ runWorktreeEffect(
379
+ worktrees.materialize(taskId, undefined, root, {
380
+ lockHeld: true,
381
+ }),
382
+ ).then(() => undefined),
383
+ manualRecovery: `Run agency work prepare for review task '${taskId}'`,
384
+ })
385
+ }
386
+ yield* runLifecycleTransaction({
387
+ root,
388
+ preconditions: [{ path: task.path, revision: task.revision }],
389
+ steps,
390
+ })
391
+ return {
392
+ taskId,
393
+ previousCommit: task.data.review.commit,
394
+ commit: latest.commit,
395
+ changed: latest.commit !== task.data.review.commit,
396
+ refreshedAt: latest.refreshedAt,
397
+ revision: (yield* tasks.show(taskId, root)).revision,
398
+ }
399
+ }),
400
+ )
401
+ }),
402
+ }),
403
+ },
404
+ ) {}
@@ -583,7 +583,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
583
583
  ),
584
584
  ),
585
585
  )
586
- await rm(workspace.writablePath, { recursive: true, force: true })
586
+ await rm(workspace.writablePath!, { recursive: true, force: true })
587
587
 
588
588
  const observed = await runTestEffect(
589
589
  SyncService.pipe(
@@ -607,7 +607,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
607
607
  )
608
608
  expect(applied.changes).toEqual([])
609
609
  expect(
610
- await Bun.file(join(workspace.writablePath, "README.md")).exists(),
610
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
611
611
  ).toBe(false)
612
612
  })
613
613
 
@@ -83,11 +83,16 @@ interface CheckoutState {
83
83
  interface ExecutionSyncState {
84
84
  readonly target: string
85
85
  readonly status: WorkStatus
86
- readonly branch: string
87
- readonly base: string
86
+ readonly branch: string | null
87
+ readonly base: string | null
88
88
  readonly claim: ClaimRecord | null
89
89
  readonly checkouts: readonly CheckoutState[]
90
90
  readonly pr: Record<string, unknown>
91
+ readonly review?: {
92
+ readonly pinnedCommit: string
93
+ readonly sourceCommit: string | null
94
+ readonly sourceAvailable: boolean
95
+ }
91
96
  }
92
97
 
93
98
  interface SyncResult {
@@ -219,7 +224,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
219
224
  data: phase.data,
220
225
  })
221
226
  }
222
- } else {
227
+ } else if (!("review" in task.data)) {
223
228
  records.push({
224
229
  key: `task:${task.id}`,
225
230
  taskId: task.id,
@@ -551,7 +556,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
551
556
  },
552
557
  root,
553
558
  )
554
- data = expired.data
559
+ data = expired.data as ExecutionData
555
560
  revision = expired.revision
556
561
  } else {
557
562
  const claim: ClaimRecord = {
@@ -787,7 +792,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
787
792
  },
788
793
  root,
789
794
  )
790
- data = recorded.data
795
+ data = recorded.data as ExecutionData
791
796
  revision = recorded.revision
792
797
  }
793
798
  changes.push({
@@ -823,7 +828,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
823
828
  },
824
829
  root,
825
830
  )
826
- data = completed.data
831
+ data = completed.data as ExecutionData
827
832
  revision = completed.revision
828
833
  }
829
834
  changes.push({
@@ -846,6 +851,105 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
846
851
  })
847
852
  }
848
853
 
854
+ for (const task of (yield* tasks.list(root)).filter(
855
+ (task) => "review" in task.data,
856
+ )) {
857
+ if (!("review" in task.data)) continue
858
+ let data = task.data
859
+ let revision = task.revision
860
+ if (
861
+ isExpired(data.claim, now) &&
862
+ (data.status === "working" || data.status === "delegated")
863
+ ) {
864
+ if (apply) {
865
+ const expired = yield* claims.expire(
866
+ { taskId: task.id, revision, now },
867
+ root,
868
+ )
869
+ if ("review" in expired.data) data = expired.data
870
+ revision = expired.revision
871
+ }
872
+ changes.push({
873
+ kind: "release-stale-claim",
874
+ target: `task:${task.id}`,
875
+ message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
876
+ status: apply ? "applied" : "planned",
877
+ })
878
+ }
879
+ const inspection = yield* worktrees.inspect(task.id, undefined, root)
880
+ for (const conflict of inspection.conflicts) {
881
+ unresolved.push({
882
+ kind: conflict.kind,
883
+ target: `task:${task.id}`,
884
+ message: conflict.message,
885
+ action: "Repair or remove the review checkout explicitly",
886
+ })
887
+ }
888
+ const checkout = inspection.checkouts[0]
889
+ if (
890
+ !checkout?.exists &&
891
+ inspection.conflicts.length === 0 &&
892
+ (data.status === "working" || data.status === "delegated")
893
+ ) {
894
+ if (apply) yield* worktrees.materialize(task.id, undefined, root)
895
+ changes.push({
896
+ kind: "materialize-workspace",
897
+ target: `task:${task.id}`,
898
+ message: `Materialize pinned review checkout under ${inspection.codePath}`,
899
+ status: apply ? "applied" : "planned",
900
+ })
901
+ }
902
+ const repositoryPath = join(root, "repos", data.review.repo)
903
+ const source = yield* runExternal([
904
+ "git",
905
+ "-C",
906
+ repositoryPath,
907
+ "ls-remote",
908
+ "origin",
909
+ data.review.source.kind === "pull-request"
910
+ ? data.review.source.fetchRef
911
+ : originRef(data.review.source.ref),
912
+ ])
913
+ const sourceCommit = source.stdout.trim().split(/\s+/)[0] || null
914
+ if (!sourceCommit) {
915
+ warnings.push({
916
+ kind: "review-source-unavailable",
917
+ target: `task:${task.id}`,
918
+ message:
919
+ "Review source is unavailable; the pinned commit is unchanged",
920
+ })
921
+ }
922
+ executions.push({
923
+ target: `task:${task.id}`,
924
+ status: data.status,
925
+ branch: null,
926
+ base: null,
927
+ claim: data.claim ?? null,
928
+ checkouts: checkout
929
+ ? [
930
+ {
931
+ repo: checkout.repo,
932
+ kind: "reference",
933
+ path: checkout.path,
934
+ requestedRef: data.review.commit,
935
+ resolvedCommit: checkout.expectedCommit,
936
+ registered: checkout.registered,
937
+ exists: checkout.exists,
938
+ head: checkout.actualCommit,
939
+ branch: checkout.actualBranch,
940
+ dirty: checkout.dirty,
941
+ },
942
+ ]
943
+ : [],
944
+ pr: { url: null, state: "none" },
945
+ review: {
946
+ pinnedCommit: data.review.commit,
947
+ sourceCommit,
948
+ sourceAvailable: sourceCommit !== null,
949
+ },
950
+ })
951
+ }
952
+
849
953
  return {
850
954
  root,
851
955
  mode: apply ? "apply" : "dry-run",