@markjaquith/agency 2.24.0 → 2.25.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/cli.ts +10 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +9 -0
- package/src/commands/archive.test.ts +22 -0
- package/src/commands/archive.ts +3 -1
- package/src/services/ArchiveService.test.ts +59 -0
- package/src/services/ArchiveService.ts +282 -113
- package/src/services/LifecycleTransaction.test.ts +107 -0
- package/src/services/LifecycleTransaction.ts +302 -0
- package/src/services/PhaseService.ts +156 -48
- package/src/services/TaskPhaseService.test.ts +47 -1
- package/src/services/TaskService.ts +28 -4
- package/src/services/WorktreeLock.ts +60 -0
- package/src/services/WorktreeService.test.ts +174 -1
- package/src/services/WorktreeService.ts +972 -523
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
-
import { Data, Effect, Either } from "effect"
|
|
3
|
-
import { mkdir, open, rename, rm } from "node:fs/promises"
|
|
2
|
+
import { Data, Effect, Either, Layer } from "effect"
|
|
3
|
+
import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
|
|
4
4
|
import { dirname, join, relative } from "node:path"
|
|
5
5
|
import { EpicService, type EpicRecord } from "./EpicService"
|
|
6
6
|
import { FileSystemService } from "./FileSystemService"
|
|
7
|
-
import { PhaseService } from "./PhaseService"
|
|
7
|
+
import { PhaseService, type PhaseRecord } from "./PhaseService"
|
|
8
8
|
import { TaskService } from "./TaskService"
|
|
9
9
|
import { WorkbaseService } from "./WorkbaseService"
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
WorktreeService,
|
|
12
|
+
type WorktreeRemovalSnapshot,
|
|
13
|
+
} from "./WorktreeService"
|
|
11
14
|
import {
|
|
12
15
|
formatMarkdownDocument,
|
|
13
16
|
parseFrontmatter,
|
|
@@ -29,6 +32,13 @@ import {
|
|
|
29
32
|
archivedTaskDirectory,
|
|
30
33
|
lifecycleManifestPath,
|
|
31
34
|
} from "../workbase/archive"
|
|
35
|
+
import {
|
|
36
|
+
directoryMoveStep,
|
|
37
|
+
documentWriteStep,
|
|
38
|
+
runLifecycleTransaction,
|
|
39
|
+
type TransactionStep,
|
|
40
|
+
} from "./LifecycleTransaction"
|
|
41
|
+
import { withWorktreeLocks } from "./WorktreeLock"
|
|
32
42
|
|
|
33
43
|
class ArchiveError extends Data.TaggedError("ArchiveError")<{
|
|
34
44
|
readonly message: string
|
|
@@ -99,6 +109,7 @@ interface TaskRecord {
|
|
|
99
109
|
readonly id: string
|
|
100
110
|
readonly path: string
|
|
101
111
|
readonly content: string
|
|
112
|
+
readonly revision: string
|
|
102
113
|
readonly data: TaskData
|
|
103
114
|
}
|
|
104
115
|
|
|
@@ -112,11 +123,6 @@ interface Write {
|
|
|
112
123
|
readonly content: string
|
|
113
124
|
}
|
|
114
125
|
|
|
115
|
-
interface WorktreeTarget {
|
|
116
|
-
readonly taskId: string
|
|
117
|
-
readonly phaseId?: string
|
|
118
|
-
}
|
|
119
|
-
|
|
120
126
|
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
121
127
|
schema: S,
|
|
122
128
|
input: unknown,
|
|
@@ -259,65 +265,63 @@ const applyMutation = (moves: readonly Move[], writes: readonly Write[]) =>
|
|
|
259
265
|
}),
|
|
260
266
|
})
|
|
261
267
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}),
|
|
317
|
-
),
|
|
318
|
-
),
|
|
268
|
+
const WorktreeLayer = Layer.mergeAll(
|
|
269
|
+
FileSystemService.Default,
|
|
270
|
+
WorkbaseService.Default,
|
|
271
|
+
TaskService.Default,
|
|
272
|
+
PhaseService.Default,
|
|
273
|
+
WorktreeService.Default,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
|
|
277
|
+
Effect.runPromise(
|
|
278
|
+
effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
const runGit = async (args: readonly string[]) => {
|
|
282
|
+
const process = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
283
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
284
|
+
process.exited,
|
|
285
|
+
new Response(process.stdout).text(),
|
|
286
|
+
new Response(process.stderr).text(),
|
|
287
|
+
])
|
|
288
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
|
|
289
|
+
return stdout.trim()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const restoreWorktreeSnapshots = async (
|
|
293
|
+
snapshots: readonly WorktreeRemovalSnapshot[],
|
|
294
|
+
) => {
|
|
295
|
+
for (const snapshot of snapshots) {
|
|
296
|
+
try {
|
|
297
|
+
await lstat(snapshot.path)
|
|
298
|
+
continue
|
|
299
|
+
} catch {}
|
|
300
|
+
await mkdir(dirname(snapshot.path), { recursive: true })
|
|
301
|
+
await runGit(
|
|
302
|
+
snapshot.branch
|
|
303
|
+
? [
|
|
304
|
+
"git",
|
|
305
|
+
"-C",
|
|
306
|
+
snapshot.repositoryPath,
|
|
307
|
+
"worktree",
|
|
308
|
+
"add",
|
|
309
|
+
snapshot.path,
|
|
310
|
+
snapshot.branch,
|
|
311
|
+
]
|
|
312
|
+
: [
|
|
313
|
+
"git",
|
|
314
|
+
"-C",
|
|
315
|
+
snapshot.repositoryPath,
|
|
316
|
+
"worktree",
|
|
317
|
+
"add",
|
|
318
|
+
"--detach",
|
|
319
|
+
snapshot.path,
|
|
320
|
+
snapshot.head,
|
|
321
|
+
],
|
|
319
322
|
)
|
|
320
|
-
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
321
325
|
|
|
322
326
|
const rejectExistingDestination = (
|
|
323
327
|
path: string,
|
|
@@ -533,6 +537,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
533
537
|
options: LifecycleOptions = {},
|
|
534
538
|
) =>
|
|
535
539
|
Effect.gen(function* () {
|
|
540
|
+
const fs = yield* FileSystemService
|
|
536
541
|
const workbase = yield* WorkbaseService
|
|
537
542
|
const epics = yield* EpicService
|
|
538
543
|
const tasks = yield* TaskService
|
|
@@ -549,35 +554,60 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
549
554
|
}
|
|
550
555
|
taskRecords.push(task)
|
|
551
556
|
}
|
|
557
|
+
const declared = new Set(epic.data.tasks.map((task) => task.id))
|
|
558
|
+
const unlisted = (yield* tasks.list(root)).find(
|
|
559
|
+
(task) => task.data.epic === id && !declared.has(task.id),
|
|
560
|
+
)
|
|
561
|
+
if (unlisted) {
|
|
562
|
+
return yield* new ArchiveError({
|
|
563
|
+
message: `Task '${unlisted.id}' references epic '${id}' but is not listed by it`,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
552
567
|
const destination = archivedEpicDirectory(root, id)
|
|
553
568
|
yield* rejectExistingDestination(destination, "Archive")
|
|
554
|
-
for (const task of taskRecords)
|
|
569
|
+
for (const task of taskRecords) {
|
|
555
570
|
yield* rejectExistingDestination(
|
|
556
571
|
archivedTaskDirectory(root, task.id),
|
|
557
572
|
"Archive",
|
|
558
573
|
)
|
|
574
|
+
}
|
|
559
575
|
|
|
560
|
-
|
|
561
|
-
const
|
|
576
|
+
const executionUnits: { taskId: string; phaseId?: string }[] = []
|
|
577
|
+
const phaseRecords: PhaseRecord[] = []
|
|
562
578
|
for (const task of taskRecords) {
|
|
579
|
+
if ("claim" in task.data && task.data.claim?.state === "active") {
|
|
580
|
+
return yield* new ArchiveError({
|
|
581
|
+
message: `Task '${task.id}' has an active claim; release or finish it before archiving`,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
563
584
|
if ("phases" in task.data) {
|
|
564
585
|
for (const phase of task.data.phases) {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
})),
|
|
586
|
+
const record = yield* (yield* PhaseService).show(
|
|
587
|
+
task.id,
|
|
588
|
+
phase.id,
|
|
589
|
+
root,
|
|
570
590
|
)
|
|
591
|
+
if (record.data.claim?.state === "active") {
|
|
592
|
+
return yield* new ArchiveError({
|
|
593
|
+
message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
phaseRecords.push(record)
|
|
597
|
+
executionUnits.push({ taskId: task.id, phaseId: phase.id })
|
|
571
598
|
}
|
|
572
599
|
} else {
|
|
573
|
-
|
|
574
|
-
removedWorktrees.push(
|
|
575
|
-
...(yield* worktrees.remove(task.id, undefined, root, {
|
|
576
|
-
dryRun: true,
|
|
577
|
-
})),
|
|
578
|
-
)
|
|
600
|
+
executionUnits.push({ taskId: task.id })
|
|
579
601
|
}
|
|
580
602
|
}
|
|
603
|
+
const removedWorktrees: string[] = []
|
|
604
|
+
for (const unit of executionUnits) {
|
|
605
|
+
removedWorktrees.push(
|
|
606
|
+
...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
607
|
+
dryRun: true,
|
|
608
|
+
})),
|
|
609
|
+
)
|
|
610
|
+
}
|
|
581
611
|
|
|
582
612
|
const at = new Date().toISOString()
|
|
583
613
|
const moves: Move[] = taskRecords.map((task) => ({
|
|
@@ -585,14 +615,16 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
585
615
|
to: archivedTaskDirectory(root, task.id),
|
|
586
616
|
}))
|
|
587
617
|
moves.push({ from: dirname(epic.path), to: destination })
|
|
588
|
-
const writes: Write[] = []
|
|
618
|
+
const writes: (Write & { create?: boolean })[] = []
|
|
589
619
|
for (const task of taskRecords) {
|
|
590
620
|
const target = archivedTaskDirectory(root, task.id)
|
|
621
|
+
const manifestPath = lifecycleManifestPath(dirname(task.path))
|
|
591
622
|
const declaration = epic.data.tasks.find(
|
|
592
623
|
(child) => child.id === task.id,
|
|
593
624
|
)!
|
|
594
625
|
writes.push({
|
|
595
|
-
path:
|
|
626
|
+
path: manifestPath,
|
|
627
|
+
create: !(yield* fs.exists(manifestPath)),
|
|
596
628
|
content: json(
|
|
597
629
|
manifestFor(
|
|
598
630
|
yield* readManifest(dirname(task.path)),
|
|
@@ -606,8 +638,10 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
606
638
|
),
|
|
607
639
|
})
|
|
608
640
|
}
|
|
641
|
+
const epicManifestPath = lifecycleManifestPath(dirname(epic.path))
|
|
609
642
|
writes.push({
|
|
610
|
-
path:
|
|
643
|
+
path: epicManifestPath,
|
|
644
|
+
create: !(yield* fs.exists(epicManifestPath)),
|
|
611
645
|
content: json(
|
|
612
646
|
manifestFor(
|
|
613
647
|
yield* readManifest(dirname(epic.path)),
|
|
@@ -617,11 +651,52 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
617
651
|
),
|
|
618
652
|
})
|
|
619
653
|
if (!options.dryRun) {
|
|
620
|
-
|
|
654
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
655
|
+
const steps: TransactionStep[] = [
|
|
656
|
+
documentWriteStep(root, writes),
|
|
657
|
+
{
|
|
658
|
+
label: `remove worktrees for epic ${id}`,
|
|
659
|
+
apply: async () => {
|
|
660
|
+
try {
|
|
661
|
+
for (const unit of executionUnits)
|
|
662
|
+
await runWorktreeEffect(
|
|
663
|
+
worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
664
|
+
snapshots,
|
|
665
|
+
lockHeld: true,
|
|
666
|
+
}),
|
|
667
|
+
)
|
|
668
|
+
} catch (cause) {
|
|
669
|
+
await restoreWorktreeSnapshots(snapshots)
|
|
670
|
+
throw cause
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
rollback: () => restoreWorktreeSnapshots(snapshots),
|
|
674
|
+
manualRecovery: `Run agency work prepare for each execution unit in epic '${id}'`,
|
|
675
|
+
},
|
|
676
|
+
]
|
|
677
|
+
for (const move of moves)
|
|
678
|
+
steps.push(directoryMoveStep(root, move.from, move.to))
|
|
679
|
+
yield* withLifecycleLock(
|
|
621
680
|
root,
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
681
|
+
withWorktreeLocks(
|
|
682
|
+
root,
|
|
683
|
+
executionUnits,
|
|
684
|
+
runLifecycleTransaction({
|
|
685
|
+
root,
|
|
686
|
+
preconditions: [
|
|
687
|
+
{ path: epic.path, revision: epic.revision },
|
|
688
|
+
...taskRecords.map((task) => ({
|
|
689
|
+
path: task.path,
|
|
690
|
+
revision: task.revision,
|
|
691
|
+
})),
|
|
692
|
+
...phaseRecords.map((phase) => ({
|
|
693
|
+
path: phase.path,
|
|
694
|
+
revision: phase.revision,
|
|
695
|
+
})),
|
|
696
|
+
],
|
|
697
|
+
steps,
|
|
698
|
+
}),
|
|
699
|
+
),
|
|
625
700
|
)
|
|
626
701
|
}
|
|
627
702
|
return {
|
|
@@ -642,12 +717,18 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
642
717
|
options: LifecycleOptions = {},
|
|
643
718
|
) =>
|
|
644
719
|
Effect.gen(function* () {
|
|
720
|
+
const fs = yield* FileSystemService
|
|
645
721
|
const workbase = yield* WorkbaseService
|
|
646
722
|
const epics = yield* EpicService
|
|
647
723
|
const tasks = yield* TaskService
|
|
648
724
|
const worktrees = yield* WorktreeService
|
|
649
725
|
const root = yield* workbase.discover(startPath)
|
|
650
726
|
const task = yield* tasks.show(id, root)
|
|
727
|
+
if ("claim" in task.data && task.data.claim?.state === "active") {
|
|
728
|
+
return yield* new ArchiveError({
|
|
729
|
+
message: `Task '${id}' has an active claim; release or finish it before archiving`,
|
|
730
|
+
})
|
|
731
|
+
}
|
|
651
732
|
const destination = archivedTaskDirectory(root, id)
|
|
652
733
|
yield* rejectExistingDestination(destination, "Archive")
|
|
653
734
|
let parentEpic: EpicRecord | undefined
|
|
@@ -670,27 +751,35 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
670
751
|
}
|
|
671
752
|
}
|
|
672
753
|
|
|
673
|
-
|
|
674
|
-
const
|
|
754
|
+
const executionUnits: { taskId: string; phaseId?: string }[] = []
|
|
755
|
+
const phaseRecords: PhaseRecord[] = []
|
|
675
756
|
if ("phases" in task.data) {
|
|
676
757
|
for (const phase of task.data.phases) {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
})),
|
|
758
|
+
const record = yield* (yield* PhaseService).show(
|
|
759
|
+
id,
|
|
760
|
+
phase.id,
|
|
761
|
+
root,
|
|
682
762
|
)
|
|
763
|
+
if (record.data.claim?.state === "active") {
|
|
764
|
+
return yield* new ArchiveError({
|
|
765
|
+
message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
phaseRecords.push(record)
|
|
769
|
+
executionUnits.push({ taskId: id, phaseId: phase.id })
|
|
683
770
|
}
|
|
684
771
|
} else {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
772
|
+
executionUnits.push({ taskId: id })
|
|
773
|
+
}
|
|
774
|
+
const removedWorktrees: string[] = []
|
|
775
|
+
for (const unit of executionUnits)
|
|
776
|
+
removedWorktrees.push(
|
|
777
|
+
...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
688
778
|
dryRun: true,
|
|
689
779
|
})),
|
|
690
780
|
)
|
|
691
|
-
}
|
|
692
781
|
const at = new Date().toISOString()
|
|
693
|
-
const writes: Write[] = []
|
|
782
|
+
const writes: (Write & { create?: boolean })[] = []
|
|
694
783
|
if (parentEpic) {
|
|
695
784
|
writes.push({
|
|
696
785
|
path: parentEpic.path,
|
|
@@ -700,8 +789,10 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
700
789
|
}),
|
|
701
790
|
})
|
|
702
791
|
}
|
|
792
|
+
const manifestPath = lifecycleManifestPath(dirname(task.path))
|
|
703
793
|
writes.push({
|
|
704
|
-
path:
|
|
794
|
+
path: manifestPath,
|
|
795
|
+
create: !(yield* fs.exists(manifestPath)),
|
|
705
796
|
content: json(
|
|
706
797
|
manifestFor(
|
|
707
798
|
yield* readManifest(dirname(task.path)),
|
|
@@ -723,11 +814,55 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
723
814
|
),
|
|
724
815
|
})
|
|
725
816
|
if (!options.dryRun) {
|
|
726
|
-
|
|
817
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
818
|
+
const steps: TransactionStep[] = [
|
|
819
|
+
documentWriteStep(root, writes),
|
|
820
|
+
{
|
|
821
|
+
label: `remove worktrees for task ${id}`,
|
|
822
|
+
apply: async () => {
|
|
823
|
+
try {
|
|
824
|
+
for (const unit of executionUnits)
|
|
825
|
+
await runWorktreeEffect(
|
|
826
|
+
worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
827
|
+
snapshots,
|
|
828
|
+
lockHeld: true,
|
|
829
|
+
}),
|
|
830
|
+
)
|
|
831
|
+
} catch (cause) {
|
|
832
|
+
await restoreWorktreeSnapshots(snapshots)
|
|
833
|
+
throw cause
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
rollback: () => restoreWorktreeSnapshots(snapshots),
|
|
837
|
+
manualRecovery: `Run agency work prepare for task '${id}'`,
|
|
838
|
+
},
|
|
839
|
+
directoryMoveStep(root, dirname(task.path), destination),
|
|
840
|
+
]
|
|
841
|
+
yield* withLifecycleLock(
|
|
727
842
|
root,
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
843
|
+
withWorktreeLocks(
|
|
844
|
+
root,
|
|
845
|
+
executionUnits,
|
|
846
|
+
runLifecycleTransaction({
|
|
847
|
+
root,
|
|
848
|
+
preconditions: [
|
|
849
|
+
{ path: task.path, revision: task.revision },
|
|
850
|
+
...(parentEpic
|
|
851
|
+
? [
|
|
852
|
+
{
|
|
853
|
+
path: parentEpic.path,
|
|
854
|
+
revision: parentEpic.revision,
|
|
855
|
+
},
|
|
856
|
+
]
|
|
857
|
+
: []),
|
|
858
|
+
...phaseRecords.map((phase) => ({
|
|
859
|
+
path: phase.path,
|
|
860
|
+
revision: phase.revision,
|
|
861
|
+
})),
|
|
862
|
+
],
|
|
863
|
+
steps,
|
|
864
|
+
}),
|
|
865
|
+
),
|
|
731
866
|
)
|
|
732
867
|
}
|
|
733
868
|
return {
|
|
@@ -736,7 +871,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
736
871
|
id,
|
|
737
872
|
path: destination,
|
|
738
873
|
affectedPaths: [destination],
|
|
739
|
-
removedWorktrees
|
|
874
|
+
removedWorktrees,
|
|
740
875
|
dryRun: options.dryRun === true,
|
|
741
876
|
at,
|
|
742
877
|
} satisfies LifecycleResult
|
|
@@ -749,6 +884,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
749
884
|
options: LifecycleOptions = {},
|
|
750
885
|
) =>
|
|
751
886
|
Effect.gen(function* () {
|
|
887
|
+
const fs = yield* FileSystemService
|
|
752
888
|
const workbase = yield* WorkbaseService
|
|
753
889
|
const tasks = yield* TaskService
|
|
754
890
|
const phases = yield* PhaseService
|
|
@@ -764,6 +900,11 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
764
900
|
const declaration = task.data.phases.find(
|
|
765
901
|
(candidate) => candidate.id === id,
|
|
766
902
|
)!
|
|
903
|
+
if (phase.data.claim?.state === "active") {
|
|
904
|
+
return yield* new ArchiveError({
|
|
905
|
+
message: `Phase '${id}' has an active claim; release or finish it before archiving`,
|
|
906
|
+
})
|
|
907
|
+
}
|
|
767
908
|
const dependent = task.data.phases.find((candidate) =>
|
|
768
909
|
candidate.dependsOn?.includes(id),
|
|
769
910
|
)
|
|
@@ -774,7 +915,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
774
915
|
}
|
|
775
916
|
const destination = archivedPhaseDirectory(root, taskId, id)
|
|
776
917
|
yield* rejectExistingDestination(destination, "Archive")
|
|
777
|
-
|
|
918
|
+
const removedWorktrees = yield* worktrees.remove(taskId, id, root, {
|
|
778
919
|
dryRun: true,
|
|
779
920
|
})
|
|
780
921
|
const at = new Date().toISOString()
|
|
@@ -782,10 +923,12 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
782
923
|
...task.data,
|
|
783
924
|
phases: task.data.phases.filter((candidate) => candidate.id !== id),
|
|
784
925
|
})
|
|
785
|
-
const
|
|
926
|
+
const manifestPath = lifecycleManifestPath(dirname(phase.path))
|
|
927
|
+
const writes: (Write & { create?: boolean })[] = [
|
|
786
928
|
{ path: task.path, content },
|
|
787
929
|
{
|
|
788
|
-
path:
|
|
930
|
+
path: manifestPath,
|
|
931
|
+
create: !(yield* fs.exists(manifestPath)),
|
|
789
932
|
content: json(
|
|
790
933
|
manifestFor(
|
|
791
934
|
yield* readManifest(dirname(phase.path)),
|
|
@@ -801,11 +944,37 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
801
944
|
},
|
|
802
945
|
]
|
|
803
946
|
if (!options.dryRun) {
|
|
804
|
-
|
|
947
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
948
|
+
yield* withLifecycleLock(
|
|
805
949
|
root,
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
950
|
+
withWorktreeLocks(
|
|
951
|
+
root,
|
|
952
|
+
[{ taskId, phaseId: id }],
|
|
953
|
+
runLifecycleTransaction({
|
|
954
|
+
root,
|
|
955
|
+
preconditions: [
|
|
956
|
+
{ path: task.path, revision: task.revision },
|
|
957
|
+
{ path: phase.path, revision: phase.revision },
|
|
958
|
+
],
|
|
959
|
+
steps: [
|
|
960
|
+
documentWriteStep(root, writes),
|
|
961
|
+
{
|
|
962
|
+
label: `remove worktrees for phase ${taskId}/${id}`,
|
|
963
|
+
apply: async () => {
|
|
964
|
+
await runWorktreeEffect(
|
|
965
|
+
worktrees.remove(taskId, id, root, {
|
|
966
|
+
snapshots,
|
|
967
|
+
lockHeld: true,
|
|
968
|
+
}),
|
|
969
|
+
)
|
|
970
|
+
},
|
|
971
|
+
rollback: () => restoreWorktreeSnapshots(snapshots),
|
|
972
|
+
manualRecovery: `Run agency work prepare for phase '${taskId}/${id}'`,
|
|
973
|
+
},
|
|
974
|
+
directoryMoveStep(root, dirname(phase.path), destination),
|
|
975
|
+
],
|
|
976
|
+
}),
|
|
977
|
+
),
|
|
809
978
|
)
|
|
810
979
|
}
|
|
811
980
|
return {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { cleanupTempDir, createTempDir } from "../test-utils"
|
|
5
|
+
import {
|
|
6
|
+
documentWriteStep,
|
|
7
|
+
runLifecycleTransaction,
|
|
8
|
+
} from "./LifecycleTransaction"
|
|
9
|
+
|
|
10
|
+
describe("lifecycle transactions", () => {
|
|
11
|
+
let root: string
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
root = await createTempDir()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(async () => cleanupTempDir(root))
|
|
18
|
+
|
|
19
|
+
test("completes every preflight before applying the first step", async () => {
|
|
20
|
+
const marker = join(root, "marker")
|
|
21
|
+
await expect(
|
|
22
|
+
Effect.runPromise(
|
|
23
|
+
runLifecycleTransaction({
|
|
24
|
+
root,
|
|
25
|
+
steps: [
|
|
26
|
+
{
|
|
27
|
+
label: "write marker",
|
|
28
|
+
apply: () => Bun.write(marker, "applied").then(() => undefined),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: "reject plan",
|
|
32
|
+
preflight: async () => {
|
|
33
|
+
throw new Error("preflight rejected")
|
|
34
|
+
},
|
|
35
|
+
apply: async () => undefined,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
).rejects.toThrow("failed before changes were applied")
|
|
41
|
+
expect(await Bun.file(marker).exists()).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("installs document writes together and rolls them back together", async () => {
|
|
45
|
+
const existing = join(root, "existing.md")
|
|
46
|
+
const created = join(root, "nested", "created.md")
|
|
47
|
+
await Bun.write(existing, "before")
|
|
48
|
+
|
|
49
|
+
let failure: any
|
|
50
|
+
const result = await Effect.runPromise(
|
|
51
|
+
runLifecycleTransaction({
|
|
52
|
+
root,
|
|
53
|
+
steps: [
|
|
54
|
+
documentWriteStep(root, [
|
|
55
|
+
{ path: existing, content: "after" },
|
|
56
|
+
{ path: created, content: "created", create: true },
|
|
57
|
+
]),
|
|
58
|
+
{
|
|
59
|
+
label: "fail after documents",
|
|
60
|
+
apply: async () => {
|
|
61
|
+
throw new Error("injected failure")
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}).pipe(Effect.either),
|
|
66
|
+
)
|
|
67
|
+
if (result._tag === "Left") failure = result.left
|
|
68
|
+
|
|
69
|
+
expect(failure.completed).toEqual([
|
|
70
|
+
"install documents: existing.md, nested/created.md",
|
|
71
|
+
])
|
|
72
|
+
expect(failure.rolledBack).toEqual(failure.completed)
|
|
73
|
+
expect(failure.manualRecovery).toEqual([])
|
|
74
|
+
expect(await Bun.file(existing).text()).toBe("before")
|
|
75
|
+
expect(await Bun.file(created).exists()).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("reports completed and manually recoverable work when rollback fails", async () => {
|
|
79
|
+
let failure: any
|
|
80
|
+
const result = await Effect.runPromise(
|
|
81
|
+
runLifecycleTransaction({
|
|
82
|
+
root,
|
|
83
|
+
steps: [
|
|
84
|
+
{
|
|
85
|
+
label: "external mutation",
|
|
86
|
+
apply: async () => undefined,
|
|
87
|
+
rollback: async () => {
|
|
88
|
+
throw new Error("rollback failed")
|
|
89
|
+
},
|
|
90
|
+
manualRecovery: "undo external mutation",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
label: "injected failure",
|
|
94
|
+
apply: async () => {
|
|
95
|
+
throw new Error("apply failed")
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
}).pipe(Effect.either),
|
|
100
|
+
)
|
|
101
|
+
if (result._tag === "Left") failure = result.left
|
|
102
|
+
|
|
103
|
+
expect(failure.completed).toEqual(["external mutation"])
|
|
104
|
+
expect(failure.rolledBack).toEqual([])
|
|
105
|
+
expect(failure.manualRecovery).toEqual(["undo external mutation"])
|
|
106
|
+
})
|
|
107
|
+
})
|