@markjaquith/agency 2.4.0 → 2.5.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.
@@ -0,0 +1,334 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } 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 { PhaseService } from "./PhaseService"
9
+ import { TaskService } from "./TaskService"
10
+ import { WorkbaseService } from "./WorkbaseService"
11
+ import { WorktreeService } from "./WorktreeService"
12
+
13
+ const git = async (args: string[], cwd?: string) => {
14
+ const process = Bun.spawn(["git", ...args], {
15
+ cwd,
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ })
19
+ await process.exited
20
+ if (process.exitCode !== 0) {
21
+ throw new Error(await new Response(process.stderr).text())
22
+ }
23
+ }
24
+
25
+ describe("ArchiveService", () => {
26
+ let root: string
27
+
28
+ beforeEach(async () => {
29
+ root = await createTempDir()
30
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
31
+ const source = join(root, "source")
32
+ await mkdir(source, { recursive: true })
33
+ await git(["init", "--initial-branch=main"], source)
34
+ await git(["config", "user.email", "test@example.com"], source)
35
+ await git(["config", "user.name", "Test"], source)
36
+ await Bun.write(join(source, "README.md"), "example\n")
37
+ await git(["add", "README.md"], source)
38
+ await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
39
+ await mkdir(join(root, "repos"), { recursive: true })
40
+ await git(["clone", "--bare", source, join(root, "repos/agency")])
41
+ })
42
+
43
+ afterEach(async () => cleanupTempDir(root))
44
+
45
+ test("archives a task after removing its worktree and preserves its branch", async () => {
46
+ await runTestEffect(
47
+ EpicService.pipe(
48
+ Effect.flatMap((service) =>
49
+ service.create(
50
+ "parent",
51
+ "https://example.com/epic",
52
+ [{ repo: "agency", ref: "main" }],
53
+ root,
54
+ ),
55
+ ),
56
+ ),
57
+ )
58
+ await runTestEffect(
59
+ TaskService.pipe(
60
+ Effect.flatMap((service) =>
61
+ service.create(
62
+ {
63
+ id: "child",
64
+ ticketUrl: "https://example.com/task",
65
+ epic: "parent",
66
+ repo: "agency",
67
+ branch: "task/child",
68
+ base: "main",
69
+ },
70
+ root,
71
+ ),
72
+ ),
73
+ ),
74
+ )
75
+ await runTestEffect(
76
+ WorktreeService.pipe(
77
+ Effect.flatMap((service) =>
78
+ service.materialize("child", undefined, root),
79
+ ),
80
+ ),
81
+ )
82
+
83
+ const result = await runTestEffect(
84
+ ArchiveService.pipe(
85
+ Effect.flatMap((service) => service.archiveTask("child", root)),
86
+ ),
87
+ )
88
+
89
+ expect(result.path).toBe(join(root, "archive/tasks/child"))
90
+ expect(await Bun.file(join(root, "tasks/child/TASK.md")).exists()).toBe(
91
+ false,
92
+ )
93
+ expect(await Bun.file(join(result.path, "TASK.md")).exists()).toBe(true)
94
+ expect(await Bun.file(join(result.path, "code")).exists()).toBe(false)
95
+ const epic = await runTestEffect(
96
+ EpicService.pipe(
97
+ Effect.flatMap((service) => service.show("parent", root)),
98
+ ),
99
+ )
100
+ expect(epic.data.tasks).toEqual([])
101
+ const report = await runTestEffect(
102
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
103
+ )
104
+ expect(report.valid).toBe(true)
105
+ expect(
106
+ Bun.spawnSync([
107
+ "git",
108
+ "-C",
109
+ join(root, "repos/agency"),
110
+ "show-ref",
111
+ "--verify",
112
+ "refs/heads/task/child",
113
+ ]).exitCode,
114
+ ).toBe(0)
115
+ })
116
+
117
+ test("archives a phase in the mirrored task hierarchy", async () => {
118
+ await runTestEffect(
119
+ TaskService.pipe(
120
+ Effect.flatMap((service) =>
121
+ service.create(
122
+ {
123
+ id: "multi",
124
+ ticketUrl: "https://example.com/task",
125
+ multiPhase: true,
126
+ },
127
+ root,
128
+ ),
129
+ ),
130
+ ),
131
+ )
132
+ for (const id of ["first", "second"]) {
133
+ await runTestEffect(
134
+ PhaseService.pipe(
135
+ Effect.flatMap((service) =>
136
+ service.create(
137
+ {
138
+ taskId: "multi",
139
+ id,
140
+ repo: "agency",
141
+ branch: `task/${id}`,
142
+ base: "main",
143
+ },
144
+ root,
145
+ ),
146
+ ),
147
+ ),
148
+ )
149
+ }
150
+ await runTestEffect(
151
+ WorktreeService.pipe(
152
+ Effect.flatMap((service) =>
153
+ service.materialize("multi", "second", root),
154
+ ),
155
+ ),
156
+ )
157
+
158
+ const result = await runTestEffect(
159
+ ArchiveService.pipe(
160
+ Effect.flatMap((service) =>
161
+ service.archivePhase("multi", "second", root),
162
+ ),
163
+ ),
164
+ )
165
+
166
+ expect(result.path).toBe(join(root, "archive/tasks/multi/phases/second"))
167
+ expect(await Bun.file(join(result.path, "PHASE.md")).exists()).toBe(true)
168
+ expect(await Bun.file(join(result.path, "code")).exists()).toBe(false)
169
+ const task = await runTestEffect(
170
+ TaskService.pipe(
171
+ Effect.flatMap((service) => service.show("multi", root)),
172
+ ),
173
+ )
174
+ expect("phases" in task.data && task.data.phases).toEqual([{ id: "first" }])
175
+ const report = await runTestEffect(
176
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
177
+ )
178
+ expect(report.valid).toBe(true)
179
+ })
180
+
181
+ test("archives an epic and all of its child task folders", async () => {
182
+ await runTestEffect(
183
+ EpicService.pipe(
184
+ Effect.flatMap((service) =>
185
+ service.create(
186
+ "initiative",
187
+ "https://example.com/epic",
188
+ [{ repo: "agency", ref: "main" }],
189
+ root,
190
+ ),
191
+ ),
192
+ ),
193
+ )
194
+ await runTestEffect(
195
+ TaskService.pipe(
196
+ Effect.flatMap((service) =>
197
+ service.create(
198
+ {
199
+ id: "delivery",
200
+ ticketUrl: "https://example.com/task",
201
+ epic: "initiative",
202
+ repo: "agency",
203
+ branch: "task/delivery",
204
+ base: "main",
205
+ },
206
+ root,
207
+ ),
208
+ ),
209
+ ),
210
+ )
211
+
212
+ const result = await runTestEffect(
213
+ ArchiveService.pipe(
214
+ Effect.flatMap((service) => service.archiveEpic("initiative", root)),
215
+ ),
216
+ )
217
+
218
+ expect(result.archivedPaths).toEqual([
219
+ join(root, "archive/tasks/delivery"),
220
+ join(root, "archive/epics/initiative"),
221
+ ])
222
+ expect(
223
+ await Bun.file(join(root, "archive/epics/initiative/EPIC.md")).exists(),
224
+ ).toBe(true)
225
+ expect(
226
+ await Bun.file(join(root, "archive/tasks/delivery/TASK.md")).exists(),
227
+ ).toBe(true)
228
+ const report = await runTestEffect(
229
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
230
+ )
231
+ expect(report.valid).toBe(true)
232
+ })
233
+
234
+ test("rejects archiving an item required by an active sibling", async () => {
235
+ await runTestEffect(
236
+ TaskService.pipe(
237
+ Effect.flatMap((service) =>
238
+ service.create(
239
+ {
240
+ id: "multi",
241
+ ticketUrl: "https://example.com/task",
242
+ multiPhase: true,
243
+ },
244
+ root,
245
+ ),
246
+ ),
247
+ ),
248
+ )
249
+ await runTestEffect(
250
+ PhaseService.pipe(
251
+ Effect.flatMap((service) =>
252
+ service.create(
253
+ {
254
+ taskId: "multi",
255
+ id: "first",
256
+ repo: "agency",
257
+ branch: "task/first",
258
+ base: "main",
259
+ },
260
+ root,
261
+ ),
262
+ ),
263
+ ),
264
+ )
265
+ await runTestEffect(
266
+ PhaseService.pipe(
267
+ Effect.flatMap((service) =>
268
+ service.create(
269
+ {
270
+ taskId: "multi",
271
+ id: "second",
272
+ repo: "agency",
273
+ branch: "task/second",
274
+ base: "main",
275
+ dependsOn: ["first"],
276
+ },
277
+ root,
278
+ ),
279
+ ),
280
+ ),
281
+ )
282
+
283
+ await expect(
284
+ runTestEffect(
285
+ ArchiveService.pipe(
286
+ Effect.flatMap((service) =>
287
+ service.archivePhase("multi", "first", root),
288
+ ),
289
+ ),
290
+ ),
291
+ ).rejects.toThrow("phase 'second' depends on it")
292
+ })
293
+
294
+ test("does not move a task when its worktree is dirty", async () => {
295
+ await runTestEffect(
296
+ TaskService.pipe(
297
+ Effect.flatMap((service) =>
298
+ service.create(
299
+ {
300
+ id: "dirty",
301
+ ticketUrl: "https://example.com/task",
302
+ repo: "agency",
303
+ branch: "task/dirty",
304
+ base: "main",
305
+ },
306
+ root,
307
+ ),
308
+ ),
309
+ ),
310
+ )
311
+ const workspace = await runTestEffect(
312
+ WorktreeService.pipe(
313
+ Effect.flatMap((service) =>
314
+ service.materialize("dirty", undefined, root),
315
+ ),
316
+ ),
317
+ )
318
+ await Bun.write(join(workspace.writablePath, "dirty.txt"), "keep me\n")
319
+
320
+ await expect(
321
+ runTestEffect(
322
+ ArchiveService.pipe(
323
+ Effect.flatMap((service) => service.archiveTask("dirty", root)),
324
+ ),
325
+ ),
326
+ ).rejects.toThrow("Failed to remove worktree")
327
+ expect(await Bun.file(join(root, "tasks/dirty/TASK.md")).exists()).toBe(
328
+ true,
329
+ )
330
+ expect(await Bun.file(join(root, "archive/tasks/dirty")).exists()).toBe(
331
+ false,
332
+ )
333
+ })
334
+ })
@@ -0,0 +1,246 @@
1
+ import { Data, Effect } from "effect"
2
+ import { dirname, join } from "node:path"
3
+ import { EpicService, type EpicRecord } from "./EpicService"
4
+ import { FileSystemService } from "./FileSystemService"
5
+ import { PhaseService } from "./PhaseService"
6
+ import { TaskService } from "./TaskService"
7
+ import { WorkbaseService } from "./WorkbaseService"
8
+ import { WorktreeService } from "./WorktreeService"
9
+ import {
10
+ formatMarkdownDocument,
11
+ parseFrontmatter,
12
+ } from "../workbase/frontmatter"
13
+ import type { TaskFrontmatter as TaskData } from "../workbase/schemas"
14
+
15
+ class ArchiveError extends Data.TaggedError("ArchiveError")<{
16
+ readonly message: string
17
+ }> {}
18
+
19
+ interface ArchiveResult {
20
+ readonly kind: "epic" | "task" | "phase"
21
+ readonly id: string
22
+ readonly taskId?: string
23
+ readonly path: string
24
+ readonly archivedPaths: readonly string[]
25
+ readonly removedWorktrees: readonly string[]
26
+ }
27
+
28
+ interface TaskRecord {
29
+ readonly id: string
30
+ readonly path: string
31
+ readonly content: string
32
+ readonly data: TaskData
33
+ }
34
+
35
+ const rejectExistingDestination = (path: string) =>
36
+ Effect.gen(function* () {
37
+ const fs = yield* FileSystemService
38
+ if (yield* fs.exists(path)) {
39
+ return yield* new ArchiveError({
40
+ message: `Archive destination already exists: ${path}`,
41
+ })
42
+ }
43
+ })
44
+
45
+ export class ArchiveService extends Effect.Service<ArchiveService>()(
46
+ "ArchiveService",
47
+ {
48
+ sync: () => ({
49
+ archiveEpic: (id: string, startPath: string = process.cwd()) =>
50
+ Effect.gen(function* () {
51
+ const fs = yield* FileSystemService
52
+ const workbase = yield* WorkbaseService
53
+ const epics = yield* EpicService
54
+ const tasks = yield* TaskService
55
+ const worktrees = yield* WorktreeService
56
+ const root = yield* workbase.discover(startPath)
57
+ const epic = yield* epics.show(id, root)
58
+ const taskRecords: TaskRecord[] = []
59
+ for (const child of epic.data.tasks) {
60
+ const task = yield* tasks.show(child.id, root)
61
+ if (task.data.epic !== id) {
62
+ return yield* new ArchiveError({
63
+ message: `Task '${task.id}' does not reference epic '${id}'`,
64
+ })
65
+ }
66
+ taskRecords.push(task)
67
+ }
68
+
69
+ const epicDestination = join(root, "archive", "epics", id)
70
+ yield* rejectExistingDestination(epicDestination)
71
+ for (const task of taskRecords) {
72
+ yield* rejectExistingDestination(
73
+ join(root, "archive", "tasks", task.id),
74
+ )
75
+ }
76
+
77
+ const removedWorktrees: string[] = []
78
+ for (const task of taskRecords) {
79
+ if ("phases" in task.data) {
80
+ for (const phase of task.data.phases) {
81
+ removedWorktrees.push(
82
+ ...(yield* worktrees.remove(task.id, phase.id, root)),
83
+ )
84
+ }
85
+ } else {
86
+ removedWorktrees.push(
87
+ ...(yield* worktrees.remove(task.id, undefined, root)),
88
+ )
89
+ }
90
+ }
91
+
92
+ const archivedPaths: string[] = []
93
+ for (const task of taskRecords) {
94
+ const destination = join(root, "archive", "tasks", task.id)
95
+ yield* fs.createDirectory(dirname(destination))
96
+ yield* fs.moveDirectory(dirname(task.path), destination)
97
+ archivedPaths.push(destination)
98
+ }
99
+ yield* fs.createDirectory(dirname(epicDestination))
100
+ yield* fs.moveDirectory(dirname(epic.path), epicDestination)
101
+ archivedPaths.push(epicDestination)
102
+
103
+ return {
104
+ kind: "epic",
105
+ id,
106
+ path: epicDestination,
107
+ archivedPaths,
108
+ removedWorktrees,
109
+ } satisfies ArchiveResult
110
+ }),
111
+
112
+ archiveTask: (id: string, startPath: string = process.cwd()) =>
113
+ Effect.gen(function* () {
114
+ const fs = yield* FileSystemService
115
+ const workbase = yield* WorkbaseService
116
+ const epics = yield* EpicService
117
+ const tasks = yield* TaskService
118
+ const worktrees = yield* WorktreeService
119
+ const root = yield* workbase.discover(startPath)
120
+ const task = yield* tasks.show(id, root)
121
+ const destination = join(root, "archive", "tasks", id)
122
+ yield* rejectExistingDestination(destination)
123
+
124
+ let parentEpic: EpicRecord | undefined
125
+ if (task.data.epic) {
126
+ parentEpic = yield* epics.show(task.data.epic, root)
127
+ const dependent = parentEpic.data.tasks.find((child) =>
128
+ child.dependsOn?.includes(id),
129
+ )
130
+ if (dependent) {
131
+ return yield* new ArchiveError({
132
+ message: `Cannot archive task '${id}'; task '${dependent.id}' depends on it`,
133
+ })
134
+ }
135
+ }
136
+
137
+ const removedWorktrees: string[] = []
138
+ if ("phases" in task.data) {
139
+ for (const phase of task.data.phases) {
140
+ removedWorktrees.push(
141
+ ...(yield* worktrees.remove(id, phase.id, root)),
142
+ )
143
+ }
144
+ } else {
145
+ removedWorktrees.push(
146
+ ...(yield* worktrees.remove(id, undefined, root)),
147
+ )
148
+ }
149
+
150
+ if (parentEpic) {
151
+ const parsed = yield* parseFrontmatter(
152
+ parentEpic.content,
153
+ parentEpic.path,
154
+ )
155
+ yield* fs.writeFile(
156
+ parentEpic.path,
157
+ formatMarkdownDocument(
158
+ {
159
+ ...parentEpic.data,
160
+ tasks: parentEpic.data.tasks.filter(
161
+ (child) => child.id !== id,
162
+ ),
163
+ },
164
+ parsed.body,
165
+ ),
166
+ )
167
+ }
168
+
169
+ yield* fs.createDirectory(dirname(destination))
170
+ yield* fs.moveDirectory(dirname(task.path), destination)
171
+ return {
172
+ kind: "task",
173
+ id,
174
+ path: destination,
175
+ archivedPaths: [destination],
176
+ removedWorktrees,
177
+ } satisfies ArchiveResult
178
+ }),
179
+
180
+ archivePhase: (
181
+ taskId: string,
182
+ id: string,
183
+ startPath: string = process.cwd(),
184
+ ) =>
185
+ Effect.gen(function* () {
186
+ const fs = yield* FileSystemService
187
+ const workbase = yield* WorkbaseService
188
+ const tasks = yield* TaskService
189
+ const phases = yield* PhaseService
190
+ const worktrees = yield* WorktreeService
191
+ const root = yield* workbase.discover(startPath)
192
+ const task = yield* tasks.show(taskId, root)
193
+ if (!("phases" in task.data)) {
194
+ return yield* new ArchiveError({
195
+ message: `Task '${taskId}' is single-phase and does not contain phases`,
196
+ })
197
+ }
198
+ const phase = yield* phases.show(taskId, id, root)
199
+ const dependent = task.data.phases.find((candidate) =>
200
+ candidate.dependsOn?.includes(id),
201
+ )
202
+ if (dependent) {
203
+ return yield* new ArchiveError({
204
+ message: `Cannot archive phase '${id}'; phase '${dependent.id}' depends on it`,
205
+ })
206
+ }
207
+
208
+ const destination = join(
209
+ root,
210
+ "archive",
211
+ "tasks",
212
+ taskId,
213
+ "phases",
214
+ id,
215
+ )
216
+ yield* rejectExistingDestination(destination)
217
+ const removedWorktrees = yield* worktrees.remove(taskId, id, root)
218
+
219
+ const parsed = yield* parseFrontmatter(task.content, task.path)
220
+ yield* fs.writeFile(
221
+ task.path,
222
+ formatMarkdownDocument(
223
+ {
224
+ ...task.data,
225
+ phases: task.data.phases.filter(
226
+ (candidate) => candidate.id !== id,
227
+ ),
228
+ },
229
+ parsed.body,
230
+ ),
231
+ )
232
+ yield* fs.createDirectory(dirname(destination))
233
+ yield* fs.moveDirectory(dirname(phase.path), destination)
234
+
235
+ return {
236
+ kind: "phase",
237
+ id,
238
+ taskId,
239
+ path: destination,
240
+ archivedPaths: [destination],
241
+ removedWorktrees,
242
+ } satisfies ArchiveResult
243
+ }),
244
+ }),
245
+ },
246
+ ) {}
@@ -9,6 +9,7 @@ import {
9
9
  PhaseFrontmatter,
10
10
  type PhaseFrontmatter as PhaseData,
11
11
  type RepositoryReference,
12
+ WorkStatus,
12
13
  } from "../workbase/schemas"
