@markjaquith/agency 2.22.0 → 2.24.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,5 +1,7 @@
1
- import { Data, Effect } from "effect"
2
- import { dirname, join } from "node:path"
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { mkdir, open, rename, rm } from "node:fs/promises"
4
+ import { dirname, join, relative } from "node:path"
3
5
  import { EpicService, type EpicRecord } from "./EpicService"
4
6
  import { FileSystemService } from "./FileSystemService"
5
7
  import { PhaseService } from "./PhaseService"
@@ -10,19 +12,87 @@ import {
10
12
  formatMarkdownDocument,
11
13
  parseFrontmatter,
12
14
  } from "../workbase/frontmatter"
13
- import type { TaskFrontmatter as TaskData } from "../workbase/schemas"
15
+ import {
16
+ EpicFrontmatter,
17
+ Dependency,
18
+ PhaseFrontmatter,
19
+ TaskFrontmatter,
20
+ type Dependency as DependencyData,
21
+ type EpicFrontmatter as EpicData,
22
+ type PhaseFrontmatter as PhaseData,
23
+ type TaskFrontmatter as TaskData,
24
+ } from "../workbase/schemas"
25
+ import { validateDependencies } from "../workbase/dependency-graph"
26
+ import {
27
+ archivedEpicDirectory,
28
+ archivedPhaseDirectory,
29
+ archivedTaskDirectory,
30
+ lifecycleManifestPath,
31
+ } from "../workbase/archive"
14
32
 
15
33
  class ArchiveError extends Data.TaggedError("ArchiveError")<{
16
34
  readonly message: string
35
+ readonly cause?: unknown
17
36
  }> {}
18
37
 
