@markjaquith/agency 2.57.0 → 2.58.1

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.
@@ -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 { ArchiveService } from "./ArchiveService"
@@ -22,8 +22,33 @@ const git = async (args: string[], cwd?: string) => {
22
22
  }
23
23
  }
24
24
 
25
+ const jj = async (args: string[]) => {
26
+ const process = Bun.spawn(["jj", ...args], {
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ })
30
+ await process.exited
31
+ if (process.exitCode !== 0) {
32
+ throw new Error(await new Response(process.stderr).text())
33
+ }
34
+ }
35
+
25
36
  describe("ArchiveService", () => {
26
37
  let root: string
38
+ const dropTask = (id: string) =>
39
+ runTestEffect(
40
+ TaskService.pipe(
41
+ Effect.flatMap((service) => service.setStatus(id, "dropped", root)),
42
+ ),
43
+ )
44
+ const dropPhase = (taskId: string, id: string) =>
45
+ runTestEffect(
46
+ PhaseService.pipe(
47
+ Effect.flatMap((service) =>
48
+ service.setStatus(taskId, id, "dropped", root),
49
+ ),
50
+ ),
51
+ )
27
52
 
28
53
  beforeEach(async () => {
29
54
  root = await createTempDir()
@@ -42,6 +67,142 @@ describe("ArchiveService", () => {
42
67
 
43
68
  afterEach(async () => cleanupTempDir(root))
44
69
 
70
+ test("requires terminal effective status for singular task archival", async () => {
71
+ await runTestEffect(
72
+ TaskService.pipe(
73
+ Effect.flatMap((service) =>
74
+ service.create(
75
+ {
76
+ id: "status-check",
77
+ ticketUrl: null,
78
+ repo: "agency",
79
+ branch: "task/status-check",
80
+ base: "main",
81
+ },
82
+ root,
83
+ ),
84
+ ),
85
+ ),
86
+ )
87
+ await expect(
88
+ runTestEffect(
89
+ ArchiveService.pipe(
90
+ Effect.flatMap((service) =>
91
+ service.archiveTask("status-check", root),
92
+ ),
93
+ ),
94
+ ),
95
+ ).rejects.toThrow("status=open")
96
+ await runTestEffect(
97
+ TaskService.pipe(
98
+ Effect.flatMap((service) =>
99
+ service.setStatus("status-check", "working", root),
100
+ ),
101
+ ),
102
+ )
103
+ await expect(
104
+ runTestEffect(
105
+ ArchiveService.pipe(
106
+ Effect.flatMap((service) =>
107
+ service.archiveTask("status-check", root),
108
+ ),
109
+ ),
110
+ ),
111
+ ).rejects.toThrow("status=working")
112
+ await runTestEffect(
113
+ TaskService.pipe(
114
+ Effect.flatMap((service) =>
115
+ service.setStatus("status-check", "dropped", root),
116
+ ),
117
+ ),
118
+ )
119
+ const result = await runTestEffect(
120
+ ArchiveService.pipe(
121
+ Effect.flatMap((service) =>
122
+ service.archiveTask("status-check", root, { dryRun: true }),
123
+ ),
124
+ ),
125
+ )
126
+ expect(result.dryRun).toBe(true)
127
+
128
+ await runTestEffect(
129
+ TaskService.pipe(
130
+ Effect.flatMap((service) =>
131
+ service.create(
132
+ {
133
+ id: "done-check",
134
+ ticketUrl: null,
135
+ repo: "agency",
136
+ branch: "task/done-check",
137
+ base: "main",
138
+ },
139
+ root,
140
+ ),
141
+ ),
142
+ ),
143
+ )
144
+ const donePath = join(root, "tasks/done-check/TASK.md")
145
+ await Bun.write(
146
+ donePath,
147
+ (await Bun.file(donePath).text()).replace("status: open", "status: done"),
148
+ )
149
+ const doneResult = await runTestEffect(
150
+ ArchiveService.pipe(
151
+ Effect.flatMap((service) =>
152
+ service.archiveTask("done-check", root, { dryRun: true }),
153
+ ),
154
+ ),
155
+ )
156
+ expect(doneResult.dryRun).toBe(true)
157
+ })
158
+
159
+ test("rejects empty and partially terminal multi-phase tasks in singular archival", async () => {
160
+ await runTestEffect(
161
+ TaskService.pipe(
162
+ Effect.flatMap((service) =>
163
+ service.create(
164
+ { id: "empty", ticketUrl: null, multiPhase: true },
165
+ root,
166
+ ),
167
+ ),
168
+ ),
169
+ )
170
+ await expect(
171
+ runTestEffect(
172
+ ArchiveService.pipe(
173
+ Effect.flatMap((service) => service.archiveTask("empty", root)),
174
+ ),
175
+ ),
176
+ ).rejects.toThrow("has no phases")
177
+
178
+ for (const id of ["terminal", "open"]) {
179
+ await runTestEffect(
180
+ PhaseService.pipe(
181
+ Effect.flatMap((service) =>
182
+ service.create(
183
+ {
184
+ taskId: "empty",
185
+ id,
186
+ repo: "agency",
187
+ branch: `task/empty-${id}`,
188
+ base: "main",
189
+ },
190
+ root,
191
+ ),
192
+ ),
193
+ ),
194
+ )
195
+ }
196
+ await dropPhase("empty", "terminal")
197
+ await expect(
198
+ runTestEffect(
199
+ ArchiveService.pipe(
200
+ Effect.flatMap((service) => service.archiveTask("empty", root)),
201
+ ),
202
+ ),
203
+ ).rejects.toThrow("phase:open:status=open")
204
+ })
205
+
45
206
  test("archives a task after removing its worktree and preserves its branch", async () => {
46
207
  await runTestEffect(
47
208
  EpicService.pipe(
@@ -79,6 +240,7 @@ describe("ArchiveService", () => {
79
240
  ),
80
241
  ),
81
242
  )
243
+ await dropTask("child")
82
244
 
83
245
  const result = await runTestEffect(
84
246
  ArchiveService.pipe(
@@ -114,6 +276,166 @@ describe("ArchiveService", () => {
114
276
  ).toBe(0)
115
277
  })
116
278
 
279
+ test("retries archive after a jj workspace was forgotten but not deleted", async () => {
280
+ if (!Bun.which("jj")) return
281
+ const repository = join(root, "repos/agency")
282
+ await rm(repository, { recursive: true, force: true })
283
+ await git(["clone", join(root, "source"), repository])
284
+ await jj(["git", "init", "--colocate", repository])
285
+ await Bun.write(
286
+ join(root, "agency.json"),
287
+ JSON.stringify({ version: 2, vcs: "jj" }),
288
+ )
289
+ await runTestEffect(
290
+ TaskService.pipe(
291
+ Effect.flatMap((service) =>
292
+ service.create(
293
+ {
294
+ id: "interrupted",
295
+ ticketUrl: null,
296
+ repo: "agency",
297
+ branch: "task/interrupted",
298
+ base: "main",
299
+ },
300
+ root,
301
+ ),
302
+ ),
303
+ ),
304
+ )
305
+ await runTestEffect(
306
+ TaskService.pipe(
307
+ Effect.flatMap((service) =>
308
+ service.setStatus("interrupted", "dropped", root),
309
+ ),
310
+ ),
311
+ )
312
+ const workspace = await runTestEffect(
313
+ WorktreeService.pipe(
314
+ Effect.flatMap((service) =>
315
+ service.materialize("interrupted", undefined, root),
316
+ ),
317
+ ),
318
+ )
319
+ await jj([
320
+ "-R",
321
+ repository,
322
+ "workspace",
323
+ "forget",
324
+ "agency-interrupted-task-agency",
325
+ ])
326
+
327
+ const preview = await runTestEffect(
328
+ ArchiveService.pipe(
329
+ Effect.flatMap((service) =>
330
+ service.archiveTask("interrupted", root, { dryRun: true }),
331
+ ),
332
+ ),
333
+ )
334
+ expect(preview.removedWorktrees).toEqual([workspace.writablePath!])
335
+
336
+ await runTestEffect(
337
+ ArchiveService.pipe(
338
+ Effect.flatMap((service) => service.archiveTask("interrupted", root)),
339
+ ),
340
+ )
341
+ expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
342
+ expect(
343
+ await Bun.file(join(root, "archive/tasks/interrupted/TASK.md")).exists(),
344
+ ).toBe(true)
345
+ })
346
+
347
+ test("refuses a modified residual jj checkout after workspace removal", async () => {
348
+ if (!Bun.which("jj")) return
349
+ const repository = join(root, "repos/agency")
350
+ await rm(repository, { recursive: true, force: true })
351
+ await git(["clone", join(root, "source"), repository])
352
+ await jj(["git", "init", "--colocate", repository])
353
+ await Bun.write(
354
+ join(root, "agency.json"),
355
+ JSON.stringify({ version: 2, vcs: "jj" }),
356
+ )
357
+ await runTestEffect(
358
+ TaskService.pipe(
359
+ Effect.flatMap((service) =>
360
+ service.create(
361
+ {
362
+ id: "modified-residual",
363
+ ticketUrl: null,
364
+ repo: "agency",
365
+ branch: "task/modified-residual",
366
+ base: "main",
367
+ },
368
+ root,
369
+ ),
370
+ ),
371
+ ),
372
+ )
373
+ await runTestEffect(
374
+ TaskService.pipe(
375
+ Effect.flatMap((service) =>
376
+ service.setStatus("modified-residual", "dropped", root),
377
+ ),
378
+ ),
379
+ )
380
+ const workspace = await runTestEffect(
381
+ WorktreeService.pipe(
382
+ Effect.flatMap((service) =>
383
+ service.materialize("modified-residual", undefined, root),
384
+ ),
385
+ ),
386
+ )
387
+ await jj([
388
+ "-R",
389
+ repository,
390
+ "workspace",
391
+ "forget",
392
+ "agency-modified-residual-task-agency",
393
+ ])
394
+ await Bun.write(join(workspace.writablePath!, "keep.txt"), "keep me\n")
395
+
396
+ await expect(
397
+ runTestEffect(
398
+ ArchiveService.pipe(
399
+ Effect.flatMap((service) =>
400
+ service.archiveTask("modified-residual", root),
401
+ ),
402
+ ),
403
+ ),
404
+ ).rejects.toThrow("contents do not match expected revision")
405
+ expect(
406
+ await Bun.file(join(workspace.writablePath!, "keep.txt")).text(),
407
+ ).toBe("keep me\n")
408
+ expect(
409
+ await Bun.file(join(root, "tasks/modified-residual/TASK.md")).exists(),
410
+ ).toBe(true)
411
+ expect(
412
+ await Bun.file(
413
+ join(
414
+ root,
415
+ `.agency-worktree-${Buffer.from("modified-residual:task").toString("hex")}.lock`,
416
+ ),
417
+ ).exists(),
418
+ ).toBe(false)
419
+
420
+ await rm(join(workspace.writablePath!, "keep.txt"))
421
+ await rm(join(workspace.writablePath!, ".jj"), {
422
+ recursive: true,
423
+ force: true,
424
+ })
425
+ await expect(
426
+ runTestEffect(
427
+ ArchiveService.pipe(
428
+ Effect.flatMap((service) =>
429
+ service.archiveTask("modified-residual", root),
430
+ ),
431
+ ),
432
+ ),
433
+ ).rejects.toThrow("is not registered as a jj workspace")
434
+ expect(
435
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
436
+ ).toBe(true)
437
+ })
438
+
117
439
  test("archives a phase in the mirrored task hierarchy", async () => {
118
440
  await runTestEffect(
119
441
  TaskService.pipe(
@@ -316,6 +638,7 @@ describe("ArchiveService", () => {
316
638
  ),
317
639
  )
318
640
  await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep me\n")
641
+ await dropTask("dirty")
319
642
 
320
643
  await expect(
321
644
  runTestEffect(
@@ -349,6 +672,7 @@ describe("ArchiveService", () => {
349
672
  ),
350
673
  ),
351
674
  )
675
+ await dropTask("preview")
352
676
 
353
677
  const archivePreview = await runTestEffect(
354
678
  ArchiveService.pipe(
@@ -416,6 +740,7 @@ describe("ArchiveService", () => {
416
740
  ),
417
741
  ),
418
742
  )
743
+ await dropTask("child")
419
744
  await runTestEffect(
420
745
  ArchiveService.pipe(
421
746
  Effect.flatMap((service) => service.archiveTask("child", root)),
@@ -426,7 +751,11 @@ describe("ArchiveService", () => {
426
751
  ArchiveService.pipe(
427
752
  Effect.flatMap((service) =>
428
753
  service.list(
429
- { kinds: ["task"], repositories: ["agency"], statuses: ["open"] },
754
+ {
755
+ kinds: ["task"],
756
+ repositories: ["agency"],
757
+ statuses: ["dropped"],
758
+ },
430
759
  root,
431
760
  ),
432
761
  ),
@@ -688,6 +1017,7 @@ describe("ArchiveService", () => {
688
1017
  ),
689
1018
  ),
690
1019
  )
1020
+ await dropTask("reserved")
691
1021
  await runTestEffect(
692
1022
  ArchiveService.pipe(
693
1023
  Effect.flatMap((service) => service.archiveTask("reserved", root)),
@@ -737,6 +1067,7 @@ describe("ArchiveService", () => {
737
1067
  ),
738
1068
  ),
739
1069
  )
1070
+ await dropTask("locked")
740
1071
  await Bun.write(join(root, ".agency-archive.lock"), "held\n")
741
1072
 
742
1073
  await expect(
@@ -784,6 +1115,7 @@ describe("ArchiveService", () => {
784
1115
  ),
785
1116
  ),
786
1117
  )
1118
+ await dropTask("child")
787
1119
  await runTestEffect(
788
1120
  ArchiveService.pipe(
789
1121
  Effect.flatMap((service) => service.archiveTask("child", root)),
@@ -895,6 +1227,8 @@ describe("ArchiveService", () => {
895
1227
  join(root, "tasks/multi-dirty/phases/dirty/code/agency/dirty.txt"),
896
1228
  "keep me\n",
897
1229
  )
1230
+ await dropPhase("multi-dirty", "clean")
1231
+ await dropPhase("multi-dirty", "dirty")
898
1232
 
899
1233
  await expect(
900
1234
  runTestEffect(