@markjaquith/agency 2.23.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,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 { EpicService } from "./EpicService"
@@ -60,6 +60,52 @@ describe("task and phase services", () => {
60
60
  expect(epic.data.tasks).toEqual([{ id: "task-one" }])
61
61
  })
62
62
 
63
+ test("does not create a task when its parent update cannot start", async () => {
64
+ await runTestEffect(
65
+ EpicService.pipe(
66
+ Effect.flatMap((service) =>
67
+ service.create(
68
+ "locked",
69
+ "https://example.com/epic",
70
+ [{ repo: "agency", ref: "main" }],
71
+ root,
72
+ ),
73
+ ),
74
+ ),
75
+ )
76
+ const lock = join(root, ".agency-graph-mutation.lock")
77
+ await Bun.write(lock, "held")
78
+ await expect(
79
+ runTestEffect(
80
+ TaskService.pipe(
81
+ Effect.flatMap((service) =>
82
+ service.create(
83
+ {
84
+ id: "not-created",
85
+ ticketUrl: null,
86
+ epic: "locked",
87
+ repo: "agency",
88
+ branch: "task/not-created",
89
+ base: "main",
90
+ },
91
+ root,
92
+ ),
93
+ ),
94
+ ),
95
+ ),
96
+ ).rejects.toThrow("Another graph mutation is in progress")
97
+ await rm(lock)
98
+ expect(
99
+ await Bun.file(join(root, "tasks/not-created/TASK.md")).exists(),
100
+ ).toBe(false)
101
+ const epic = await runTestEffect(
102
+ EpicService.pipe(
103
+ Effect.flatMap((service) => service.show("locked", root)),
104
+ ),
105
+ )
106
+ expect(epic.data.tasks).toEqual([])
107
+ })
108
+
63
109
  test("creates and sequences phases on a multi-phase task", async () => {
64
110
  await runTestEffect(
65
111
  TaskService.pipe(
@@ -17,6 +17,11 @@ import {
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
19
  import { documentRevision } from "../workbase/document-revision"
20
+ import { archivedTaskDirectory } from "../workbase/archive"
21
+ import {
22
+ documentWriteStep,
23
+ runLifecycleTransaction,
24
+ } from "./LifecycleTransaction"
20
25
 
21
26
  class TaskError extends Data.TaggedError("TaskError")<{
22
27
  readonly message: string
@@ -85,6 +90,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
85
90
  message: `Task '${id}' already exists`,
86
91
  })
87
92
  }
93
+ if (yield* fs.exists(archivedTaskDirectory(root, id))) {
94
+ return yield* new TaskError({
95
+ message: `Task '${id}' is archived; restore it before reusing this ID`,
96
+ })
97
+ }
88
98
 
89
99
  let data: TaskData
90
100
  if (input.multiPhase) {
@@ -123,6 +133,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
123
133
  ...(data.repos ?? []).map((reference) => reference.repo),
124
134
  ]
125
135
  : []
136
+ if (new Set(referencedRepos).size !== referencedRepos.length) {
137
+ return yield* new TaskError({
138
+ message:
139
+ "Repository references must be unique and cannot include the writable repository",
140
+ })
141
+ }
126
142
  for (const alias of referencedRepos) {
127
143
  if (!(yield* fs.exists(join(root, "repos", alias)))) {
128
144
  return yield* new TaskError({
@@ -134,6 +150,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
134
150
  let parentEpic: EpicRecord | undefined
135
151
  if (input.epic) {
136
152
  parentEpic = yield* epics.show(input.epic, root)
153
+ if (parentEpic.data.tasks.some((task) => task.id === id)) {
154
+ return yield* new TaskError({
155
+ message: `Epic '${input.epic}' already lists task '${id}'`,
156
+ })
157
+ }
137
158
  }
138
159
 
139
160
  const title = id
@@ -144,9 +165,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
144
165
  data,
145
166
  `# ${title}\n\nDescribe the task outcome.`,
146
167
  )
147
- yield* fs.createDirectory(directory)
148
- yield* fs.writeFile(path, content)
149
-
168
+ const writes: {
169
+ path: string
170
+ content: string
171
+ create?: boolean
172
+ }[] = [{ path, content, create: true }]
150
173
  if (input.epic && parentEpic) {
151
174
  const parsed = yield* parseFrontmatter(
152
175
  parentEpic.content,
@@ -157,8 +180,15 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
157
180
  tasks: [...parentEpic.data.tasks, { id }],
158
181
  }
159
182
  const updated = formatMarkdownDocument(epicData, parsed.body)
160
- yield* fs.writeFile(parentEpic.path, updated)
183
+ writes.push({ path: parentEpic.path, content: updated })
161
184
  }
185
+ yield* runLifecycleTransaction({
186
+ root,
187
+ preconditions: parentEpic
188
+ ? [{ path: parentEpic.path, revision: parentEpic.revision }]
189
+ : [],
190
+ steps: [documentWriteStep(root, writes)],
191
+ })
162
192
 
163
193
  return {
164
194
  id,
@@ -0,0 +1,60 @@
1
+ import { Data, Effect } from "effect"
2
+ import { open, rm } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+
5
+ class WorktreeLockError extends Data.TaggedError("WorktreeLockError")<{
6
+ readonly message: string
7
+ readonly cause?: unknown
8
+ }> {}
9
+
10
+ export interface WorktreeLockTarget {
11
+ readonly taskId: string
12
+ readonly phaseId?: string
13
+ }
14
+
15
+ const withWorktreeLock = <A, E, R>(
16
+ root: string,
17
+ target: WorktreeLockTarget,
18
+ effect: Effect.Effect<A, E, R>,
19
+ ): Effect.Effect<A, E | WorktreeLockError, R> => {
20
+ const key = Buffer.from(
21
+ `${target.taskId}:${target.phaseId ?? "task"}`,
22
+ ).toString("hex")
23
+ const lockPath = join(root, `.agency-worktree-${key}.lock`)
24
+ return Effect.acquireUseRelease(
25
+ Effect.tryPromise({
26
+ try: () => open(lockPath, "wx"),
27
+ catch: (cause) =>
28
+ new WorktreeLockError({
29
+ message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'`,
30
+ cause,
31
+ }),
32
+ }),
33
+ () => effect,
34
+ (lock) =>
35
+ Effect.promise(async () => {
36
+ await lock.close().catch(() => undefined)
37
+ await rm(lockPath, { force: true }).catch(() => undefined)
38
+ }),
39
+ )
40
+ }
41
+
42
+ export const withWorktreeLocks = <A, E, R>(
43
+ root: string,
44
+ targets: readonly WorktreeLockTarget[],
45
+ effect: Effect.Effect<A, E, R>,
46
+ ): Effect.Effect<A, E | WorktreeLockError, R> => {
47
+ const unique = new Map(
48
+ targets.map((target) => [
49
+ `${target.taskId}:${target.phaseId ?? "task"}`,
50
+ target,
51
+ ]),
52
+ )
53
+ let current: Effect.Effect<A, E | WorktreeLockError, R> = effect
54
+ for (const [, target] of [...unique.entries()]
55
+ .sort(([left], [right]) => left.localeCompare(right))
56
+ .reverse()) {
57
+ current = withWorktreeLock(root, target, current)
58
+ }
59
+ return current
60
+ }
@@ -427,7 +427,8 @@ describe("WorktreeService", () => {
427
427
  repo: "agency",
428
428
  base: "absent-base",
429
429
  config: { version: 2 },
430
- expected: "Failed to create branch 'task/bad-base'",
430
+ expected:
431
+ "Base 'absent-base' for repository 'agency' does not resolve to a commit",
431
432
  },
432
433
  {
433
434
  id: "failed-command",
@@ -486,6 +487,64 @@ pr: null
486
487
  }
487
488
  })
488
489
 
490
+ test("compensates a custom command that fails after creating Git state", async () => {
491
+ await Bun.write(
492
+ join(root, "agency.json"),
493
+ JSON.stringify({
494
+ version: 2,
495
+ worktreeCreateCommand: [
496
+ "sh",
497
+ "-c",
498
+ 'git -C "$1" worktree add -b "$3" "$2" "$4" >/dev/null 2>&1; exit 7',
499
+ "agency-worktree",
500
+ "{repo}",
501
+ "{worktree}",
502
+ "{branch}",
503
+ "{base}",
504
+ ],
505
+ }),
506
+ )
507
+ await runTestEffect(
508
+ TaskService.pipe(
509
+ Effect.flatMap((service) =>
510
+ service.create(
511
+ {
512
+ id: "compensated",
513
+ ticketUrl: "https://example.com/task",
514
+ repo: "agency",
515
+ branch: "task/compensated",
516
+ base: "main",
517
+ },
518
+ root,
519
+ ),
520
+ ),
521
+ ),
522
+ )
523
+
524
+ await expect(
525
+ runTestEffect(
526
+ WorktreeService.pipe(
527
+ Effect.flatMap((service) =>
528
+ service.materialize("compensated", undefined, root),
529
+ ),
530
+ ),
531
+ ),
532
+ ).rejects.toThrow("Failed to create worktree for 'agency'")
533
+ expect(
534
+ await Bun.file(join(root, "tasks/compensated/code/agency")).exists(),
535
+ ).toBe(false)
536
+ expect(
537
+ Bun.spawnSync([
538
+ "git",
539
+ "-C",
540
+ join(root, "repos/agency"),
541
+ "show-ref",
542
+ "--verify",
543
+ "refs/heads/task/compensated",
544
+ ]).exitCode,
545
+ ).not.toBe(0)
546
+ })
547
+
489
548
  test("moves and repairs an existing worktree when converting a task", async () => {
490
549
  await runTestEffect(
491
550
  TaskService.pipe(
@@ -554,6 +613,54 @@ pr: null
554
613
  expect(status.exitCode).toBe(0)
555
614
  })
556
615
 
616
+ test("preflights worktree registration before converting a task", async () => {
617
+ await runTestEffect(
618
+ TaskService.pipe(
619
+ Effect.flatMap((service) =>
620
+ service.create(
621
+ {
622
+ id: "unregistered",
623
+ ticketUrl: null,
624
+ repo: "agency",
625
+ branch: "task/unregistered",
626
+ base: "main",
627
+ },
628
+ root,
629
+ ),
630
+ ),
631
+ ),
632
+ )
633
+ const checkout = join(root, "tasks/unregistered/code/agency")
634
+ await mkdir(checkout, { recursive: true })
635
+ await Bun.write(join(checkout, "keep.txt"), "keep\n")
636
+
637
+ await expect(
638
+ runTestEffect(
639
+ PhaseService.pipe(
640
+ Effect.flatMap((service) =>
641
+ service.create(
642
+ {
643
+ taskId: "unregistered",
644
+ id: "follow-up",
645
+ firstPhase: "implementation",
646
+ repo: "agency",
647
+ branch: "task/follow-up-unregistered",
648
+ base: "main",
649
+ },
650
+ root,
651
+ ),
652
+ ),
653
+ ),
654
+ ),
655
+ ).rejects.toThrow("is not registered as a Git worktree")
656
+ expect(await Bun.file(join(checkout, "keep.txt")).text()).toBe("keep\n")
657
+ expect(
658
+ await Bun.file(
659
+ join(root, "tasks/unregistered/phases/implementation/PHASE.md"),
660
+ ).exists(),
661
+ ).toBe(false)
662
+ })
663
+
557
664
  test("rejects a writable branch checked out in another worktree", async () => {
558
665
  const repository = join(root, "repos/agency")
559
666
  await git(["-C", repository, "branch", "task/shared", "main"])
@@ -733,6 +840,21 @@ pr: null
733
840
  ),
734
841
  ),
735
842
  ).rejects.toThrow("is attached to branch 'main'")
843
+ expect(
844
+ await Bun.file(
845
+ join(root, "tasks/attached-reference/code/agency"),
846
+ ).exists(),
847
+ ).toBe(false)
848
+ expect(
849
+ Bun.spawnSync([
850
+ "git",
851
+ "-C",
852
+ join(root, "repos/agency"),
853
+ "show-ref",
854
+ "--verify",
855
+ "refs/heads/task/attached-reference",
856
+ ]).exitCode,
857
+ ).not.toBe(0)
736
858
  })
737
859
 
738
860
  test("removes worktrees without deleting branches", async () => {
@@ -860,6 +982,57 @@ pr: null
860
982
  ).not.toBe(0)
861
983
  })
862
984
 
985
+ test("dry-run resolves a reference that exists only on the remote", async () => {
986
+ await git(["checkout", "-b", "remote-only"], source)
987
+ await Bun.write(join(source, "remote.txt"), "remote\n")
988
+ await git(["add", "remote.txt"], source)
989
+ await git(
990
+ ["-c", "commit.gpgsign=false", "commit", "-m", "remote branch"],
991
+ source,
992
+ )
993
+ await runTestEffect(
994
+ TaskService.pipe(
995
+ Effect.flatMap((service) =>
996
+ service.create(
997
+ {
998
+ id: "remote-reference",
999
+ ticketUrl: null,
1000
+ repo: "agency",
1001
+ repos: [{ repo: "effect", ref: "remote-only" }],
1002
+ branch: "task/remote-reference",
1003
+ base: "main",
1004
+ },
1005
+ root,
1006
+ ),
1007
+ ),
1008
+ ),
1009
+ )
1010
+
1011
+ const workspace = await runTestEffect(
1012
+ WorktreeService.pipe(
1013
+ Effect.flatMap((service) =>
1014
+ service.materialize("remote-reference", undefined, root, {
1015
+ dryRun: true,
1016
+ }),
1017
+ ),
1018
+ ),
1019
+ )
1020
+ expect(
1021
+ workspace.checkouts.find((checkout) => checkout.repo === "effect")
1022
+ ?.resolvedCommit,
1023
+ ).toMatch(/^[0-9a-f]{40}$/)
1024
+ expect(
1025
+ Bun.spawnSync([
1026
+ "git",
1027
+ "-C",
1028
+ join(root, "repos/effect"),
1029
+ "show-ref",
1030
+ "--verify",
1031
+ "refs/heads/remote-only",
1032
+ ]).exitCode,
1033
+ ).not.toBe(0)
1034
+ })
1035
+
863
1036
  test("refuses to remove a worktree with uncommitted changes", async () => {
864
1037
  await runTestEffect(
865
1038
  TaskService.pipe(