19
- interface ArchiveResult {
20
- readonly kind: "epic" | "task" | "phase"
38
+ export type ArchiveKind = "epic" | "task" | "phase"
39
+
40
+ const LifecycleEventSchema = Schema.Struct({
41
+ operation: Schema.Literal("archive", "restore"),
42
+ at: Schema.String,
43
+ from: Schema.String,
44
+ to: Schema.String,
45
+ })
46
+
47
+ const LifecycleManifestSchema = Schema.Struct({
48
+ version: Schema.Literal(1),
49
+ kind: Schema.Literal("epic", "task", "phase"),
50
+ id: Schema.String,
51
+ taskId: Schema.optional(Schema.String),
52
+ parent: Schema.optional(
53
+ Schema.Struct({
54
+ kind: Schema.Literal("epic", "task"),
55
+ id: Schema.String,
56
+ declaration: Dependency,
57
+ }),
58
+ ),
59
+ history: Schema.Array(LifecycleEventSchema),
60
+ })
61
+
62
+ type LifecycleEvent = Schema.Schema.Type<typeof LifecycleEventSchema>
63
+ type LifecycleManifest = Schema.Schema.Type<typeof LifecycleManifestSchema>
64
+
65
+ interface ArchivedRecord {
66
+ readonly kind: ArchiveKind
67
+ readonly id: string
68
+ readonly taskId?: string
69
+ readonly path: string
70
+ readonly documentPath: string
71
+ readonly content: string
72
+ readonly data: EpicData | TaskData | PhaseData
73
+ readonly provenance?: LifecycleManifest
74
+ }
75
+
76
+ interface LifecycleResult {
77
+ readonly operation: "archive" | "restore"
78
+ readonly kind: ArchiveKind
21
79
  readonly id: string
22
80
  readonly taskId?: string
23
81
  readonly path: string
24
- readonly archivedPaths: readonly string[]
82
+ readonly affectedPaths: readonly string[]
25
83
  readonly removedWorktrees: readonly string[]
84
+ readonly dryRun: boolean
85
+ readonly at: string
86
+ }
87
+
88
+ interface LifecycleOptions {
89
+ readonly dryRun?: boolean
90
+ }
91
+
92
+ export interface ArchiveFilters {
93
+ readonly kinds?: readonly string[]
94
+ readonly statuses?: readonly string[]
95
+ readonly repositories?: readonly string[]
26
96
  }
27
97
 
28
98
  interface TaskRecord {
@@ -32,23 +102,437 @@ interface TaskRecord {
32
102
  readonly data: TaskData
33
103
  }
34
104
 
35
- const rejectExistingDestination = (path: string) =>
105
+ interface Move {
106
+ readonly from: string
107
+ readonly to: string
108
+ }
109
+
110
+ interface Write {
111
+ readonly path: string
112
+ readonly content: string
113
+ }
114
+
115
+ interface WorktreeTarget {
116
+ readonly taskId: string
117
+ readonly phaseId?: string
118
+ }
119
+
120
+ const decode = <S extends Schema.Schema.AnyNoContext>(
121
+ schema: S,
122
+ input: unknown,
123
+ label: string,
124
+ ) => {
125
+ const result = Schema.decodeUnknownEither(schema, {
126
+ errors: "all",
127
+ onExcessProperty: "error",
128
+ })(input)
129
+ return Either.isLeft(result)
130
+ ? Effect.fail(
131
+ new ArchiveError({
132
+ message: `Invalid archived ${label}: ${TreeFormatter.formatErrorSync(result.left)}`,
133
+ }),
134
+ )
135
+ : Effect.succeed(result.right)
136
+ }
137
+
138
+ const readManifest = (directory: string) =>
139
+ Effect.gen(function* () {
140
+ const fs = yield* FileSystemService
141
+ const path = lifecycleManifestPath(directory)
142
+ if (!(yield* fs.exists(path))) return undefined
143
+ const content = yield* fs.readFile(path)
144
+ const input = yield* Effect.try({
145
+ try: () => JSON.parse(content) as unknown,
146
+ catch: (cause) =>
147
+ new ArchiveError({
148
+ message: `Invalid lifecycle provenance: ${path}`,
149
+ cause,
150
+ }),
151
+ })
152
+ const decoded = Schema.decodeUnknownEither(LifecycleManifestSchema, {
153
+ errors: "all",
154
+ onExcessProperty: "error",
155
+ })(input)
156
+ if (Either.isLeft(decoded)) {
157
+ return yield* new ArchiveError({
158
+ message: `Invalid lifecycle provenance ${path}: ${TreeFormatter.formatErrorSync(decoded.left)}`,
159
+ })
160
+ }
161
+ return decoded.right
162
+ })
163
+
164
+ const manifestFor = (
165
+ existing: LifecycleManifest | undefined,
166
+ entity: Omit<LifecycleManifest, "version" | "history">,
167
+ event: LifecycleEvent,
168
+ ): LifecycleManifest => ({
169
+ version: 1,
170
+ ...entity,
171
+ history: [...(existing?.history ?? []), event],
172
+ })
173
+
174
+ const json = (value: unknown) => JSON.stringify(value, null, 2) + "\n"
175
+
176
+ const withLifecycleLock = <A, E, R>(
177
+ root: string,
178
+ operation: Effect.Effect<A, E, R>,
179
+ ) => {
180
+ const lockPath = join(root, ".agency-archive.lock")
181
+ return Effect.acquireUseRelease(
182
+ Effect.tryPromise({
183
+ try: () => open(lockPath, "wx"),
184
+ catch: (cause) =>
185
+ new ArchiveError({
186
+ message:
187
+ "Another archive or restore operation is in progress; wait and retry",
188
+ cause,
189
+ }),
190
+ }),
191
+ () => operation,
192
+ (lock) =>
193
+ Effect.promise(async () => {
194
+ await lock.close().catch(() => undefined)
195
+ await rm(lockPath, { force: true }).catch(() => undefined)
196
+ }),
197
+ )
198
+ }
199
+
200
+ const applyMutation = (moves: readonly Move[], writes: readonly Write[]) =>
201
+ Effect.tryPromise({
202
+ try: async () => {
203
+ const completedMoves: Move[] = []
204
+ const completedWrites: {
205
+ path: string
206
+ existed: boolean
207
+ content?: string
208
+ }[] = []
209
+ try {
210
+ for (const move of moves) {
211
+ await mkdir(dirname(move.to), { recursive: true })
212
+ await rename(move.from, move.to)
213
+ completedMoves.push(move)
214
+ }
215
+ for (const write of writes) {
216
+ const file = Bun.file(write.path)
217
+ const existed = await file.exists()
218
+ completedWrites.push({
219
+ path: write.path,
220
+ existed,
221
+ ...(existed ? { content: await file.text() } : {}),
222
+ })
223
+ await Bun.write(write.path, write.content)
224
+ }
225
+ } catch (cause) {
226
+ let rollbackCause: unknown
227
+ for (const write of [...completedWrites].reverse()) {
228
+ try {
229
+ if (write.existed) await Bun.write(write.path, write.content!)
230
+ else await rm(write.path, { force: true })
231
+ } catch (error) {
232
+ rollbackCause ??= error
233
+ }
234
+ }
235
+ for (const move of [...completedMoves].reverse()) {
236
+ try {
237
+ await rename(move.to, move.from)
238
+ } catch (error) {
239
+ rollbackCause ??= error
240
+ }
241
+ }
242
+ if (rollbackCause) {
243
+ throw new ArchiveError({
244
+ message:
245
+ "Archive lifecycle rollback failed; manual recovery is required",
246
+ cause: new AggregateError([cause, rollbackCause]),
247
+ })
248
+ }
249
+ throw cause
250
+ }
251
+ },
252
+ catch: (cause) =>
253
+ cause instanceof ArchiveError
254
+ ? cause
255
+ : new ArchiveError({
256
+ message:
257
+ "Archive lifecycle operation failed; changes were rolled back",
258
+ cause,
259
+ }),
260
+ })
261
+
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
+ ),
319
+ )
320
+ })
321
+
322
+ const rejectExistingDestination = (
323
+ path: string,
324
+ operation: "Archive" | "Restore",
325
+ ) =>
36
326
  Effect.gen(function* () {
37
327
  const fs = yield* FileSystemService
38
328
  if (yield* fs.exists(path)) {
39
329
  return yield* new ArchiveError({
40
- message: `Archive destination already exists: ${path}`,
330
+ message: `${operation} destination already exists: ${path}`,
41
331
  })
42
332
  }
43
333
  })
44
334
 
