@markjaquith/agency 2.57.0 → 2.58.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 +21 -6
- package/package.json +1 -1
- package/src/cli-parser.test.ts +8 -0
- package/src/cli-parser.ts +7 -1
- package/src/commands/archive.test.ts +39 -1
- package/src/commands/archive.ts +25 -2
- package/src/commands/restore.test.ts +8 -0
- package/src/services/ArchiveBulkService.test.ts +485 -0
- package/src/services/ArchiveService.test.ts +164 -1
- package/src/services/ArchiveService.ts +447 -29
- package/src/services/WorktreeService.test.ts +4 -0
- package/src/services/WorktreeService.ts +1 -5
|
@@ -0,0 +1,485 @@
|
|
|
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 { ArchiveService } from "./ArchiveService"
|
|
7
|
+
import { EpicService } from "./EpicService"
|
|
8
|
+
import { GraphMutationService } from "./GraphMutationService"
|
|
9
|
+
import { PhaseService } from "./PhaseService"
|
|
10
|
+
import { TaskService } from "./TaskService"
|
|
11
|
+
import { WorktreeService } from "./WorktreeService"
|
|
12
|
+
|
|
13
|
+
const git = (args: readonly string[], cwd?: string) => {
|
|
14
|
+
const result = Bun.spawnSync(["git", ...args], { cwd })
|
|
15
|
+
if (result.exitCode !== 0) {
|
|
16
|
+
throw new Error(new TextDecoder().decode(result.stderr))
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("ArchiveService bulk task archive", () => {
|
|
21
|
+
let root: string
|
|
22
|
+
let source: string
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
root = await createTempDir()
|
|
26
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
27
|
+
source = join(root, "source")
|
|
28
|
+
await mkdir(source, { recursive: true })
|
|
29
|
+
git(["init", "--initial-branch=main"], source)
|
|
30
|
+
git(["config", "user.email", "test@example.com"], source)
|
|
31
|
+
git(["config", "user.name", "Test"], source)
|
|
32
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
33
|
+
git(["add", "README.md"], source)
|
|
34
|
+
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
|
|
35
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
36
|
+
git(["clone", "--bare", source, join(root, "repos/agency")])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(async () => cleanupTempDir(root))
|
|
40
|
+
|
|
41
|
+
const createTask = (id: string, epic?: string) =>
|
|
42
|
+
runTestEffect(
|
|
43
|
+
TaskService.pipe(
|
|
44
|
+
Effect.flatMap((service) =>
|
|
45
|
+
service.create(
|
|
46
|
+
{
|
|
47
|
+
id,
|
|
48
|
+
ticketUrl: null,
|
|
49
|
+
...(epic ? { epic } : {}),
|
|
50
|
+
repo: "agency",
|
|
51
|
+
branch: `task/${id}`,
|
|
52
|
+
base: "main",
|
|
53
|
+
},
|
|
54
|
+
root,
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const dropTask = (id: string) =>
|
|
61
|
+
runTestEffect(
|
|
62
|
+
TaskService.pipe(
|
|
63
|
+
Effect.flatMap((service) => service.setStatus(id, "dropped", root)),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const archiveTasks = (dryRun = false) =>
|
|
68
|
+
runTestEffect(
|
|
69
|
+
ArchiveService.pipe(
|
|
70
|
+
Effect.flatMap((service) => service.archiveTasks(root, { dryRun })),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
test("returns sorted dispositions and treats an empty multi-phase task as non-terminal", async () => {
|
|
75
|
+
await createTask("z-dropped")
|
|
76
|
+
await dropTask("z-dropped")
|
|
77
|
+
await createTask("a-open")
|
|
78
|
+
await runTestEffect(
|
|
79
|
+
TaskService.pipe(
|
|
80
|
+
Effect.flatMap((service) =>
|
|
81
|
+
service.create(
|
|
82
|
+
{ id: "m-empty", ticketUrl: null, multiPhase: true },
|
|
83
|
+
root,
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const result = await archiveTasks(true)
|
|
90
|
+
|
|
91
|
+
expect(result.tasks.map((task) => task.id)).toEqual([
|
|
92
|
+
"a-open",
|
|
93
|
+
"m-empty",
|
|
94
|
+
"z-dropped",
|
|
95
|
+
])
|
|
96
|
+
expect(result.tasks).toMatchObject([
|
|
97
|
+
{
|
|
98
|
+
id: "a-open",
|
|
99
|
+
disposition: "skipped",
|
|
100
|
+
reason: { code: "non-terminal", details: ["status=open"] },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "m-empty",
|
|
104
|
+
disposition: "skipped",
|
|
105
|
+
reason: {
|
|
106
|
+
code: "non-terminal",
|
|
107
|
+
details: ["multi-phase task has no phases"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{ id: "z-dropped", disposition: "planned" },
|
|
111
|
+
])
|
|
112
|
+
expect(await Bun.file(join(root, "tasks/z-dropped/TASK.md")).exists()).toBe(
|
|
113
|
+
true,
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("uses aggregate phase status and archives only when every phase is terminal", async () => {
|
|
118
|
+
await runTestEffect(
|
|
119
|
+
TaskService.pipe(
|
|
120
|
+
Effect.flatMap((service) =>
|
|
121
|
+
service.create(
|
|
122
|
+
{ id: "multi", ticketUrl: null, multiPhase: true },
|
|
123
|
+
root,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
for (const id of ["done", "open"]) {
|
|
129
|
+
await runTestEffect(
|
|
130
|
+
PhaseService.pipe(
|
|
131
|
+
Effect.flatMap((service) =>
|
|
132
|
+
service.create(
|
|
133
|
+
{
|
|
134
|
+
taskId: "multi",
|
|
135
|
+
id,
|
|
136
|
+
repo: "agency",
|
|
137
|
+
branch: `task/multi-${id}`,
|
|
138
|
+
base: "main",
|
|
139
|
+
},
|
|
140
|
+
root,
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
await runTestEffect(
|
|
147
|
+
PhaseService.pipe(
|
|
148
|
+
Effect.flatMap((service) =>
|
|
149
|
+
service.setStatus("multi", "done", "dropped", root),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const mixed = await archiveTasks(true)
|
|
155
|
+
expect(mixed.tasks[0]).toMatchObject({
|
|
156
|
+
disposition: "skipped",
|
|
157
|
+
reason: { code: "non-terminal" },
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await runTestEffect(
|
|
161
|
+
PhaseService.pipe(
|
|
162
|
+
Effect.flatMap((service) =>
|
|
163
|
+
service.setStatus("multi", "open", "dropped", root),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
const terminal = await archiveTasks()
|
|
168
|
+
expect(terminal.tasks[0]).toMatchObject({
|
|
169
|
+
id: "multi",
|
|
170
|
+
disposition: "archived",
|
|
171
|
+
})
|
|
172
|
+
expect(
|
|
173
|
+
await Bun.file(join(root, "archive/tasks/multi/TASK.md")).exists(),
|
|
174
|
+
).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("propagates retained-dependent skips to a fixed point", async () => {
|
|
178
|
+
await runTestEffect(
|
|
179
|
+
EpicService.pipe(
|
|
180
|
+
Effect.flatMap((service) =>
|
|
181
|
+
service.create(
|
|
182
|
+
"parent",
|
|
183
|
+
"https://example.com/epic",
|
|
184
|
+
[{ repo: "agency", ref: "main" }],
|
|
185
|
+
root,
|
|
186
|
+
),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
for (const id of ["base", "middle", "retained"])
|
|
191
|
+
await createTask(id, "parent")
|
|
192
|
+
await runTestEffect(
|
|
193
|
+
GraphMutationService.pipe(
|
|
194
|
+
Effect.flatMap((service) =>
|
|
195
|
+
service.mutateTaskDependency("add", "middle", "base", root),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
await runTestEffect(
|
|
200
|
+
GraphMutationService.pipe(
|
|
201
|
+
Effect.flatMap((service) =>
|
|
202
|
+
service.mutateTaskDependency("add", "retained", "middle", root),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
await dropTask("base")
|
|
207
|
+
await dropTask("middle")
|
|
208
|
+
|
|
209
|
+
const result = await archiveTasks()
|
|
210
|
+
|
|
211
|
+
expect(result.tasks).toMatchObject([
|
|
212
|
+
{
|
|
213
|
+
id: "base",
|
|
214
|
+
disposition: "skipped",
|
|
215
|
+
reason: { code: "retained-dependent", details: ["middle"] },
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "middle",
|
|
219
|
+
disposition: "skipped",
|
|
220
|
+
reason: { code: "retained-dependent", details: ["retained"] },
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: "retained",
|
|
224
|
+
disposition: "skipped",
|
|
225
|
+
reason: { code: "non-terminal" },
|
|
226
|
+
},
|
|
227
|
+
])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("archives an internal dependency cohort and updates its parent once without archiving it", async () => {
|
|
231
|
+
await runTestEffect(
|
|
232
|
+
EpicService.pipe(
|
|
233
|
+
Effect.flatMap((service) =>
|
|
234
|
+
service.create(
|
|
235
|
+
"parent",
|
|
236
|
+
"https://example.com/epic",
|
|
237
|
+
[{ repo: "agency", ref: "main" }],
|
|
238
|
+
root,
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
await createTask("base", "parent")
|
|
244
|
+
await createTask("dependent", "parent")
|
|
245
|
+
await runTestEffect(
|
|
246
|
+
GraphMutationService.pipe(
|
|
247
|
+
Effect.flatMap((service) =>
|
|
248
|
+
service.mutateTaskDependency("add", "dependent", "base", root),
|
|
249
|
+
),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
await dropTask("base")
|
|
253
|
+
await dropTask("dependent")
|
|
254
|
+
|
|
255
|
+
const result = await archiveTasks()
|
|
256
|
+
|
|
257
|
+
expect(result.tasks.map((task) => task.disposition)).toEqual([
|
|
258
|
+
"archived",
|
|
259
|
+
"archived",
|
|
260
|
+
])
|
|
261
|
+
const parent = await runTestEffect(
|
|
262
|
+
EpicService.pipe(
|
|
263
|
+
Effect.flatMap((service) => service.show("parent", root)),
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
expect(parent.data.tasks).toEqual([])
|
|
267
|
+
expect(await Bun.file(join(root, "epics/parent/EPIC.md")).exists()).toBe(
|
|
268
|
+
true,
|
|
269
|
+
)
|
|
270
|
+
expect(await Bun.file(join(root, "archive/epics/parent")).exists()).toBe(
|
|
271
|
+
false,
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test("skips destination collisions and dirty managed worktrees", async () => {
|
|
276
|
+
await createTask("collision")
|
|
277
|
+
await dropTask("collision")
|
|
278
|
+
await mkdir(join(root, "archive/tasks/collision"), { recursive: true })
|
|
279
|
+
await createTask("dirty")
|
|
280
|
+
await dropTask("dirty")
|
|
281
|
+
const workspace = await runTestEffect(
|
|
282
|
+
WorktreeService.pipe(
|
|
283
|
+
Effect.flatMap((service) =>
|
|
284
|
+
service.materialize("dirty", undefined, root),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep\n")
|
|
289
|
+
|
|
290
|
+
const result = await archiveTasks()
|
|
291
|
+
|
|
292
|
+
expect(result.tasks).toMatchObject([
|
|
293
|
+
{
|
|
294
|
+
id: "collision",
|
|
295
|
+
disposition: "skipped",
|
|
296
|
+
reason: { code: "destination-exists" },
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "dirty",
|
|
300
|
+
disposition: "skipped",
|
|
301
|
+
reason: { code: "dirty-worktree" },
|
|
302
|
+
},
|
|
303
|
+
])
|
|
304
|
+
expect(
|
|
305
|
+
await Bun.file(join(workspace.writablePath!, "dirty.txt")).exists(),
|
|
306
|
+
).toBe(true)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test("skips task- and phase-level active claims", async () => {
|
|
310
|
+
await createTask("claimed")
|
|
311
|
+
await dropTask("claimed")
|
|
312
|
+
const taskPath = join(root, "tasks/claimed/TASK.md")
|
|
313
|
+
await Bun.write(
|
|
314
|
+
taskPath,
|
|
315
|
+
(await Bun.file(taskPath).text()).replace(
|
|
316
|
+
"status: dropped\n",
|
|
317
|
+
`status: dropped
|
|
318
|
+
claim:
|
|
319
|
+
claimant: orchestrator
|
|
320
|
+
runner: opencode
|
|
321
|
+
sessionId: task-session
|
|
322
|
+
startedAt: 2026-08-07T00:00:00.000Z
|
|
323
|
+
targetRevision: ${"a".repeat(64)}
|
|
324
|
+
state: active
|
|
325
|
+
`,
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
await runTestEffect(
|
|
329
|
+
TaskService.pipe(
|
|
330
|
+
Effect.flatMap((service) =>
|
|
331
|
+
service.create(
|
|
332
|
+
{ id: "multi-claimed", ticketUrl: null, multiPhase: true },
|
|
333
|
+
root,
|
|
334
|
+
),
|
|
335
|
+
),
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
await runTestEffect(
|
|
339
|
+
PhaseService.pipe(
|
|
340
|
+
Effect.flatMap((service) =>
|
|
341
|
+
service.create(
|
|
342
|
+
{
|
|
343
|
+
taskId: "multi-claimed",
|
|
344
|
+
id: "phase",
|
|
345
|
+
repo: "agency",
|
|
346
|
+
branch: "task/multi-claimed",
|
|
347
|
+
base: "main",
|
|
348
|
+
},
|
|
349
|
+
root,
|
|
350
|
+
),
|
|
351
|
+
),
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
await runTestEffect(
|
|
355
|
+
PhaseService.pipe(
|
|
356
|
+
Effect.flatMap((service) =>
|
|
357
|
+
service.setStatus("multi-claimed", "phase", "dropped", root),
|
|
358
|
+
),
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
const phasePath = join(root, "tasks/multi-claimed/phases/phase/PHASE.md")
|
|
362
|
+
await Bun.write(
|
|
363
|
+
phasePath,
|
|
364
|
+
(await Bun.file(phasePath).text()).replace(
|
|
365
|
+
"status: dropped\n",
|
|
366
|
+
`status: dropped
|
|
367
|
+
claim:
|
|
368
|
+
claimant: orchestrator
|
|
369
|
+
runner: opencode
|
|
370
|
+
sessionId: phase-session
|
|
371
|
+
startedAt: 2026-08-07T00:00:00.000Z
|
|
372
|
+
targetRevision: ${"b".repeat(64)}
|
|
373
|
+
state: active
|
|
374
|
+
`,
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
const result = await archiveTasks(true)
|
|
379
|
+
|
|
380
|
+
expect(result.tasks).toMatchObject([
|
|
381
|
+
{
|
|
382
|
+
id: "claimed",
|
|
383
|
+
disposition: "skipped",
|
|
384
|
+
reason: { code: "active-claim", details: ["task:claimed"] },
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: "multi-claimed",
|
|
388
|
+
disposition: "skipped",
|
|
389
|
+
reason: {
|
|
390
|
+
code: "active-claim",
|
|
391
|
+
details: ["phase:multi-claimed/phase"],
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
])
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test("skips a dirty jj workspace", async () => {
|
|
398
|
+
if (!Bun.which("jj")) return
|
|
399
|
+
const repository = join(root, "repos/agency")
|
|
400
|
+
await rm(repository, { recursive: true, force: true })
|
|
401
|
+
git(["clone", source, repository])
|
|
402
|
+
const initialized = Bun.spawnSync([
|
|
403
|
+
"jj",
|
|
404
|
+
"git",
|
|
405
|
+
"init",
|
|
406
|
+
"--colocate",
|
|
407
|
+
repository,
|
|
408
|
+
])
|
|
409
|
+
if (initialized.exitCode !== 0) {
|
|
410
|
+
throw new Error(new TextDecoder().decode(initialized.stderr))
|
|
411
|
+
}
|
|
412
|
+
await Bun.write(
|
|
413
|
+
join(root, "agency.json"),
|
|
414
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
415
|
+
)
|
|
416
|
+
await createTask("jj-dirty")
|
|
417
|
+
await dropTask("jj-dirty")
|
|
418
|
+
const workspace = await runTestEffect(
|
|
419
|
+
WorktreeService.pipe(
|
|
420
|
+
Effect.flatMap((service) =>
|
|
421
|
+
service.materialize("jj-dirty", undefined, root),
|
|
422
|
+
),
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep\n")
|
|
426
|
+
|
|
427
|
+
const result = await archiveTasks()
|
|
428
|
+
|
|
429
|
+
expect(result.tasks[0]).toMatchObject({
|
|
430
|
+
id: "jj-dirty",
|
|
431
|
+
disposition: "skipped",
|
|
432
|
+
reason: { code: "dirty-worktree" },
|
|
433
|
+
})
|
|
434
|
+
expect(
|
|
435
|
+
await Bun.file(join(workspace.writablePath!, "dirty.txt")).exists(),
|
|
436
|
+
).toBe(true)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test("rolls back the entire cohort when application fails", async () => {
|
|
440
|
+
await runTestEffect(
|
|
441
|
+
EpicService.pipe(
|
|
442
|
+
Effect.flatMap((service) =>
|
|
443
|
+
service.create(
|
|
444
|
+
"rollback-parent",
|
|
445
|
+
"https://example.com/epic",
|
|
446
|
+
[{ repo: "agency", ref: "main" }],
|
|
447
|
+
root,
|
|
448
|
+
),
|
|
449
|
+
),
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
for (const id of ["first", "second"]) {
|
|
453
|
+
await createTask(id, "rollback-parent")
|
|
454
|
+
await dropTask(id)
|
|
455
|
+
}
|
|
456
|
+
const archiveDirectory = join(root, "archive")
|
|
457
|
+
await mkdir(archiveDirectory, { mode: 0o500 })
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
await expect(archiveTasks()).rejects.toThrow("rolled back")
|
|
461
|
+
} finally {
|
|
462
|
+
await chmod(archiveDirectory, 0o700)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const id of ["first", "second"]) {
|
|
466
|
+
expect(await Bun.file(join(root, `tasks/${id}/TASK.md`)).exists()).toBe(
|
|
467
|
+
true,
|
|
468
|
+
)
|
|
469
|
+
expect(
|
|
470
|
+
await Bun.file(
|
|
471
|
+
join(root, `tasks/${id}/.agency-lifecycle.json`),
|
|
472
|
+
).exists(),
|
|
473
|
+
).toBe(false)
|
|
474
|
+
}
|
|
475
|
+
const parent = await runTestEffect(
|
|
476
|
+
EpicService.pipe(
|
|
477
|
+
Effect.flatMap((service) => service.show("rollback-parent", root)),
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
expect(parent.data.tasks.map((task) => task.id)).toEqual([
|
|
481
|
+
"first",
|
|
482
|
+
"second",
|
|
483
|
+
])
|
|
484
|
+
})
|
|
485
|
+
})
|
|
@@ -24,6 +24,20 @@ const git = async (args: string[], cwd?: string) => {
|
|
|
24
24
|
|
|
25
25
|
describe("ArchiveService", () => {
|
|
26
26
|
let root: string
|
|
27
|
+
const dropTask = (id: string) =>
|
|
28
|
+
runTestEffect(
|
|
29
|
+
TaskService.pipe(
|
|
30
|
+
Effect.flatMap((service) => service.setStatus(id, "dropped", root)),
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
const dropPhase = (taskId: string, id: string) =>
|
|
34
|
+
runTestEffect(
|
|
35
|
+
PhaseService.pipe(
|
|
36
|
+
Effect.flatMap((service) =>
|
|
37
|
+
service.setStatus(taskId, id, "dropped", root),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
27
41
|
|
|
28
42
|
beforeEach(async () => {
|
|
29
43
|
root = await createTempDir()
|
|
@@ -42,6 +56,142 @@ describe("ArchiveService", () => {
|
|
|
42
56
|
|
|
43
57
|
afterEach(async () => cleanupTempDir(root))
|
|
44
58
|
|
|
59
|
+
test("requires terminal effective status for singular task archival", async () => {
|
|
60
|
+
await runTestEffect(
|
|
61
|
+
TaskService.pipe(
|
|
62
|
+
Effect.flatMap((service) =>
|
|
63
|
+
service.create(
|
|
64
|
+
{
|
|
65
|
+
id: "status-check",
|
|
66
|
+
ticketUrl: null,
|
|
67
|
+
repo: "agency",
|
|
68
|
+
branch: "task/status-check",
|
|
69
|
+
base: "main",
|
|
70
|
+
},
|
|
71
|
+
root,
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
await expect(
|
|
77
|
+
runTestEffect(
|
|
78
|
+
ArchiveService.pipe(
|
|
79
|
+
Effect.flatMap((service) =>
|
|
80
|
+
service.archiveTask("status-check", root),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
).rejects.toThrow("status=open")
|
|
85
|
+
await runTestEffect(
|
|
86
|
+
TaskService.pipe(
|
|
87
|
+
Effect.flatMap((service) =>
|
|
88
|
+
service.setStatus("status-check", "working", root),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
await expect(
|
|
93
|
+
runTestEffect(
|
|
94
|
+
ArchiveService.pipe(
|
|
95
|
+
Effect.flatMap((service) =>
|
|
96
|
+
service.archiveTask("status-check", root),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
).rejects.toThrow("status=working")
|
|
101
|
+
await runTestEffect(
|
|
102
|
+
TaskService.pipe(
|
|
103
|
+
Effect.flatMap((service) =>
|
|
104
|
+
service.setStatus("status-check", "dropped", root),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
const result = await runTestEffect(
|
|
109
|
+
ArchiveService.pipe(
|
|
110
|
+
Effect.flatMap((service) =>
|
|
111
|
+
service.archiveTask("status-check", root, { dryRun: true }),
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
expect(result.dryRun).toBe(true)
|
|
116
|
+
|
|
117
|
+
await runTestEffect(
|
|
118
|
+
TaskService.pipe(
|
|
119
|
+
Effect.flatMap((service) =>
|
|
120
|
+
service.create(
|
|
121
|
+
{
|
|
122
|
+
id: "done-check",
|
|
123
|
+
ticketUrl: null,
|
|
124
|
+
repo: "agency",
|
|
125
|
+
branch: "task/done-check",
|
|
126
|
+
base: "main",
|
|
127
|
+
},
|
|
128
|
+
root,
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
const donePath = join(root, "tasks/done-check/TASK.md")
|
|
134
|
+
await Bun.write(
|
|
135
|
+
donePath,
|
|
136
|
+
(await Bun.file(donePath).text()).replace("status: open", "status: done"),
|
|
137
|
+
)
|
|
138
|
+
const doneResult = await runTestEffect(
|
|
139
|
+
ArchiveService.pipe(
|
|
140
|
+
Effect.flatMap((service) =>
|
|
141
|
+
service.archiveTask("done-check", root, { dryRun: true }),
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
expect(doneResult.dryRun).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("rejects empty and partially terminal multi-phase tasks in singular archival", async () => {
|
|
149
|
+
await runTestEffect(
|
|
150
|
+
TaskService.pipe(
|
|
151
|
+
Effect.flatMap((service) =>
|
|
152
|
+
service.create(
|
|
153
|
+
{ id: "empty", ticketUrl: null, multiPhase: true },
|
|
154
|
+
root,
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
await expect(
|
|
160
|
+
runTestEffect(
|
|
161
|
+
ArchiveService.pipe(
|
|
162
|
+
Effect.flatMap((service) => service.archiveTask("empty", root)),
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
).rejects.toThrow("has no phases")
|
|
166
|
+
|
|
167
|
+
for (const id of ["terminal", "open"]) {
|
|
168
|
+
await runTestEffect(
|
|
169
|
+
PhaseService.pipe(
|
|
170
|
+
Effect.flatMap((service) =>
|
|
171
|
+
service.create(
|
|
172
|
+
{
|
|
173
|
+
taskId: "empty",
|
|
174
|
+
id,
|
|
175
|
+
repo: "agency",
|
|
176
|
+
branch: `task/empty-${id}`,
|
|
177
|
+
base: "main",
|
|
178
|
+
},
|
|
179
|
+
root,
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
await dropPhase("empty", "terminal")
|
|
186
|
+
await expect(
|
|
187
|
+
runTestEffect(
|
|
188
|
+
ArchiveService.pipe(
|
|
189
|
+
Effect.flatMap((service) => service.archiveTask("empty", root)),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
).rejects.toThrow("phase:open:status=open")
|
|
193
|
+
})
|
|
194
|
+
|
|
45
195
|
test("archives a task after removing its worktree and preserves its branch", async () => {
|
|
46
196
|
await runTestEffect(
|
|
47
197
|
EpicService.pipe(
|
|
@@ -79,6 +229,7 @@ describe("ArchiveService", () => {
|
|
|
79
229
|
),
|
|
80
230
|
),
|
|
81
231
|
)
|
|
232
|
+
await dropTask("child")
|
|
82
233
|
|
|
83
234
|
const result = await runTestEffect(
|
|
84
235
|
ArchiveService.pipe(
|
|
@@ -316,6 +467,7 @@ describe("ArchiveService", () => {
|
|
|
316
467
|
),
|
|
317
468
|
)
|
|
318
469
|
await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep me\n")
|
|
470
|
+
await dropTask("dirty")
|
|
319
471
|
|
|
320
472
|
await expect(
|
|
321
473
|
runTestEffect(
|
|
@@ -349,6 +501,7 @@ describe("ArchiveService", () => {
|
|
|
349
501
|
),
|
|
350
502
|
),
|
|
351
503
|
)
|
|
504
|
+
await dropTask("preview")
|
|
352
505
|
|
|
353
506
|
const archivePreview = await runTestEffect(
|
|
354
507
|
ArchiveService.pipe(
|
|
@@ -416,6 +569,7 @@ describe("ArchiveService", () => {
|
|
|
416
569
|
),
|
|
417
570
|
),
|
|
418
571
|
)
|
|
572
|
+
await dropTask("child")
|
|
419
573
|
await runTestEffect(
|
|
420
574
|
ArchiveService.pipe(
|
|
421
575
|
Effect.flatMap((service) => service.archiveTask("child", root)),
|
|
@@ -426,7 +580,11 @@ describe("ArchiveService", () => {
|
|
|
426
580
|
ArchiveService.pipe(
|
|
427
581
|
Effect.flatMap((service) =>
|
|
428
582
|
service.list(
|
|
429
|
-
{
|
|
583
|
+
{
|
|
584
|
+
kinds: ["task"],
|
|
585
|
+
repositories: ["agency"],
|
|
586
|
+
statuses: ["dropped"],
|
|
587
|
+
},
|
|
430
588
|
root,
|
|
431
589
|
),
|
|
432
590
|
),
|
|
@@ -688,6 +846,7 @@ describe("ArchiveService", () => {
|
|
|
688
846
|
),
|
|
689
847
|
),
|
|
690
848
|
)
|
|
849
|
+
await dropTask("reserved")
|
|
691
850
|
await runTestEffect(
|
|
692
851
|
ArchiveService.pipe(
|
|
693
852
|
Effect.flatMap((service) => service.archiveTask("reserved", root)),
|
|
@@ -737,6 +896,7 @@ describe("ArchiveService", () => {
|
|
|
737
896
|
),
|
|
738
897
|
),
|
|
739
898
|
)
|
|
899
|
+
await dropTask("locked")
|
|
740
900
|
await Bun.write(join(root, ".agency-archive.lock"), "held\n")
|
|
741
901
|
|
|
742
902
|
await expect(
|
|
@@ -784,6 +944,7 @@ describe("ArchiveService", () => {
|
|
|
784
944
|
),
|
|
785
945
|
),
|
|
786
946
|
)
|
|
947
|
+
await dropTask("child")
|
|
787
948
|
await runTestEffect(
|
|
788
949
|
ArchiveService.pipe(
|
|
789
950
|
Effect.flatMap((service) => service.archiveTask("child", root)),
|
|
@@ -895,6 +1056,8 @@ describe("ArchiveService", () => {
|
|
|
895
1056
|
join(root, "tasks/multi-dirty/phases/dirty/code/agency/dirty.txt"),
|
|
896
1057
|
"keep me\n",
|
|
897
1058
|
)
|
|
1059
|
+
await dropPhase("multi-dirty", "clean")
|
|
1060
|
+
await dropPhase("multi-dirty", "dirty")
|
|
898
1061
|
|
|
899
1062
|
await expect(
|
|
900
1063
|
runTestEffect(
|