13
14
  import {
14
15
  formatMarkdownDocument,
@@ -58,6 +59,15 @@ const decodePhase = (input: unknown) => {
58
59
  : Effect.succeed(result.right)
59
60
  }
60
61
 
62
+ const decodeStatus = (status: string) => {
63
+ const result = Schema.decodeUnknownEither(WorkStatus)(status)
64
+ return Either.isLeft(result)
65
+ ? Effect.fail(
66
+ new PhaseError({ message: `Invalid work status '${status}'` }),
67
+ )
68
+ : Effect.succeed(result.right)
69
+ }
70
+
61
71
  export class PhaseService extends Effect.Service<PhaseService>()(
62
72
  "PhaseService",
63
73
  {
@@ -318,6 +328,24 @@ export class PhaseService extends Effect.Service<PhaseService>()(
318
328
  }
319
329
  return record
320
330
  }),
331
+
332
+ setStatus: (
333
+ taskId: string,
334
+ id: string,
335
+ status: string,
336
+ startPath: string = process.cwd(),
337
+ ) =>
338
+ Effect.gen(function* () {
339
+ const fs = yield* FileSystemService
340
+ const service = yield* PhaseService
341
+ const validStatus = yield* decodeStatus(status)
342
+ const record = yield* service.show(taskId, id, startPath)
343
+ const parsed = yield* parseFrontmatter(record.content, record.path)
344
+ const data = { ...record.data, status: validStatus }
345
+ const content = formatMarkdownDocument(data, parsed.body)
346
+ yield* fs.writeFile(record.path, content)
347
+ return { ...record, content, data } satisfies PhaseRecord
348
+ }),
321
349
  }),
