@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.
@@ -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 { WorktreeService } from "./WorktreeService"
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 applyArchiveMutation = (
263
- root: string,
264
- targets: readonly WorktreeTarget[],
265
- moves: readonly Move[],
266
- writes: readonly Write[],
267
- ) =>
268
- Effect.gen(function* () {
269
- const worktrees = yield* WorktreeService
270
- const removedPaths: string[] = []
271
- const removedTargets: WorktreeTarget[] = []
272
- return yield* withLifecycleLock(
273
- root,
274
- Effect.gen(function* () {
275
- const targetsWithWorktrees = new Set<WorktreeTarget>()
276
- for (const target of targets) {
277
- const planned = yield* worktrees.remove(
278
- target.taskId,
279
- target.phaseId,
280
- root,
281
- {
282
- dryRun: true,
283
- },
284
- )
285
- if (planned.length > 0) targetsWithWorktrees.add(target)
286
- }
287
- for (const target of targets) {
288
- if (targetsWithWorktrees.has(target)) removedTargets.push(target)
289
- const removed = yield* worktrees.remove(
290
- target.taskId,
291
- target.phaseId,
292
- root,
293
- )
294
- removedPaths.push(...removed)
295
- }
296
- yield* applyMutation(moves, writes)
297
- return removedPaths
298
- }).pipe(
299
- Effect.catchAll((cause) =>
300
- Effect.gen(function* () {
301
- const rollbackErrors: unknown[] = []
302
- for (const target of [...removedTargets].reverse()) {
303
- const restored = yield* worktrees
304
- .materialize(target.taskId, target.phaseId, root)
305
- .pipe(Effect.either)
306
- if (Either.isLeft(restored)) rollbackErrors.push(restored.left)
307
- }
308
- if (rollbackErrors.length > 0) {
309
- return yield* new ArchiveError({
310
- message:
311
- "Archive failed and worktree rollback was incomplete; manual recovery is required",
312
- cause: new AggregateError([cause, ...rollbackErrors]),
313
- })
314
- }
315
- return yield* Effect.fail(cause)
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
- let removedWorktrees: string[] = []
561
- const targets: WorktreeTarget[] = []
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
- targets.push({ taskId: task.id, phaseId: phase.id })
566
- removedWorktrees.push(
567
- ...(yield* worktrees.remove(task.id, phase.id, root, {
568
- dryRun: true,
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
- targets.push({ taskId: task.id })
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: lifecycleManifestPath(target),
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: lifecycleManifestPath(destination),
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
- removedWorktrees = yield* applyArchiveMutation(
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
- targets,
623
- moves,
624
- writes,
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
- let plannedWorktrees: string[] = []
674
- const targets: WorktreeTarget[] = []
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
- targets.push({ taskId: id, phaseId: phase.id })
678
- plannedWorktrees.push(
679
- ...(yield* worktrees.remove(id, phase.id, root, {
680
- dryRun: true,
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
- targets.push({ taskId: id })
686
- plannedWorktrees.push(
687
- ...(yield* worktrees.remove(id, undefined, root, {
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: lifecycleManifestPath(destination),
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
- plannedWorktrees = yield* applyArchiveMutation(
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
- targets,
729
- [{ from: dirname(task.path), to: destination }],
730
- writes,
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: plannedWorktrees,
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
- let removedWorktrees = yield* worktrees.remove(taskId, id, root, {
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 writes: Write[] = [
926
+ const manifestPath = lifecycleManifestPath(dirname(phase.path))
927
+ const writes: (Write & { create?: boolean })[] = [
786
928
  { path: task.path, content },
787
929
  {
788
- path: lifecycleManifestPath(destination),
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
- removedWorktrees = yield* applyArchiveMutation(
947
+ const snapshots: WorktreeRemovalSnapshot[] = []
948
+ yield* withLifecycleLock(
805
949
  root,
806
- [{ taskId, phaseId: id }],
807
- [{ from: dirname(phase.path), to: destination }],
808
- writes,
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
+ })