@markjaquith/agency 2.54.1 → 2.54.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.54.1",
3
+ "version": "2.54.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -10,6 +10,7 @@ import { RepositoryService } from "./RepositoryService"
10
10
  import { TaskService } from "./TaskService"
11
11
  import { WorkbaseService } from "./WorkbaseService"
12
12
  import { WorktreeService } from "./WorktreeService"
13
+ import { VersionControlService } from "./VersionControlService"
13
14
 
14
15
  type DoctorCheckLevel = "error" | "warning" | "optional"
15
16
 
@@ -87,7 +88,9 @@ export class DoctorService extends Effect.Service<DoctorService>()(
87
88
  const tasks = yield* TaskService
88
89
  const workbases = yield* WorkbaseService
89
90
  const worktrees = yield* WorktreeService
91
+ const versionControl = yield* VersionControlService
90
92
  const { root, config } = yield* workbases.loadConfig(startPath)
93
+ const backend = yield* versionControl.forWorkbase(root)
91
94
  const checks: DoctorCheck[] = []
92
95
  const add = (
93
96
  check: Omit<DoctorCheck, "remediation"> & {
@@ -409,32 +412,8 @@ export class DoctorService extends Effect.Service<DoctorService>()(
409
412
  }
410
413
 
411
414
  for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
412
- const local = yield* fs.runCommand(
413
- [
414
- "git",
415
- "-C",
416
- repository.path,
417
- "rev-parse",
418
- "--verify",
419
- `${ref}^{commit}`,
420
- ],
421
- { captureOutput: true },
422
- )
423
- const remote =
424
- local.exitCode === 0
425
- ? local
426
- : yield* fs.runCommand(
427
- [
428
- "git",
429
- "-C",
430
- repository.path,
431
- "rev-parse",
432
- "--verify",
433
- `origin/${ref}^{commit}`,
434
- ],
435
- { captureOutput: true },
436
- )
437
- const found = local.exitCode === 0 || remote.exitCode === 0
415
+ const found =
416
+ (yield* backend.resolveRevision(repository.path, ref)) !== null
438
417
  add({
439
418
  id: `ref.${repository.alias}.${ref}`,
440
419
  category: "ref",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { TaskService } from "./TaskService"
@@ -13,6 +13,7 @@ import { PhaseService } from "./PhaseService"
13
13
  import { ArchiveService } from "./ArchiveService"
14
14
  import { ClaimService } from "./ClaimService"
15
15
  import { SyncService } from "./SyncService"
16
+ import { DoctorService } from "./DoctorService"
16
17
  import { task as taskCommand } from "../commands/task"
17
18
 
18
19
  const git = async (args: string[], cwd?: string) => {
@@ -26,6 +27,16 @@ const git = async (args: string[], cwd?: string) => {
26
27
  throw new Error(await new Response(child.stderr).text())
27
28
  }
28
29
 
30
+ const jj = async (args: string[]) => {
31
+ const child = Bun.spawn(["jj", ...args], {
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ })
35
+ await child.exited
36
+ if (child.exitCode !== 0)
37
+ throw new Error(await new Response(child.stderr).text())
38
+ }
39
+
29
40
  describe("ReviewService", () => {
30
41
  let root: string
31
42
  let source: string
@@ -151,6 +162,78 @@ describe("ReviewService", () => {
151
162
  expect(new TextDecoder().decode(oldPins.stdout).trim()).toBe("")
152
163
  })
153
164
 
165
+ test("imports jj reviews for creation, diagnostics, materialization, and refresh", async () => {
166
+ if (!Bun.which("jj")) return
167
+ const repository = join(root, "repos/agency")
168
+ await rm(repository, { recursive: true, force: true })
169
+ await git(["clone", source, repository])
170
+ await git(["switch", "-c", "jj-review"], source)
171
+ await Bun.write(join(source, "README.md"), "jj review one\n")
172
+ await git(["commit", "-am", "jj review one"], source)
173
+ await jj(["git", "init", "--colocate", repository])
174
+ await Bun.write(
175
+ join(root, "agency.json"),
176
+ JSON.stringify({ version: 2, vcs: "jj" }),
177
+ )
178
+
179
+ await runTestEffect(
180
+ taskCommand({
181
+ subcommand: "create",
182
+ args: ["jj-review"],
183
+ review: "agency",
184
+ ref: "jj-review",
185
+ cwd: root,
186
+ silent: true,
187
+ }),
188
+ )
189
+ const created = await runTestEffect(
190
+ TaskService.pipe(
191
+ Effect.flatMap((service) => service.show("jj-review", root)),
192
+ ),
193
+ )
194
+ const original = "review" in created.data ? created.data.review.commit : ""
195
+
196
+ const context = await runTestEffect(
197
+ ContextService.pipe(
198
+ Effect.flatMap((service) =>
199
+ service.get({ target: "jj-review", cwd: root }),
200
+ ),
201
+ ),
202
+ )
203
+ expect(context.workspace!.warnings).toEqual([])
204
+ expect(context.review?.checkout?.resolvedCommit).toBe(original)
205
+
206
+ const doctor = await runTestEffect(
207
+ DoctorService.pipe(Effect.flatMap((service) => service.inspect(root))),
208
+ )
209
+ expect(
210
+ doctor.checks.find((check) => check.id === `ref.agency.${original}`),
211
+ ).toMatchObject({ status: "pass" })
212
+
213
+ const workspace = await runTestEffect(
214
+ WorktreeService.pipe(
215
+ Effect.flatMap((service) =>
216
+ service.materialize("jj-review", undefined, root),
217
+ ),
218
+ ),
219
+ )
220
+ expect(
221
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
222
+ ).toBe("jj review one\n")
223
+
224
+ await Bun.write(join(source, "README.md"), "jj review two\n")
225
+ await git(["commit", "-am", "jj review two"], source)
226
+ const refreshed = await runTestEffect(
227
+ ReviewService.pipe(
228
+ Effect.flatMap((service) => service.refresh("jj-review", root)),
229
+ ),
230
+ )
231
+ expect(refreshed.changed).toBe(true)
232
+ expect(
233
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
234
+ ).toBe("jj review two\n")
235
+ })
236
+
154
237
  test("rejects delivery and phase operations even when forced", async () => {
155
238
  await createReview()
156
239
  await expect(
@@ -1,4 +1,4 @@
1
- import { Data, Effect, Layer } from "effect"
1
+ import { Data, Effect, Either, Layer } from "effect"
2
2
  import { randomUUID } from "node:crypto"
3
3
  import { lstat, mkdir } from "node:fs/promises"
4
4
  import { dirname } from "node:path"
@@ -15,6 +15,7 @@ import {
15
15
  GitVersionControlService,
16
16
  JjVersionControlService,
17
17
  VersionControlService,
18
+ type VersionControlBackend,
18
19
  } from "./VersionControlService"
19
20
  import { withWorktreeLocks } from "./WorktreeLock"
20
21
  import {
@@ -160,7 +161,11 @@ const normalizeBranch = (input: string, repositoryPath: string) =>
160
161
  return `refs/heads/${name}`
161
162
  })
162
163
 
163
- const fetchCommit = (repoPath: string, sourceRef: string) =>
164
+ const fetchCommit = (
165
+ repoPath: string,
166
+ sourceRef: string,
167
+ backend: VersionControlBackend,
168
+ ) =>
164
169
  Effect.gen(function* () {
165
170
  const fs = yield* FileSystemService
166
171
  const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
@@ -196,6 +201,17 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
196
201
  ],
197
202
  { captureOutput: true },
198
203
  )
204
+ const commit = resolved.stdout.trim()
205
+ if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
206
+ yield* fs.runCommand(
207
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
208
+ { captureOutput: true },
209
+ )
210
+ return yield* new ReviewError({
211
+ message: `Review source '${sourceRef}' did not resolve to a commit`,
212
+ })
213
+ }
214
+ const imported = yield* Effect.either(backend.importGitRefs(repoPath))
199
215
  const cleanup = yield* fs.runCommand(
200
216
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
201
217
  { captureOutput: true },
@@ -205,10 +221,10 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
205
221
  message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
206
222
  })
207
223
  }
208
- const commit = resolved.stdout.trim()
209
- if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
224
+ if (Either.isLeft(imported)) {
210
225
  return yield* new ReviewError({
211
- message: `Review source '${sourceRef}' did not resolve to a commit`,
226
+ message: `Review source '${sourceRef}' was fetched but could not be imported into ${backend.kind}`,
227
+ cause: imported.left,
212
228
  })
213
229
  }
214
230
  return commit
@@ -225,6 +241,8 @@ export class ReviewService extends Effect.Service<ReviewService>()(
225
241
  ) =>
226
242
  Effect.gen(function* () {
227
243
  const repositories = yield* RepositoryService
244
+ const versionControl = yield* VersionControlService
245
+ const backend = yield* versionControl.forWorkbase(startPath)
228
246
  const repository = yield* repositories.show(repo, startPath)
229
247
  if (!repository.remote || repository.states.includes("missing")) {
230
248
  return yield* new ReviewError({
@@ -273,7 +291,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
273
291
  message: "Exactly one review source is required",
274
292
  })
275
293
  }
276
- const commit = yield* fetchCommit(repository.path, sourceRef)
294
+ const commit = yield* fetchCommit(repository.path, sourceRef, backend)
277
295
  return {
278
296
  repo,
279
297
  source,
@@ -47,6 +47,9 @@ export interface VersionControlBackend {
47
47
  remote?: string,
48
48
  branch?: string,
49
49
  ) => Effect.Effect<void, unknown, any>
50
+ readonly importGitRefs: (
51
+ repositoryPath: string,
52
+ ) => Effect.Effect<void, unknown, any>
50
53
  readonly push: (
51
54
  workspacePath: string,
52
55
  remote: string,
@@ -240,6 +243,7 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
240
243
  ),
241
244
  )
242
245
  }),
246
+ importGitRefs: () => Effect.void,
243
247
  push: (workspacePath, remote, branch) =>
244
248
  Effect.gen(function* () {
245
249
  const fs = yield* FileSystemService
@@ -333,18 +337,27 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
333
337
  resolveRevision: (repositoryPath, revision) =>
334
338
  Effect.gen(function* () {
335
339
  const fs = yield* FileSystemService
336
- const result = yield* fs.runCommand(
337
- jjCommand(repositoryPath, [
338
- "log",
339
- "--ignore-working-copy",
340
- "--no-graph",
341
- "-r",
342
- revision,
343
- "-T",
344
- 'commit_id ++ "\\n"',
345
- ]),
346
- { captureOutput: true },
347
- )
340
+ const resolve = () =>
341
+ fs.runCommand(
342
+ jjCommand(repositoryPath, [
343
+ "log",
344
+ "--ignore-working-copy",
345
+ "--no-graph",
346
+ "-r",
347
+ revision,
348
+ "-T",
349
+ 'commit_id ++ "\\n"',
350
+ ]),
351
+ { captureOutput: true },
352
+ )
353
+ let result = yield* resolve()
354
+ if (result.exitCode !== 0 || !result.stdout.trim()) {
355
+ const imported = yield* fs.runCommand(
356
+ jjCommand(repositoryPath, ["git", "import"]),
357
+ { captureOutput: true },
358
+ )
359
+ if (imported.exitCode === 0) result = yield* resolve()
360
+ }
348
361
  return result.exitCode === 0 ? result.stdout.trim() || null : null
349
362
  }),
350
363
  workspaceHead: (workspacePath) =>
@@ -430,6 +443,16 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
430
443
  ),
431
444
  )
432
445
  }),
446
+ importGitRefs: (repositoryPath) =>
447
+ Effect.gen(function* () {
448
+ const fs = yield* FileSystemService
449
+ yield* requireSuccess(
450
+ "Failed to import Git refs into jj",
451
+ fs.runCommand(jjCommand(repositoryPath, ["git", "import"]), {
452
+ captureOutput: true,
453
+ }),
454
+ )
455
+ }),
433
456
  push: (workspacePath, remote, branch) =>
434
457
  Effect.gen(function* () {
435
458
  const fs = yield* FileSystemService
@@ -164,6 +164,44 @@ describe("WorktreeService", () => {
164
164
  expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
165
165
  })
166
166
 
167
+ test("recommends a repository fetch when jj cannot resolve a base", async () => {
168
+ if (!Bun.which("jj")) return
169
+ const repository = join(root, "repos/agency")
170
+ await rm(repository, { recursive: true, force: true })
171
+ await git(["clone", source, repository])
172
+ await jj(["git", "init", "--colocate", repository])
173
+ await Bun.write(
174
+ join(root, "agency.json"),
175
+ JSON.stringify({ version: 2, vcs: "jj" }),
176
+ )
177
+ await runTestEffect(
178
+ TaskService.pipe(
179
+ Effect.flatMap((service) =>
180
+ service.create(
181
+ {
182
+ id: "jj-missing",
183
+ ticketUrl: null,
184
+ repo: "agency",
185
+ branch: "task/jj-missing",
186
+ base: "absent-base",
187
+ },
188
+ root,
189
+ ),
190
+ ),
191
+ ),
192
+ )
193
+
194
+ await expect(
195
+ runTestEffect(
196
+ WorktreeService.pipe(
197
+ Effect.flatMap((service) =>
198
+ service.materialize("jj-missing", undefined, root),
199
+ ),
200
+ ),
201
+ ),
202
+ ).rejects.toThrow("run 'agency repo fetch agency' and retry")
203
+ })
204
+
167
205
  test("does not fetch the origin for an existing writable worktree", async () => {
168
206
  await runTestEffect(
169
207
  TaskService.pipe(
@@ -884,8 +884,12 @@ const materializeJj = (options: {
884
884
  revision = yield* backend.resolveRevision(repositoryPath, base)
885
885
  }
886
886
  if (!revision) {
887
+ const recovery =
888
+ backend.kind === "jj"
889
+ ? `; run 'agency repo fetch ${checkout.repo}' and retry`
890
+ : ""
887
891
  return yield* new WorktreeError({
888
- message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit`,
892
+ message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit${recovery}`,
889
893
  })
890
894
  }
891
895