322
350
  },
323
351
  ) {}
@@ -212,6 +212,85 @@ describe("task and phase services", () => {
212
212
  branch: "task/single",
213
213
  base: "main",
214
214
  pr: "https://github.com/example/agency/pull/42",
215
+ status: "open",
215
216
  })
216
217
  })
218
+
219
+ test("updates status on execution units", async () => {
220
+ const createdTask = await runTestEffect(
221
+ TaskService.pipe(
222
+ Effect.flatMap((service) =>
223
+ service.create(
224
+ {
225
+ id: "single-status",
226
+ ticketUrl: "https://example.com/task",
227
+ repo: "agency",
228
+ branch: "task/single-status",
229
+ base: "main",
230
+ },
231
+ root,
232
+ ),
233
+ ),
234
+ ),
235
+ )
236
+ expect(createdTask.content).toContain("status: open")
237
+ const task = await runTestEffect(
238
+ TaskService.pipe(
239
+ Effect.flatMap((service) =>
240
+ service.setStatus("single-status", "done", root),
241
+ ),
242
+ ),
243
+ )
244
+ expect(task.data.status).toBe("done")
245
+ expect(task.content).toContain("status: done")
246
+ expect(task.content).toContain("Describe the task outcome.")
247
+
248
+ await runTestEffect(
249
+ TaskService.pipe(
250
+ Effect.flatMap((service) =>
251
+ service.create(
252
+ {
253
+ id: "multi-status",
254
+ ticketUrl: "https://example.com/task",
255
+ multiPhase: true,
256
+ },
257
+ root,
258
+ ),
259
+ ),
260
+ ),
261
+ )
262
+ await runTestEffect(
263
+ PhaseService.pipe(
264
+ Effect.flatMap((service) =>
265
+ service.create(
266
+ {
267
+ taskId: "multi-status",
268
+ id: "implementation",
269
+ repo: "agency",
270
+ branch: "task/multi-status",
271
+ base: "main",
272
+ },
273
+ root,
274
+ ),
275
+ ),
276
+ ),
277
+ )
278
+ const phase = await runTestEffect(
279
+ PhaseService.pipe(
280
+ Effect.flatMap((service) =>
281
+ service.setStatus("multi-status", "implementation", "dropped", root),
282
+ ),
283
+ ),
284
+ )
285
+ expect(phase.data.status).toBe("dropped")
286
+ await expect(
287
+ runTestEffect(
288
+ TaskService.pipe(
289
+ Effect.flatMap((service) =>
290
+ service.setStatus("multi-status", "done", root),
291
+ ),
292
+ ),
293
+ ),
294
+ ).rejects.toThrow("set status on a phase instead")
295
+ })
217
296
  })