335
+ const event = (
336
+ root: string,
337
+ operation: LifecycleEvent["operation"],
338
+ at: string,
339
+ from: string,
340
+ to: string,
341
+ ): LifecycleEvent => ({
342
+ operation,
343
+ at,
344
+ from: relative(root, from),
345
+ to: relative(root, to),
346
+ })
347
+
348
+ const declarationContent = (
349
+ record: { readonly content: string; readonly path: string },
350
+ data: EpicData | TaskData,
351
+ ) =>
352
+ parseFrontmatter(record.content, record.path).pipe(
353
+ Effect.map((parsed) => formatMarkdownDocument(data, parsed.body)),
354
+ )
355
+
356
+ const repositoriesFor = (record: ArchivedRecord) => {
357
+ if (record.kind === "epic") {
358
+ return (record.data as EpicData).repos.map((reference) => reference.repo)
359
+ }
360
+ if ("repo" in record.data) {
361
+ return [
362
+ record.data.repo,
363
+ ...(record.data.repos ?? []).map((reference) => reference.repo),
364
+ ]
365
+ }
366
+ return []
367
+ }
368
+
369
+ const statusFor = (record: ArchivedRecord) =>
370
+ "status" in record.data ? record.data.status : undefined
371
+
45
372
  export class ArchiveService extends Effect.Service<ArchiveService>()(
46
373
  "ArchiveService",
47
374
  {
48
375
  sync: () => ({
49
- archiveEpic: (id: string, startPath: string = process.cwd()) =>
376
+ list: (filters: ArchiveFilters = {}, startPath: string = process.cwd()) =>
50
377
  Effect.gen(function* () {
51
378
  const fs = yield* FileSystemService
379
+ const workbase = yield* WorkbaseService
380
+ const root = yield* workbase.discover(startPath)
381
+ const kinds = filters.kinds?.length
382
+ ? new Set(filters.kinds)
383
+ : new Set<ArchiveKind>(["epic", "task", "phase"])
384
+ for (const kind of kinds) {
385
+ if (
386
+ !(["epic", "task", "phase"] as const).includes(
387
+ kind as ArchiveKind,
388
+ )
389
+ ) {
390
+ return yield* new ArchiveError({
391
+ message: `Unknown archive kind '${kind}'`,
392
+ })
393
+ }
394
+ }
395
+
396
+ const records: ArchivedRecord[] = []
397
+ const readRecord = (
398
+ kind: ArchiveKind,
399
+ id: string,
400
+ directory: string,
401
+ documentName: string,
402
+ schema: Schema.Schema.AnyNoContext,
403
+ taskId?: string,
404
+ ) =>
405
+ Effect.gen(function* () {
406
+ const documentPath = join(directory, documentName)
407
+ if (!(yield* fs.exists(documentPath))) return
408
+ const content = yield* fs.readFile(documentPath)
409
+ const parsed = yield* parseFrontmatter(content, documentPath)
410
+ const data = yield* decode(schema, parsed.data, `${kind} '${id}'`)
411
+ const provenance = yield* readManifest(directory)
412
+ if (
413
+ provenance &&
414
+ (provenance.kind !== kind ||
415
+ provenance.id !== id ||
416
+ (kind === "phase" && provenance.taskId !== taskId))
417
+ ) {
418
+ return yield* new ArchiveError({
419
+ message: `Lifecycle provenance does not match archived ${kind} '${id}'`,
420
+ })
421
+ }
422
+ records.push({
423
+ kind,
424
+ id,
425
+ ...(taskId ? { taskId } : {}),
426
+ path: directory,
427
+ documentPath,
428
+ content,
429
+ data: data as EpicData | TaskData | PhaseData,
430
+ ...(provenance ? { provenance } : {}),
431
+ })
432
+ })
433
+
434
+ if (kinds.has("epic")) {
435
+ const directory = join(root, "archive", "epics")
436
+ if (yield* fs.isDirectory(directory)) {
437
+ for (const entry of yield* fs.readDirectory(directory)) {
438
+ if (entry.isDirectory)
439
+ yield* readRecord(
440
+ "epic",
441
+ entry.name,
442
+ join(directory, entry.name),
443
+ "EPIC.md",
444
+ EpicFrontmatter,
445
+ )
446
+ }
447
+ }
448
+ }
449
+
450
+ const tasksDirectory = join(root, "archive", "tasks")
451
+ if (yield* fs.isDirectory(tasksDirectory)) {
452
+ for (const taskEntry of yield* fs.readDirectory(tasksDirectory)) {
453
+ if (!taskEntry.isDirectory) continue
454
+ const taskDirectory = join(tasksDirectory, taskEntry.name)
455
+ if (kinds.has("task")) {
456
+ yield* readRecord(
457
+ "task",
458
+ taskEntry.name,
459
+ taskDirectory,
460
+ "TASK.md",
461
+ TaskFrontmatter,
462
+ )
463
+ }
464
+ if (!kinds.has("phase")) continue
465
+ const phasesDirectory = join(taskDirectory, "phases")
466
+ if (!(yield* fs.isDirectory(phasesDirectory))) continue
467
+ for (const phaseEntry of yield* fs.readDirectory(
468
+ phasesDirectory,
469
+ )) {
470
+ if (phaseEntry.isDirectory)
471
+ yield* readRecord(
472
+ "phase",
473
+ phaseEntry.name,
474
+ join(phasesDirectory, phaseEntry.name),
475
+ "PHASE.md",
476
+ PhaseFrontmatter,
477
+ taskEntry.name,
478
+ )
479
+ }
480
+ }
481
+ }
482
+
483
+ return records
484
+ .filter(
485
+ (record) =>
486
+ !filters.statuses?.length ||
487
+ filters.statuses.includes(statusFor(record) ?? ""),
488
+ )
489
+ .filter(
490
+ (record) =>
491
+ !filters.repositories?.length ||
492
+ filters.repositories.some((repository) =>
493
+ repositoriesFor(record).includes(repository),
494
+ ),
495
+ )
496
+ .sort((a, b) =>
497
+ `${a.kind}:${a.taskId ?? ""}:${a.id}`.localeCompare(
498
+ `${b.kind}:${b.taskId ?? ""}:${b.id}`,
499
+ ),
500
+ )
501
+ }),
502
+
503
+ show: (
504
+ kind: ArchiveKind,
505
+ id: string,
506
+ taskId: string | undefined,
507
+ startPath: string = process.cwd(),
508
+ ) =>
509
+ Effect.gen(function* () {
510
+ const service = yield* ArchiveService
511
+ const record = (yield* service.list(
512
+ { kinds: [kind] },
513
+ startPath,
514
+ )).find(
515
+ (candidate) =>
516
+ candidate.id === id &&
517
+ (kind !== "phase" || candidate.taskId === taskId),
518
+ )
519
+ if (!record) {
520
+ return yield* new ArchiveError({
521
+ message:
522
+ kind === "phase"
523
+ ? `Archived phase '${id}' does not exist on task '${taskId}'`
524
+ : `Archived ${kind} '${id}' does not exist`,
525
+ })
526
+ }
527
+ return record
528
+ }),
529
+
530
+ archiveEpic: (
531
+ id: string,
532
+ startPath: string = process.cwd(),
533
+ options: LifecycleOptions = {},
534
+ ) =>
535
+ Effect.gen(function* () {
52
536
  const workbase = yield* WorkbaseService
53
537
  const epics = yield* EpicService
54
538
  const tasks = yield* TaskService
@@ -65,65 +549,117 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
65
549
  }
66
550
  taskRecords.push(task)
67
551
  }
68
-
69
- const epicDestination = join(root, "archive", "epics", id)
70
- yield* rejectExistingDestination(epicDestination)
71
- for (const task of taskRecords) {
552
+ const destination = archivedEpicDirectory(root, id)
553
+ yield* rejectExistingDestination(destination, "Archive")
554
+ for (const task of taskRecords)
72
555
  yield* rejectExistingDestination(
73
- join(root, "archive", "tasks", task.id),
556
+ archivedTaskDirectory(root, task.id),
557
+ "Archive",
74
558
  )
75
- }
76
559
 
77
- const removedWorktrees: string[] = []
560
+ let removedWorktrees: string[] = []
561
+ const targets: WorktreeTarget[] = []
78
562
  for (const task of taskRecords) {
79
563
  if ("phases" in task.data) {
80
564
  for (const phase of task.data.phases) {
565
+ targets.push({ taskId: task.id, phaseId: phase.id })
81
566
  removedWorktrees.push(
82
- ...(yield* worktrees.remove(task.id, phase.id, root)),
567
+ ...(yield* worktrees.remove(task.id, phase.id, root, {
568
+ dryRun: true,
569
+ })),
83
570
  )
84
571
  }
85
572
  } else {
573
+ targets.push({ taskId: task.id })
86
574
  removedWorktrees.push(
87
- ...(yield* worktrees.remove(task.id, undefined, root)),
575
+ ...(yield* worktrees.remove(task.id, undefined, root, {
576
+ dryRun: true,
577
+ })),
88
578
  )
89
579
  }
90
580
  }
91
581
 
92
- const archivedPaths: string[] = []
582
+ const at = new Date().toISOString()
583
+ const moves: Move[] = taskRecords.map((task) => ({
584
+ from: dirname(task.path),
585
+ to: archivedTaskDirectory(root, task.id),
586
+ }))
587
+ moves.push({ from: dirname(epic.path), to: destination })
588
+ const writes: Write[] = []
93
589
  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)
590
+ const target = archivedTaskDirectory(root, task.id)
591
+ const declaration = epic.data.tasks.find(
592
+ (child) => child.id === task.id,
593
+ )!
594
+ writes.push({
595
+ path: lifecycleManifestPath(target),
596
+ content: json(
597
+ manifestFor(
598
+ yield* readManifest(dirname(task.path)),
599
+ {
600
+ kind: "task",
601
+ id: task.id,
602
+ parent: { kind: "epic", id, declaration },
603
+ },
604
+ event(root, "archive", at, dirname(task.path), target),
605
+ ),
606
+ ),
607
+ })
608
+ }
609
+ writes.push({
610
+ path: lifecycleManifestPath(destination),
611
+ content: json(
612
+ manifestFor(
613
+ yield* readManifest(dirname(epic.path)),
614
+ { kind: "epic", id },
615
+ event(root, "archive", at, dirname(epic.path), destination),
616
+ ),
617
+ ),
618
+ })
619
+ if (!options.dryRun) {
620
+ removedWorktrees = yield* applyArchiveMutation(
621
+ root,
622
+ targets,
623
+ moves,
624
+ writes,
625
+ )
98
626
  }
99
- yield* fs.createDirectory(dirname(epicDestination))
100
- yield* fs.moveDirectory(dirname(epic.path), epicDestination)
101
- archivedPaths.push(epicDestination)
102
-
103
627
  return {
628
+ operation: "archive",
104
629
  kind: "epic",
105
630
  id,
106
- path: epicDestination,
107
- archivedPaths,
631
+ path: destination,
632
+ affectedPaths: moves.map((move) => move.to),
108
633
  removedWorktrees,
109
- } satisfies ArchiveResult
634
+ dryRun: options.dryRun === true,
635
+ at,
636
+ } satisfies LifecycleResult
110
637
  }),
