@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.
- package/README.md +13 -4
- package/cli.ts +34 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +9 -1
- package/src/cli-parser.test.ts +41 -0
- package/src/cli-parser.ts +63 -10
- package/src/cli.test.ts +1 -0
- package/src/commands/archive.test.ts +43 -3
- package/src/commands/archive.ts +88 -18
- package/src/commands/restore.test.ts +98 -0
- package/src/commands/restore.ts +73 -0
- package/src/services/ArchiveService.test.ts +579 -1
- package/src/services/ArchiveService.ts +1132 -83
- package/src/services/EpicService.ts +6 -0
- package/src/services/LifecycleTransaction.test.ts +107 -0
- package/src/services/LifecycleTransaction.ts +302 -0
- package/src/services/PhaseService.ts +162 -48
- package/src/services/TaskPhaseService.test.ts +47 -1
- package/src/services/TaskService.ts +34 -4
- package/src/services/WorktreeLock.ts +60 -0
- package/src/services/WorktreeService.test.ts +174 -1
- package/src/services/WorktreeService.ts +973 -494
- package/src/workbase/archive.ts +18 -0
|
@@ -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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|