111
638
 
112
- archiveTask: (id: string, startPath: string = process.cwd()) =>
639
+ archiveTask: (
640
+ id: string,
641
+ startPath: string = process.cwd(),
642
+ options: LifecycleOptions = {},
643
+ ) =>
113
644
  Effect.gen(function* () {
114
- const fs = yield* FileSystemService
115
645
  const workbase = yield* WorkbaseService
116
646
  const epics = yield* EpicService
117
647
  const tasks = yield* TaskService
118
648
  const worktrees = yield* WorktreeService
119
649
  const root = yield* workbase.discover(startPath)
120
650
  const task = yield* tasks.show(id, root)
121
- const destination = join(root, "archive", "tasks", id)
122
- yield* rejectExistingDestination(destination)
123
-
651
+ const destination = archivedTaskDirectory(root, id)
652
+ yield* rejectExistingDestination(destination, "Archive")
124
653
  let parentEpic: EpicRecord | undefined
654
+ let declaration: DependencyData | undefined
125
655
  if (task.data.epic) {
126
656
  parentEpic = yield* epics.show(task.data.epic, root)
657
+ declaration = parentEpic.data.tasks.find((child) => child.id === id)
658
+ if (!declaration) {
659
+ return yield* new ArchiveError({
660
+ message: `Epic '${task.data.epic}' does not declare task '${id}'`,
661
+ })
662
+ }
127
663
  const dependent = parentEpic.data.tasks.find((child) =>
128
664
  child.dependsOn?.includes(id),
129
665
  )
@@ -134,56 +670,85 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
134
670
  }
135
671
  }
136
672
 
137
- const removedWorktrees: string[] = []
673
+ let plannedWorktrees: string[] = []
674
+ const targets: WorktreeTarget[] = []
138
675
  if ("phases" in task.data) {
139
676
  for (const phase of task.data.phases) {
140
- removedWorktrees.push(
141
- ...(yield* worktrees.remove(id, phase.id, root)),
677
+ targets.push({ taskId: id, phaseId: phase.id })
678
+ plannedWorktrees.push(
679
+ ...(yield* worktrees.remove(id, phase.id, root, {
680
+ dryRun: true,
681
+ })),
142
682
  )
143
683
  }
144
684
  } else {
145
- removedWorktrees.push(
146
- ...(yield* worktrees.remove(id, undefined, root)),
685
+ targets.push({ taskId: id })
686
+ plannedWorktrees.push(
687
+ ...(yield* worktrees.remove(id, undefined, root, {
688
+ dryRun: true,
689
+ })),
147
690
  )
148
691
  }
149
-
692
+ const at = new Date().toISOString()
693
+ const writes: Write[] = []
150
694
  if (parentEpic) {
151
- const parsed = yield* parseFrontmatter(
152
- parentEpic.content,
153
- parentEpic.path,
154
- )
155
- yield* fs.writeFile(
156
- parentEpic.path,
157
- formatMarkdownDocument(
695
+ writes.push({
696
+ path: parentEpic.path,
697
+ content: yield* declarationContent(parentEpic, {
698
+ ...parentEpic.data,
699
+ tasks: parentEpic.data.tasks.filter((child) => child.id !== id),
700
+ }),
701
+ })
702
+ }
703
+ writes.push({
704
+ path: lifecycleManifestPath(destination),
705
+ content: json(
706
+ manifestFor(
707
+ yield* readManifest(dirname(task.path)),
158
708
  {
159
- ...parentEpic.data,
160
- tasks: parentEpic.data.tasks.filter(
161
- (child) => child.id !== id,
162
- ),
709
+ kind: "task",
710
+ id,
711
+ ...(task.data.epic && declaration
712
+ ? {
713
+ parent: {
714
+ kind: "epic" as const,
715
+ id: task.data.epic,
716
+ declaration,
717
+ },
718
+ }
719
+ : {}),
163
720
  },
164
- parsed.body,
721
+ event(root, "archive", at, dirname(task.path), destination),
165
722
  ),
723
+ ),
724
+ })
725
+ if (!options.dryRun) {
726
+ plannedWorktrees = yield* applyArchiveMutation(
727
+ root,
728
+ targets,
729
+ [{ from: dirname(task.path), to: destination }],
730
+ writes,
166
731
  )
167
732
  }
168
-
169
- yield* fs.createDirectory(dirname(destination))
170
- yield* fs.moveDirectory(dirname(task.path), destination)
171
733
  return {
734
+ operation: "archive",
172
735
  kind: "task",
173
736
  id,
174
737
  path: destination,
175
- archivedPaths: [destination],
176
- removedWorktrees,
177
- } satisfies ArchiveResult
738
+ affectedPaths: [destination],
739
+ removedWorktrees: plannedWorktrees,
740
+ dryRun: options.dryRun === true,
741
+ at,
742
+ } satisfies LifecycleResult
178
743
  }),
179
744
 
180
745
  archivePhase: (
181
746
  taskId: string,
182
747
  id: string,
183
748
  startPath: string = process.cwd(),
749
+ options: LifecycleOptions = {},
184
750
  ) =>
185
751
  Effect.gen(function* () {
186
- const fs = yield* FileSystemService
187
752
  const workbase = yield* WorkbaseService
188
753
  const tasks = yield* TaskService
189
754
  const phases = yield* PhaseService
@@ -196,6 +761,9 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
196
761
  })
197
762
  }
198
763
  const phase = yield* phases.show(taskId, id, root)
764
+ const declaration = task.data.phases.find(
765
+ (candidate) => candidate.id === id,
766
+ )!
199
767
  const dependent = task.data.phases.find((candidate) =>
200
768
  candidate.dependsOn?.includes(id),
201
769
  )
@@ -204,42 +772,354 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
204
772
  message: `Cannot archive phase '${id}'; phase '${dependent.id}' depends on it`,
205
773
  })
206
774
  }
207
-
208
- const destination = join(
209
- root,
210
- "archive",
211
- "tasks",
212
- taskId,
213
- "phases",
775
+ const destination = archivedPhaseDirectory(root, taskId, id)
776
+ yield* rejectExistingDestination(destination, "Archive")
777
+ let removedWorktrees = yield* worktrees.remove(taskId, id, root, {
778
+ dryRun: true,
779
+ })
780
+ const at = new Date().toISOString()
781
+ const content = yield* declarationContent(task, {
782
+ ...task.data,
783
+ phases: task.data.phases.filter((candidate) => candidate.id !== id),
784
+ })
785
+ const writes: Write[] = [
786
+ { path: task.path, content },
787
+ {
788
+ path: lifecycleManifestPath(destination),
789
+ content: json(
790
+ manifestFor(
791
+ yield* readManifest(dirname(phase.path)),
792
+ {
793
+ kind: "phase",
794
+ id,
795
+ taskId,
796
+ parent: { kind: "task", id: taskId, declaration },
797
+ },
798
+ event(root, "archive", at, dirname(phase.path), destination),
799
+ ),
800
+ ),
801
+ },
802
+ ]
803
+ if (!options.dryRun) {
804
+ removedWorktrees = yield* applyArchiveMutation(
805
+ root,
806
+ [{ taskId, phaseId: id }],
807
+ [{ from: dirname(phase.path), to: destination }],
808
+ writes,
809
+ )
810
+ }
811
+ return {
812
+ operation: "archive",
813
+ kind: "phase",
214
814
  id,
815
+ taskId,
816
+ path: destination,
817
+ affectedPaths: [destination],
818
+ removedWorktrees,
819
+ dryRun: options.dryRun === true,
820
+ at,
821
+ } satisfies LifecycleResult
822
+ }),
823
+
824
+ restoreEpic: (
825
+ id: string,
826
+ startPath: string = process.cwd(),
827
+ options: LifecycleOptions = {},
828
+ ) =>
829
+ Effect.gen(function* () {
830
+ const workbase = yield* WorkbaseService
831
+ const epics = yield* EpicService
832
+ const service = yield* ArchiveService
833
+ const root = yield* workbase.discover(startPath)
834
+ const epic = yield* service.show("epic", id, undefined, root)
835
+ const epicData = epic.data as EpicData
836
+ const destination = join(root, "epics", id)
837
+ yield* rejectExistingDestination(destination, "Restore")
838
+ const activeEpics = yield* epics.list(root)
839
+ const tasks: ArchivedRecord[] = []
840
+ for (const child of epicData.tasks) {
841
+ const conflictingEpic = activeEpics.find((candidate) =>
842
+ candidate.data.tasks.some(
843
+ (declaration) => declaration.id === child.id,
844
+ ),
845
+ )
846
+ if (conflictingEpic) {
847
+ return yield* new ArchiveError({
848
+ message: `Active epic '${conflictingEpic.id}' already declares archived task '${child.id}'`,
849
+ })
850
+ }
851
+ const task = yield* service.show("task", child.id, undefined, root)
852
+ if ((task.data as TaskData).epic !== id) {
853
+ return yield* new ArchiveError({
854
+ message: `Archived task '${child.id}' does not backlink to epic '${id}'`,
855
+ })
856
+ }
857
+ if (
858
+ task.provenance?.parent &&
859
+ (task.provenance.parent.kind !== "epic" ||
860
+ task.provenance.parent.id !== id ||
861
+ task.provenance.parent.declaration.id !== child.id)
862
+ ) {
863
+ return yield* new ArchiveError({
864
+ message: `Archived task '${child.id}' has conflicting epic provenance`,
865
+ })
866
+ }
867
+ yield* rejectExistingDestination(
868
+ join(root, "tasks", child.id),
869
+ "Restore",
870
+ )
871
+ tasks.push(task)
872
+ }
873
+ const dependencyIssue = validateDependencies(
874
+ epicData.tasks,
875
+ `epic '${id}'`,
215
876
  )
216
- yield* rejectExistingDestination(destination)
217
- const removedWorktrees = yield* worktrees.remove(taskId, id, root)
877
+ if (dependencyIssue)
878
+ return yield* new ArchiveError({ message: dependencyIssue })
879
+ const at = new Date().toISOString()
880
+ const moves: Move[] = tasks.map((task) => ({
881
+ from: task.path,
882
+ to: join(root, "tasks", task.id),
883
+ }))
884
+ moves.push({ from: epic.path, to: destination })
885
+ if (!options.dryRun) {
886
+ const writes: Write[] = []
887
+ for (const record of [...tasks, epic]) {
888
+ const target =
889
+ record.kind === "epic"
890
+ ? destination
891
+ : join(root, "tasks", record.id)
892
+ const manifest = manifestFor(
893
+ record.provenance,
894
+ {
895
+ kind: record.kind,
896
+ id: record.id,
897
+ ...(record.provenance?.parent
898
+ ? { parent: record.provenance.parent }
899
+ : {}),
900
+ },
901
+ event(root, "restore", at, record.path, target),
902
+ )
903
+ writes.push({
904
+ path: lifecycleManifestPath(target),
905
+ content: json(manifest),
906
+ })
907
+ }
908
+ yield* withLifecycleLock(root, applyMutation(moves, writes))
909
+ }
910
+ return {
911
+ operation: "restore",
912
+ kind: "epic",
913
+ id,
914
+ path: destination,
915
+ affectedPaths: moves.map((move) => move.to),
916
+ removedWorktrees: [],
917
+ dryRun: options.dryRun === true,
918
+ at,
919
+ } satisfies LifecycleResult
920
+ }),
218
921
 
219
- const parsed = yield* parseFrontmatter(task.content, task.path)
220
- yield* fs.writeFile(
221
- task.path,
222
- formatMarkdownDocument(
922
+ restoreTask: (
923
+ id: string,
924
+ startPath: string = process.cwd(),
925
+ options: LifecycleOptions = {},
926
+ ) =>
927
+ Effect.gen(function* () {
928
+ const workbase = yield* WorkbaseService
929
+ const epics = yield* EpicService
930
+ const service = yield* ArchiveService
931
+ const root = yield* workbase.discover(startPath)
932
+ const task = yield* service.show("task", id, undefined, root)
933
+ const taskData = task.data as TaskData
934
+ const destination = join(root, "tasks", id)
935
+ yield* rejectExistingDestination(destination, "Restore")
936
+ const activeEpics = yield* epics.list(root)
937
+ const conflictingEpic = activeEpics.find(
938
+ (candidate) =>
939
+ candidate.id !== taskData.epic &&
940
+ candidate.data.tasks.some((declaration) => declaration.id === id),
941
+ )
942
+ if (conflictingEpic) {
943
+ return yield* new ArchiveError({
944
+ message: `Active epic '${conflictingEpic.id}' already declares archived task '${id}'`,
945
+ })
946
+ }
947
+ if (!taskData.epic && task.provenance?.parent) {
948
+ return yield* new ArchiveError({
949
+ message: `Archived task '${id}' is missing its epic backlink`,
950
+ })
951
+ }
952
+ let parent: EpicRecord | undefined
953
+ let declaration: DependencyData | undefined
954
+ if (taskData.epic) {
955
+ parent = yield* epics.show(taskData.epic, root)
956
+ if (
957
+ task.provenance?.parent &&
958
+ (task.provenance.parent.kind !== "epic" ||
959
+ task.provenance.parent.id !== taskData.epic)
960
+ ) {
961
+ return yield* new ArchiveError({
962
+ message: `Archived task '${id}' has conflicting epic backlink provenance`,
963
+ })
964
+ }
965
+ declaration = task.provenance?.parent?.declaration ?? { id }
966
+ if (declaration.id !== id) {
967
+ return yield* new ArchiveError({
968
+ message: `Archived task '${id}' has a conflicting parent declaration ID '${declaration.id}'`,
969
+ })
970
+ }
971
+ if (parent.data.tasks.some((child) => child.id === id)) {
972
+ return yield* new ArchiveError({
973
+ message: `Epic '${taskData.epic}' already declares task '${id}'`,
974
+ })
975
+ }
976
+ const nodes = [...parent.data.tasks, declaration]
977
+ const dependencyIssue = validateDependencies(
978
+ nodes,
979
+ `epic '${taskData.epic}'`,
980
+ )
981
+ if (dependencyIssue)
982
+ return yield* new ArchiveError({ message: dependencyIssue })
983
+ }
984
+ const at = new Date().toISOString()
985
+ if (!options.dryRun) {
986
+ const writes: Write[] = [
223
987
  {
224
- ...task.data,
225
- phases: task.data.phases.filter(
226
- (candidate) => candidate.id !== id,
988
+ path: lifecycleManifestPath(destination),
989
+ content: json(
990
+ manifestFor(
991
+ task.provenance,
992
+ {
993
+ kind: "task",
994
+ id,
995
+ ...(task.provenance?.parent
996
+ ? { parent: task.provenance.parent }
997
+ : {}),
998
+ },
999
+ event(root, "restore", at, task.path, destination),
1000
+ ),
227
1001
  ),
228
1002
  },
229
- parsed.body,
230
- ),
231
- )
232
- yield* fs.createDirectory(dirname(destination))
233
- yield* fs.moveDirectory(dirname(phase.path), destination)
1003
+ ]
1004
+ if (parent && declaration) {
1005
+ writes.push({
1006
+ path: parent.path,
1007
+ content: yield* declarationContent(parent, {
1008
+ ...parent.data,
1009
+ tasks: [...parent.data.tasks, declaration],
1010
+ }),
1011
+ })
1012
+ }
1013
+ yield* withLifecycleLock(
1014
+ root,
1015
+ applyMutation([{ from: task.path, to: destination }], writes),
1016
+ )
1017
+ }
1018
+ return {
1019
+ operation: "restore",
1020
+ kind: "task",
1021
+ id,
1022
+ path: destination,
1023
+ affectedPaths: [destination],
1024
+ removedWorktrees: [],
1025
+ dryRun: options.dryRun === true,
1026
+ at,
1027
+ } satisfies LifecycleResult
1028
+ }),
234
1029
 
1030
+ restorePhase: (
1031
+ taskId: string,
1032
+ id: string,
1033
+ startPath: string = process.cwd(),
1034
+ options: LifecycleOptions = {},
1035
+ ) =>
1036
+ Effect.gen(function* () {
1037
+ const workbase = yield* WorkbaseService
1038
+ const tasks = yield* TaskService
1039
+ const service = yield* ArchiveService
1040
+ const root = yield* workbase.discover(startPath)
1041
+ const task = yield* tasks.show(taskId, root)
1042
+ if (!("phases" in task.data)) {
1043
+ return yield* new ArchiveError({
1044
+ message: `Task '${taskId}' is single-phase and cannot receive a phase`,
1045
+ })
1046
+ }
1047
+ const phase = yield* service.show("phase", id, taskId, root)
1048
+ if (
1049
+ phase.provenance?.parent &&
1050
+ (phase.provenance.parent.kind !== "task" ||
1051
+ phase.provenance.parent.id !== taskId)
1052
+ ) {
1053
+ return yield* new ArchiveError({
1054
+ message: `Archived phase '${id}' has conflicting task backlink provenance`,
1055
+ })
1056
+ }
1057
+ if (task.data.phases.some((candidate) => candidate.id === id)) {
1058
+ return yield* new ArchiveError({
1059
+ message: `Task '${taskId}' already declares phase '${id}'`,
1060
+ })
1061
+ }
1062
+ const destination = join(root, "tasks", taskId, "phases", id)
1063
+ yield* rejectExistingDestination(destination, "Restore")
1064
+ const declaration = phase.provenance?.parent?.declaration ?? { id }
1065
+ if (declaration.id !== id) {
1066
+ return yield* new ArchiveError({
1067
+ message: `Archived phase '${id}' has a conflicting parent declaration ID '${declaration.id}'`,
1068
+ })
1069
+ }
1070
+ const nodes = [...task.data.phases, declaration]
1071
+ const dependencyIssue = validateDependencies(
1072
+ nodes,
1073
+ `task '${taskId}'`,
1074
+ )
1075
+ if (dependencyIssue)
1076
+ return yield* new ArchiveError({ message: dependencyIssue })
1077
+ const at = new Date().toISOString()
1078
+ if (!options.dryRun) {
1079
+ yield* withLifecycleLock(
1080
+ root,
1081
+ applyMutation(
1082
+ [{ from: phase.path, to: destination }],
1083
+ [
1084
+ {
1085
+ path: task.path,
1086
+ content: yield* declarationContent(task, {
1087
+ ...task.data,
1088
+ phases: [...task.data.phases, declaration],
1089
+ }),
1090
+ },
1091
+ {
1092
+ path: lifecycleManifestPath(destination),
1093
+ content: json(
1094
+ manifestFor(
1095
+ phase.provenance,
1096
+ {
1097
+ kind: "phase",
1098
+ id,
1099
+ taskId,
1100
+ ...(phase.provenance?.parent
1101
+ ? { parent: phase.provenance.parent }
1102
+ : {}),
1103
+ },
1104
+ event(root, "restore", at, phase.path, destination),
1105
+ ),
1106
+ ),
1107
+ },
1108
+ ],
1109
+ ),
1110
+ )
1111
+ }
235
1112
  return {
1113
+ operation: "restore",
236
1114
  kind: "phase",
237
1115
  id,
238
1116
  taskId,
239
1117
  path: destination,
240
- archivedPaths: [destination],
241
- removedWorktrees,
242
- } satisfies ArchiveResult
1118
+ affectedPaths: [destination],
1119
+ removedWorktrees: [],
1120
+ dryRun: options.dryRun === true,
1121
+ at,
1122
+ } satisfies LifecycleResult
243
1123
  }),
244
1124
  }),
245
1125
  },