@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,52 +1,541 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
+
import { Data, Effect, Either, Layer } from "effect"
|
|
3
|
+
import { lstat, 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
|
-
import { PhaseService } from "./PhaseService"
|
|
7
|
+
import { PhaseService, type PhaseRecord } from "./PhaseService"
|
|
6
8
|
import { TaskService } from "./TaskService"
|
|
7
9
|
import { WorkbaseService } from "./WorkbaseService"
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
WorktreeService,
|
|
12
|
+
type WorktreeRemovalSnapshot,
|
|
13
|
+
} from "./WorktreeService"
|
|
9
14
|
import {
|
|
10
15
|
formatMarkdownDocument,
|
|
11
16
|
parseFrontmatter,
|
|
12
17
|
} from "../workbase/frontmatter"
|
|
13
|
-
import
|
|
18
|
+
import {
|
|
19
|
+
EpicFrontmatter,
|
|
20
|
+
Dependency,
|
|
21
|
+
PhaseFrontmatter,
|
|
22
|
+
TaskFrontmatter,
|
|
23
|
+
type Dependency as DependencyData,
|
|
24
|
+
type EpicFrontmatter as EpicData,
|
|
25
|
+
type PhaseFrontmatter as PhaseData,
|
|
26
|
+
type TaskFrontmatter as TaskData,
|
|
27
|
+
} from "../workbase/schemas"
|
|
28
|
+
import { validateDependencies } from "../workbase/dependency-graph"
|
|
29
|
+
import {
|
|
30
|
+
archivedEpicDirectory,
|
|
31
|
+
archivedPhaseDirectory,
|
|
32
|
+
archivedTaskDirectory,
|
|
33
|
+
lifecycleManifestPath,
|
|
34
|
+
} from "../workbase/archive"
|
|
35
|
+
import {
|
|
36
|
+
directoryMoveStep,
|
|
37
|
+
documentWriteStep,
|
|
38
|
+
runLifecycleTransaction,
|
|
39
|
+
type TransactionStep,
|
|
40
|
+
} from "./LifecycleTransaction"
|
|
41
|
+
import { withWorktreeLocks } from "./WorktreeLock"
|
|
14
42
|
|
|
15
43
|
class ArchiveError extends Data.TaggedError("ArchiveError")<{
|
|
16
44
|
readonly message: string
|
|
45
|
+
readonly cause?: unknown
|
|
17
46
|
}> {}
|
|
18
47
|
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
export type ArchiveKind = "epic" | "task" | "phase"
|
|
49
|
+
|
|
50
|
+
const LifecycleEventSchema = Schema.Struct({
|
|
51
|
+
operation: Schema.Literal("archive", "restore"),
|
|
52
|
+
at: Schema.String,
|
|
53
|
+
from: Schema.String,
|
|
54
|
+
to: Schema.String,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const LifecycleManifestSchema = Schema.Struct({
|
|
58
|
+
version: Schema.Literal(1),
|
|
59
|
+
kind: Schema.Literal("epic", "task", "phase"),
|
|
60
|
+
id: Schema.String,
|
|
61
|
+
taskId: Schema.optional(Schema.String),
|
|
62
|
+
parent: Schema.optional(
|
|
63
|
+
Schema.Struct({
|
|
64
|
+
kind: Schema.Literal("epic", "task"),
|
|
65
|
+
id: Schema.String,
|
|
66
|
+
declaration: Dependency,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
history: Schema.Array(LifecycleEventSchema),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
type LifecycleEvent = Schema.Schema.Type<typeof LifecycleEventSchema>
|
|
73
|
+
type LifecycleManifest = Schema.Schema.Type<typeof LifecycleManifestSchema>
|
|
74
|
+
|
|
75
|
+
interface ArchivedRecord {
|
|
76
|
+
readonly kind: ArchiveKind
|
|
21
77
|
readonly id: string
|
|
22
78
|
readonly taskId?: string
|
|
23
79
|
readonly path: string
|
|
24
|
-
readonly
|
|
80
|
+
readonly documentPath: string
|
|
81
|
+
readonly content: string
|
|
82
|
+
readonly data: EpicData | TaskData | PhaseData
|
|
83
|
+
readonly provenance?: LifecycleManifest
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface LifecycleResult {
|
|
87
|
+
readonly operation: "archive" | "restore"
|
|
88
|
+
readonly kind: ArchiveKind
|
|
89
|
+
readonly id: string
|
|
90
|
+
readonly taskId?: string
|
|
91
|
+
readonly path: string
|
|
92
|
+
readonly affectedPaths: readonly string[]
|
|
25
93
|
readonly removedWorktrees: readonly string[]
|
|
94
|
+
readonly dryRun: boolean
|
|
95
|
+
readonly at: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface LifecycleOptions {
|
|
99
|
+
readonly dryRun?: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ArchiveFilters {
|
|
103
|
+
readonly kinds?: readonly string[]
|
|
104
|
+
readonly statuses?: readonly string[]
|
|
105
|
+
readonly repositories?: readonly string[]
|
|
26
106
|
}
|
|
27
107
|
|
|
28
108
|
interface TaskRecord {
|
|
29
109
|
readonly id: string
|
|
30
110
|
readonly path: string
|
|
31
111
|
readonly content: string
|
|
112
|
+
readonly revision: string
|
|
32
113
|
readonly data: TaskData
|
|
33
114
|
}
|
|
34
115
|
|
|
35
|
-
|
|
116
|
+
interface Move {
|
|
117
|
+
readonly from: string
|
|
118
|
+
readonly to: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Write {
|
|
122
|
+
readonly path: string
|
|
123
|
+
readonly content: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
127
|
+
schema: S,
|
|
128
|
+
input: unknown,
|
|
129
|
+
label: string,
|
|
130
|
+
) => {
|
|
131
|
+
const result = Schema.decodeUnknownEither(schema, {
|
|
132
|
+
errors: "all",
|
|
133
|
+
onExcessProperty: "error",
|
|
134
|
+
})(input)
|
|
135
|
+
return Either.isLeft(result)
|
|
136
|
+
? Effect.fail(
|
|
137
|
+
new ArchiveError({
|
|
138
|
+
message: `Invalid archived ${label}: ${TreeFormatter.formatErrorSync(result.left)}`,
|
|
139
|
+
}),
|
|
140
|
+
)
|
|
141
|
+
: Effect.succeed(result.right)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const readManifest = (directory: string) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const fs = yield* FileSystemService
|
|
147
|
+
const path = lifecycleManifestPath(directory)
|
|
148
|
+
if (!(yield* fs.exists(path))) return undefined
|
|
149
|
+
const content = yield* fs.readFile(path)
|
|
150
|
+
const input = yield* Effect.try({
|
|
151
|
+
try: () => JSON.parse(content) as unknown,
|
|
152
|
+
catch: (cause) =>
|
|
153
|
+
new ArchiveError({
|
|
154
|
+
message: `Invalid lifecycle provenance: ${path}`,
|
|
155
|
+
cause,
|
|
156
|
+
}),
|
|
157
|
+
})
|
|
158
|
+
const decoded = Schema.decodeUnknownEither(LifecycleManifestSchema, {
|
|
159
|
+
errors: "all",
|
|
160
|
+
onExcessProperty: "error",
|
|
161
|
+
})(input)
|
|
162
|
+
if (Either.isLeft(decoded)) {
|
|
163
|
+
return yield* new ArchiveError({
|
|
164
|
+
message: `Invalid lifecycle provenance ${path}: ${TreeFormatter.formatErrorSync(decoded.left)}`,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
return decoded.right
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const manifestFor = (
|
|
171
|
+
existing: LifecycleManifest | undefined,
|
|
172
|
+
entity: Omit<LifecycleManifest, "version" | "history">,
|
|
173
|
+
event: LifecycleEvent,
|
|
174
|
+
): LifecycleManifest => ({
|
|
175
|
+
version: 1,
|
|
176
|
+
...entity,
|
|
177
|
+
history: [...(existing?.history ?? []), event],
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const json = (value: unknown) => JSON.stringify(value, null, 2) + "\n"
|
|
181
|
+
|
|
182
|
+
const withLifecycleLock = <A, E, R>(
|
|
183
|
+
root: string,
|
|
184
|
+
operation: Effect.Effect<A, E, R>,
|
|
185
|
+
) => {
|
|
186
|
+
const lockPath = join(root, ".agency-archive.lock")
|
|
187
|
+
return Effect.acquireUseRelease(
|
|
188
|
+
Effect.tryPromise({
|
|
189
|
+
try: () => open(lockPath, "wx"),
|
|
190
|
+
catch: (cause) =>
|
|
191
|
+
new ArchiveError({
|
|
192
|
+
message:
|
|
193
|
+
"Another archive or restore operation is in progress; wait and retry",
|
|
194
|
+
cause,
|
|
195
|
+
}),
|
|
196
|
+
}),
|
|
197
|
+
() => operation,
|
|
198
|
+
(lock) =>
|
|
199
|
+
Effect.promise(async () => {
|
|
200
|
+
await lock.close().catch(() => undefined)
|
|
201
|
+
await rm(lockPath, { force: true }).catch(() => undefined)
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const applyMutation = (moves: readonly Move[], writes: readonly Write[]) =>
|
|
207
|
+
Effect.tryPromise({
|
|
208
|
+
try: async () => {
|
|
209
|
+
const completedMoves: Move[] = []
|
|
210
|
+
const completedWrites: {
|
|
211
|
+
path: string
|
|
212
|
+
existed: boolean
|
|
213
|
+
content?: string
|
|
214
|
+
}[] = []
|
|
215
|
+
try {
|
|
216
|
+
for (const move of moves) {
|
|
217
|
+
await mkdir(dirname(move.to), { recursive: true })
|
|
218
|
+
await rename(move.from, move.to)
|
|
219
|
+
completedMoves.push(move)
|
|
220
|
+
}
|
|
221
|
+
for (const write of writes) {
|
|
222
|
+
const file = Bun.file(write.path)
|
|
223
|
+
const existed = await file.exists()
|
|
224
|
+
completedWrites.push({
|
|
225
|
+
path: write.path,
|
|
226
|
+
existed,
|
|
227
|
+
...(existed ? { content: await file.text() } : {}),
|
|
228
|
+
})
|
|
229
|
+
await Bun.write(write.path, write.content)
|
|
230
|
+
}
|
|
231
|
+
} catch (cause) {
|
|
232
|
+
let rollbackCause: unknown
|
|
233
|
+
for (const write of [...completedWrites].reverse()) {
|
|
234
|
+
try {
|
|
235
|
+
if (write.existed) await Bun.write(write.path, write.content!)
|
|
236
|
+
else await rm(write.path, { force: true })
|
|
237
|
+
} catch (error) {
|
|
238
|
+
rollbackCause ??= error
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const move of [...completedMoves].reverse()) {
|
|
242
|
+
try {
|
|
243
|
+
await rename(move.to, move.from)
|
|
244
|
+
} catch (error) {
|
|
245
|
+
rollbackCause ??= error
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (rollbackCause) {
|
|
249
|
+
throw new ArchiveError({
|
|
250
|
+
message:
|
|
251
|
+
"Archive lifecycle rollback failed; manual recovery is required",
|
|
252
|
+
cause: new AggregateError([cause, rollbackCause]),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
throw cause
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
catch: (cause) =>
|
|
259
|
+
cause instanceof ArchiveError
|
|
260
|
+
? cause
|
|
261
|
+
: new ArchiveError({
|
|
262
|
+
message:
|
|
263
|
+
"Archive lifecycle operation failed; changes were rolled back",
|
|
264
|
+
cause,
|
|
265
|
+
}),
|
|
266
|
+
})
|
|
267
|
+
|
|
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
|
+
],
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const rejectExistingDestination = (
|
|
327
|
+
path: string,
|
|
328
|
+
operation: "Archive" | "Restore",
|
|
329
|
+
) =>
|
|
36
330
|
Effect.gen(function* () {
|
|
37
331
|
const fs = yield* FileSystemService
|
|
38
332
|
if (yield* fs.exists(path)) {
|
|
39
333
|
return yield* new ArchiveError({
|
|
40
|
-
message:
|
|
334
|
+
message: `${operation} destination already exists: ${path}`,
|
|
41
335
|
})
|
|
42
336
|
}
|
|
43
337
|
})
|
|
44
338
|
|
|
339
|
+
const event = (
|
|
340
|
+
root: string,
|
|
341
|
+
operation: LifecycleEvent["operation"],
|
|
342
|
+
at: string,
|
|
343
|
+
from: string,
|
|
344
|
+
to: string,
|
|
345
|
+
): LifecycleEvent => ({
|
|
346
|
+
operation,
|
|
347
|
+
at,
|
|
348
|
+
from: relative(root, from),
|
|
349
|
+
to: relative(root, to),
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const declarationContent = (
|
|
353
|
+
record: { readonly content: string; readonly path: string },
|
|
354
|
+
data: EpicData | TaskData,
|
|
355
|
+
) =>
|
|
356
|
+
parseFrontmatter(record.content, record.path).pipe(
|
|
357
|
+
Effect.map((parsed) => formatMarkdownDocument(data, parsed.body)),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
const repositoriesFor = (record: ArchivedRecord) => {
|
|
361
|
+
if (record.kind === "epic") {
|
|
362
|
+
return (record.data as EpicData).repos.map((reference) => reference.repo)
|
|
363
|
+
}
|
|
364
|
+
if ("repo" in record.data) {
|
|
365
|
+
return [
|
|
366
|
+
record.data.repo,
|
|
367
|
+
...(record.data.repos ?? []).map((reference) => reference.repo),
|
|
368
|
+
]
|
|
369
|
+
}
|
|
370
|
+
return []
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const statusFor = (record: ArchivedRecord) =>
|
|
374
|
+
"status" in record.data ? record.data.status : undefined
|
|
375
|
+
|
|
45
376
|
export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
46
377
|
"ArchiveService",
|
|
47
378
|
{
|
|
48
379
|
sync: () => ({
|
|
49
|
-
|
|
380
|
+
list: (filters: ArchiveFilters = {}, startPath: string = process.cwd()) =>
|
|
381
|
+
Effect.gen(function* () {
|
|
382
|
+
const fs = yield* FileSystemService
|
|
383
|
+
const workbase = yield* WorkbaseService
|
|
384
|
+
const root = yield* workbase.discover(startPath)
|
|
385
|
+
const kinds = filters.kinds?.length
|
|
386
|
+
? new Set(filters.kinds)
|
|
387
|
+
: new Set<ArchiveKind>(["epic", "task", "phase"])
|
|
388
|
+
for (const kind of kinds) {
|
|
389
|
+
if (
|
|
390
|
+
!(["epic", "task", "phase"] as const).includes(
|
|
391
|
+
kind as ArchiveKind,
|
|
392
|
+
)
|
|
393
|
+
) {
|
|
394
|
+
return yield* new ArchiveError({
|
|
395
|
+
message: `Unknown archive kind '${kind}'`,
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const records: ArchivedRecord[] = []
|
|
401
|
+
const readRecord = (
|
|
402
|
+
kind: ArchiveKind,
|
|
403
|
+
id: string,
|
|
404
|
+
directory: string,
|
|
405
|
+
documentName: string,
|
|
406
|
+
schema: Schema.Schema.AnyNoContext,
|
|
407
|
+
taskId?: string,
|
|
408
|
+
) =>
|
|
409
|
+
Effect.gen(function* () {
|
|
410
|
+
const documentPath = join(directory, documentName)
|
|
411
|
+
if (!(yield* fs.exists(documentPath))) return
|
|
412
|
+
const content = yield* fs.readFile(documentPath)
|
|
413
|
+
const parsed = yield* parseFrontmatter(content, documentPath)
|
|
414
|
+
const data = yield* decode(schema, parsed.data, `${kind} '${id}'`)
|
|
415
|
+
const provenance = yield* readManifest(directory)
|
|
416
|
+
if (
|
|
417
|
+
provenance &&
|
|
418
|
+
(provenance.kind !== kind ||
|
|
419
|
+
provenance.id !== id ||
|
|
420
|
+
(kind === "phase" && provenance.taskId !== taskId))
|
|
421
|
+
) {
|
|
422
|
+
return yield* new ArchiveError({
|
|
423
|
+
message: `Lifecycle provenance does not match archived ${kind} '${id}'`,
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
records.push({
|
|
427
|
+
kind,
|
|
428
|
+
id,
|
|
429
|
+
...(taskId ? { taskId } : {}),
|
|
430
|
+
path: directory,
|
|
431
|
+
documentPath,
|
|
432
|
+
content,
|
|
433
|
+
data: data as EpicData | TaskData | PhaseData,
|
|
434
|
+
...(provenance ? { provenance } : {}),
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
if (kinds.has("epic")) {
|
|
439
|
+
const directory = join(root, "archive", "epics")
|
|
440
|
+
if (yield* fs.isDirectory(directory)) {
|
|
441
|
+
for (const entry of yield* fs.readDirectory(directory)) {
|
|
442
|
+
if (entry.isDirectory)
|
|
443
|
+
yield* readRecord(
|
|
444
|
+
"epic",
|
|
445
|
+
entry.name,
|
|
446
|
+
join(directory, entry.name),
|
|
447
|
+
"EPIC.md",
|
|
448
|
+
EpicFrontmatter,
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const tasksDirectory = join(root, "archive", "tasks")
|
|
455
|
+
if (yield* fs.isDirectory(tasksDirectory)) {
|
|
456
|
+
for (const taskEntry of yield* fs.readDirectory(tasksDirectory)) {
|
|
457
|
+
if (!taskEntry.isDirectory) continue
|
|
458
|
+
const taskDirectory = join(tasksDirectory, taskEntry.name)
|
|
459
|
+
if (kinds.has("task")) {
|
|
460
|
+
yield* readRecord(
|
|
461
|
+
"task",
|
|
462
|
+
taskEntry.name,
|
|
463
|
+
taskDirectory,
|
|
464
|
+
"TASK.md",
|
|
465
|
+
TaskFrontmatter,
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
if (!kinds.has("phase")) continue
|
|
469
|
+
const phasesDirectory = join(taskDirectory, "phases")
|
|
470
|
+
if (!(yield* fs.isDirectory(phasesDirectory))) continue
|
|
471
|
+
for (const phaseEntry of yield* fs.readDirectory(
|
|
472
|
+
phasesDirectory,
|
|
473
|
+
)) {
|
|
474
|
+
if (phaseEntry.isDirectory)
|
|
475
|
+
yield* readRecord(
|
|
476
|
+
"phase",
|
|
477
|
+
phaseEntry.name,
|
|
478
|
+
join(phasesDirectory, phaseEntry.name),
|
|
479
|
+
"PHASE.md",
|
|
480
|
+
PhaseFrontmatter,
|
|
481
|
+
taskEntry.name,
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return records
|
|
488
|
+
.filter(
|
|
489
|
+
(record) =>
|
|
490
|
+
!filters.statuses?.length ||
|
|
491
|
+
filters.statuses.includes(statusFor(record) ?? ""),
|
|
492
|
+
)
|
|
493
|
+
.filter(
|
|
494
|
+
(record) =>
|
|
495
|
+
!filters.repositories?.length ||
|
|
496
|
+
filters.repositories.some((repository) =>
|
|
497
|
+
repositoriesFor(record).includes(repository),
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
.sort((a, b) =>
|
|
501
|
+
`${a.kind}:${a.taskId ?? ""}:${a.id}`.localeCompare(
|
|
502
|
+
`${b.kind}:${b.taskId ?? ""}:${b.id}`,
|
|
503
|
+
),
|
|
504
|
+
)
|
|
505
|
+
}),
|
|
506
|
+
|
|
507
|
+
show: (
|
|
508
|
+
kind: ArchiveKind,
|
|
509
|
+
id: string,
|
|
510
|
+
taskId: string | undefined,
|
|
511
|
+
startPath: string = process.cwd(),
|
|
512
|
+
) =>
|
|
513
|
+
Effect.gen(function* () {
|
|
514
|
+
const service = yield* ArchiveService
|
|
515
|
+
const record = (yield* service.list(
|
|
516
|
+
{ kinds: [kind] },
|
|
517
|
+
startPath,
|
|
518
|
+
)).find(
|
|
519
|
+
(candidate) =>
|
|
520
|
+
candidate.id === id &&
|
|
521
|
+
(kind !== "phase" || candidate.taskId === taskId),
|
|
522
|
+
)
|
|
523
|
+
if (!record) {
|
|
524
|
+
return yield* new ArchiveError({
|
|
525
|
+
message:
|
|
526
|
+
kind === "phase"
|
|
527
|
+
? `Archived phase '${id}' does not exist on task '${taskId}'`
|
|
528
|
+
: `Archived ${kind} '${id}' does not exist`,
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
return record
|
|
532
|
+
}),
|
|
533
|
+
|
|
534
|
+
archiveEpic: (
|
|
535
|
+
id: string,
|
|
536
|
+
startPath: string = process.cwd(),
|
|
537
|
+
options: LifecycleOptions = {},
|
|
538
|
+
) =>
|
|
50
539
|
Effect.gen(function* () {
|
|
51
540
|
const fs = yield* FileSystemService
|
|
52
541
|
const workbase = yield* WorkbaseService
|
|
@@ -65,51 +554,168 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
65
554
|
}
|
|
66
555
|
taskRecords.push(task)
|
|
67
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
|
+
}
|
|
68
566
|
|
|
69
|
-
const
|
|
70
|
-
yield* rejectExistingDestination(
|
|
567
|
+
const destination = archivedEpicDirectory(root, id)
|
|
568
|
+
yield* rejectExistingDestination(destination, "Archive")
|
|
71
569
|
for (const task of taskRecords) {
|
|
72
570
|
yield* rejectExistingDestination(
|
|
73
|
-
|
|
571
|
+
archivedTaskDirectory(root, task.id),
|
|
572
|
+
"Archive",
|
|
74
573
|
)
|
|
75
574
|
}
|
|
76
575
|
|
|
77
|
-
const
|
|
576
|
+
const executionUnits: { taskId: string; phaseId?: string }[] = []
|
|
577
|
+
const phaseRecords: PhaseRecord[] = []
|
|
78
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
|
+
}
|
|
79
584
|
if ("phases" in task.data) {
|
|
80
585
|
for (const phase of task.data.phases) {
|
|
81
|
-
|
|
82
|
-
|
|
586
|
+
const record = yield* (yield* PhaseService).show(
|
|
587
|
+
task.id,
|
|
588
|
+
phase.id,
|
|
589
|
+
root,
|
|
83
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 })
|
|
84
598
|
}
|
|
85
599
|
} else {
|
|
86
|
-
|
|
87
|
-
...(yield* worktrees.remove(task.id, undefined, root)),
|
|
88
|
-
)
|
|
600
|
+
executionUnits.push({ taskId: task.id })
|
|
89
601
|
}
|
|
90
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
|
+
}
|
|
91
611
|
|
|
92
|
-
const
|
|
612
|
+
const at = new Date().toISOString()
|
|
613
|
+
const moves: Move[] = taskRecords.map((task) => ({
|
|
614
|
+
from: dirname(task.path),
|
|
615
|
+
to: archivedTaskDirectory(root, task.id),
|
|
616
|
+
}))
|
|
617
|
+
moves.push({ from: dirname(epic.path), to: destination })
|
|
618
|
+
const writes: (Write & { create?: boolean })[] = []
|
|
93
619
|
for (const task of taskRecords) {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
620
|
+
const target = archivedTaskDirectory(root, task.id)
|
|
621
|
+
const manifestPath = lifecycleManifestPath(dirname(task.path))
|
|
622
|
+
const declaration = epic.data.tasks.find(
|
|
623
|
+
(child) => child.id === task.id,
|
|
624
|
+
)!
|
|
625
|
+
writes.push({
|
|
626
|
+
path: manifestPath,
|
|
627
|
+
create: !(yield* fs.exists(manifestPath)),
|
|
628
|
+
content: json(
|
|
629
|
+
manifestFor(
|
|
630
|
+
yield* readManifest(dirname(task.path)),
|
|
631
|
+
{
|
|
632
|
+
kind: "task",
|
|
633
|
+
id: task.id,
|
|
634
|
+
parent: { kind: "epic", id, declaration },
|
|
635
|
+
},
|
|
636
|
+
event(root, "archive", at, dirname(task.path), target),
|
|
637
|
+
),
|
|
638
|
+
),
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
const epicManifestPath = lifecycleManifestPath(dirname(epic.path))
|
|
642
|
+
writes.push({
|
|
643
|
+
path: epicManifestPath,
|
|
644
|
+
create: !(yield* fs.exists(epicManifestPath)),
|
|
645
|
+
content: json(
|
|
646
|
+
manifestFor(
|
|
647
|
+
yield* readManifest(dirname(epic.path)),
|
|
648
|
+
{ kind: "epic", id },
|
|
649
|
+
event(root, "archive", at, dirname(epic.path), destination),
|
|
650
|
+
),
|
|
651
|
+
),
|
|
652
|
+
})
|
|
653
|
+
if (!options.dryRun) {
|
|
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(
|
|
680
|
+
root,
|
|
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
|
+
),
|
|
700
|
+
)
|
|
98
701
|
}
|
|
99
|
-
yield* fs.createDirectory(dirname(epicDestination))
|
|
100
|
-
yield* fs.moveDirectory(dirname(epic.path), epicDestination)
|
|
101
|
-
archivedPaths.push(epicDestination)
|
|
102
|
-
|
|
103
702
|
return {
|
|
703
|
+
operation: "archive",
|
|
104
704
|
kind: "epic",
|
|
105
705
|
id,
|
|
106
|
-
path:
|
|
107
|
-
|
|
706
|
+
path: destination,
|
|
707
|
+
affectedPaths: moves.map((move) => move.to),
|
|
108
708
|
removedWorktrees,
|
|
109
|
-
|
|
709
|
+
dryRun: options.dryRun === true,
|
|
710
|
+
at,
|
|
711
|
+
} satisfies LifecycleResult
|
|
110
712
|
}),
|
|
111
713
|
|
|
112
|
-
archiveTask: (
|
|
714
|
+
archiveTask: (
|
|
715
|
+
id: string,
|
|
716
|
+
startPath: string = process.cwd(),
|
|
717
|
+
options: LifecycleOptions = {},
|
|
718
|
+
) =>
|
|
113
719
|
Effect.gen(function* () {
|
|
114
720
|
const fs = yield* FileSystemService
|
|
115
721
|
const workbase = yield* WorkbaseService
|
|
@@ -118,12 +724,23 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
118
724
|
const worktrees = yield* WorktreeService
|
|
119
725
|
const root = yield* workbase.discover(startPath)
|
|
120
726
|
const task = yield* tasks.show(id, root)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
}
|
|
732
|
+
const destination = archivedTaskDirectory(root, id)
|
|
733
|
+
yield* rejectExistingDestination(destination, "Archive")
|
|
124
734
|
let parentEpic: EpicRecord | undefined
|
|
735
|
+
let declaration: DependencyData | undefined
|
|
125
736
|
if (task.data.epic) {
|
|
126
737
|
parentEpic = yield* epics.show(task.data.epic, root)
|
|
738
|
+
declaration = parentEpic.data.tasks.find((child) => child.id === id)
|
|
739
|
+
if (!declaration) {
|
|
740
|
+
return yield* new ArchiveError({
|
|
741
|
+
message: `Epic '${task.data.epic}' does not declare task '${id}'`,
|
|
742
|
+
})
|
|
743
|
+
}
|
|
127
744
|
const dependent = parentEpic.data.tasks.find((child) =>
|
|
128
745
|
child.dependsOn?.includes(id),
|
|
129
746
|
)
|
|
@@ -134,53 +751,137 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
134
751
|
}
|
|
135
752
|
}
|
|
136
753
|
|
|
137
|
-
const
|
|
754
|
+
const executionUnits: { taskId: string; phaseId?: string }[] = []
|
|
755
|
+
const phaseRecords: PhaseRecord[] = []
|
|
138
756
|
if ("phases" in task.data) {
|
|
139
757
|
for (const phase of task.data.phases) {
|
|
140
|
-
|
|
141
|
-
|
|
758
|
+
const record = yield* (yield* PhaseService).show(
|
|
759
|
+
id,
|
|
760
|
+
phase.id,
|
|
761
|
+
root,
|
|
142
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 })
|
|
143
770
|
}
|
|
144
771
|
} else {
|
|
772
|
+
executionUnits.push({ taskId: id })
|
|
773
|
+
}
|
|
774
|
+
const removedWorktrees: string[] = []
|
|
775
|
+
for (const unit of executionUnits)
|
|
145
776
|
removedWorktrees.push(
|
|
146
|
-
...(yield* worktrees.remove(
|
|
777
|
+
...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
778
|
+
dryRun: true,
|
|
779
|
+
})),
|
|
147
780
|
)
|
|
148
|
-
|
|
149
|
-
|
|
781
|
+
const at = new Date().toISOString()
|
|
782
|
+
const writes: (Write & { create?: boolean })[] = []
|
|
150
783
|
if (parentEpic) {
|
|
151
|
-
|
|
152
|
-
parentEpic.
|
|
153
|
-
parentEpic
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
784
|
+
writes.push({
|
|
785
|
+
path: parentEpic.path,
|
|
786
|
+
content: yield* declarationContent(parentEpic, {
|
|
787
|
+
...parentEpic.data,
|
|
788
|
+
tasks: parentEpic.data.tasks.filter((child) => child.id !== id),
|
|
789
|
+
}),
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
const manifestPath = lifecycleManifestPath(dirname(task.path))
|
|
793
|
+
writes.push({
|
|
794
|
+
path: manifestPath,
|
|
795
|
+
create: !(yield* fs.exists(manifestPath)),
|
|
796
|
+
content: json(
|
|
797
|
+
manifestFor(
|
|
798
|
+
yield* readManifest(dirname(task.path)),
|
|
158
799
|
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
800
|
+
kind: "task",
|
|
801
|
+
id,
|
|
802
|
+
...(task.data.epic && declaration
|
|
803
|
+
? {
|
|
804
|
+
parent: {
|
|
805
|
+
kind: "epic" as const,
|
|
806
|
+
id: task.data.epic,
|
|
807
|
+
declaration,
|
|
808
|
+
},
|
|
809
|
+
}
|
|
810
|
+
: {}),
|
|
811
|
+
},
|
|
812
|
+
event(root, "archive", at, dirname(task.path), destination),
|
|
813
|
+
),
|
|
814
|
+
),
|
|
815
|
+
})
|
|
816
|
+
if (!options.dryRun) {
|
|
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
|
+
}
|
|
163
835
|
},
|
|
164
|
-
|
|
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(
|
|
842
|
+
root,
|
|
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
|
+
}),
|
|
165
865
|
),
|
|
166
866
|
)
|
|
167
867
|
}
|
|
168
|
-
|
|
169
|
-
yield* fs.createDirectory(dirname(destination))
|
|
170
|
-
yield* fs.moveDirectory(dirname(task.path), destination)
|
|
171
868
|
return {
|
|
869
|
+
operation: "archive",
|
|
172
870
|
kind: "task",
|
|
173
871
|
id,
|
|
174
872
|
path: destination,
|
|
175
|
-
|
|
873
|
+
affectedPaths: [destination],
|
|
176
874
|
removedWorktrees,
|
|
177
|
-
|
|
875
|
+
dryRun: options.dryRun === true,
|
|
876
|
+
at,
|
|
877
|
+
} satisfies LifecycleResult
|
|
178
878
|
}),
|
|
179
879
|
|
|
180
880
|
archivePhase: (
|
|
181
881
|
taskId: string,
|
|
182
882
|
id: string,
|
|
183
883
|
startPath: string = process.cwd(),
|
|
884
|
+
options: LifecycleOptions = {},
|
|
184
885
|
) =>
|
|
185
886
|
Effect.gen(function* () {
|
|
186
887
|
const fs = yield* FileSystemService
|
|
@@ -196,6 +897,14 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
196
897
|
})
|
|
197
898
|
}
|
|
198
899
|
const phase = yield* phases.show(taskId, id, root)
|
|
900
|
+
const declaration = task.data.phases.find(
|
|
901
|
+
(candidate) => candidate.id === id,
|
|
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
|
+
}
|
|
199
908
|
const dependent = task.data.phases.find((candidate) =>
|
|
200
909
|
candidate.dependsOn?.includes(id),
|
|
201
910
|
)
|
|
@@ -204,42 +913,382 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
204
913
|
message: `Cannot archive phase '${id}'; phase '${dependent.id}' depends on it`,
|
|
205
914
|
})
|
|
206
915
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
916
|
+
const destination = archivedPhaseDirectory(root, taskId, id)
|
|
917
|
+
yield* rejectExistingDestination(destination, "Archive")
|
|
918
|
+
const removedWorktrees = yield* worktrees.remove(taskId, id, root, {
|
|
919
|
+
dryRun: true,
|
|
920
|
+
})
|
|
921
|
+
const at = new Date().toISOString()
|
|
922
|
+
const content = yield* declarationContent(task, {
|
|
923
|
+
...task.data,
|
|
924
|
+
phases: task.data.phases.filter((candidate) => candidate.id !== id),
|
|
925
|
+
})
|
|
926
|
+
const manifestPath = lifecycleManifestPath(dirname(phase.path))
|
|
927
|
+
const writes: (Write & { create?: boolean })[] = [
|
|
928
|
+
{ path: task.path, content },
|
|
929
|
+
{
|
|
930
|
+
path: manifestPath,
|
|
931
|
+
create: !(yield* fs.exists(manifestPath)),
|
|
932
|
+
content: json(
|
|
933
|
+
manifestFor(
|
|
934
|
+
yield* readManifest(dirname(phase.path)),
|
|
935
|
+
{
|
|
936
|
+
kind: "phase",
|
|
937
|
+
id,
|
|
938
|
+
taskId,
|
|
939
|
+
parent: { kind: "task", id: taskId, declaration },
|
|
940
|
+
},
|
|
941
|
+
event(root, "archive", at, dirname(phase.path), destination),
|
|
942
|
+
),
|
|
943
|
+
),
|
|
944
|
+
},
|
|
945
|
+
]
|
|
946
|
+
if (!options.dryRun) {
|
|
947
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
948
|
+
yield* withLifecycleLock(
|
|
949
|
+
root,
|
|
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
|
+
),
|
|
978
|
+
)
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
operation: "archive",
|
|
982
|
+
kind: "phase",
|
|
214
983
|
id,
|
|
984
|
+
taskId,
|
|
985
|
+
path: destination,
|
|
986
|
+
affectedPaths: [destination],
|
|
987
|
+
removedWorktrees,
|
|
988
|
+
dryRun: options.dryRun === true,
|
|
989
|
+
at,
|
|
990
|
+
} satisfies LifecycleResult
|
|
991
|
+
}),
|
|
992
|
+
|
|
993
|
+
restoreEpic: (
|
|
994
|
+
id: string,
|
|
995
|
+
startPath: string = process.cwd(),
|
|
996
|
+
options: LifecycleOptions = {},
|
|
997
|
+
) =>
|
|
998
|
+
Effect.gen(function* () {
|
|
999
|
+
const workbase = yield* WorkbaseService
|
|
1000
|
+
const epics = yield* EpicService
|
|
1001
|
+
const service = yield* ArchiveService
|
|
1002
|
+
const root = yield* workbase.discover(startPath)
|
|
1003
|
+
const epic = yield* service.show("epic", id, undefined, root)
|
|
1004
|
+
const epicData = epic.data as EpicData
|
|
1005
|
+
const destination = join(root, "epics", id)
|
|
1006
|
+
yield* rejectExistingDestination(destination, "Restore")
|
|
1007
|
+
const activeEpics = yield* epics.list(root)
|
|
1008
|
+
const tasks: ArchivedRecord[] = []
|
|
1009
|
+
for (const child of epicData.tasks) {
|
|
1010
|
+
const conflictingEpic = activeEpics.find((candidate) =>
|
|
1011
|
+
candidate.data.tasks.some(
|
|
1012
|
+
(declaration) => declaration.id === child.id,
|
|
1013
|
+
),
|
|
1014
|
+
)
|
|
1015
|
+
if (conflictingEpic) {
|
|
1016
|
+
return yield* new ArchiveError({
|
|
1017
|
+
message: `Active epic '${conflictingEpic.id}' already declares archived task '${child.id}'`,
|
|
1018
|
+
})
|
|
1019
|
+
}
|
|
1020
|
+
const task = yield* service.show("task", child.id, undefined, root)
|
|
1021
|
+
if ((task.data as TaskData).epic !== id) {
|
|
1022
|
+
return yield* new ArchiveError({
|
|
1023
|
+
message: `Archived task '${child.id}' does not backlink to epic '${id}'`,
|
|
1024
|
+
})
|
|
1025
|
+
}
|
|
1026
|
+
if (
|
|
1027
|
+
task.provenance?.parent &&
|
|
1028
|
+
(task.provenance.parent.kind !== "epic" ||
|
|
1029
|
+
task.provenance.parent.id !== id ||
|
|
1030
|
+
task.provenance.parent.declaration.id !== child.id)
|
|
1031
|
+
) {
|
|
1032
|
+
return yield* new ArchiveError({
|
|
1033
|
+
message: `Archived task '${child.id}' has conflicting epic provenance`,
|
|
1034
|
+
})
|
|
1035
|
+
}
|
|
1036
|
+
yield* rejectExistingDestination(
|
|
1037
|
+
join(root, "tasks", child.id),
|
|
1038
|
+
"Restore",
|
|
1039
|
+
)
|
|
1040
|
+
tasks.push(task)
|
|
1041
|
+
}
|
|
1042
|
+
const dependencyIssue = validateDependencies(
|
|
1043
|
+
epicData.tasks,
|
|
1044
|
+
`epic '${id}'`,
|
|
215
1045
|
)
|
|
216
|
-
|
|
217
|
-
|
|
1046
|
+
if (dependencyIssue)
|
|
1047
|
+
return yield* new ArchiveError({ message: dependencyIssue })
|
|
1048
|
+
const at = new Date().toISOString()
|
|
1049
|
+
const moves: Move[] = tasks.map((task) => ({
|
|
1050
|
+
from: task.path,
|
|
1051
|
+
to: join(root, "tasks", task.id),
|
|
1052
|
+
}))
|
|
1053
|
+
moves.push({ from: epic.path, to: destination })
|
|
1054
|
+
if (!options.dryRun) {
|
|
1055
|
+
const writes: Write[] = []
|
|
1056
|
+
for (const record of [...tasks, epic]) {
|
|
1057
|
+
const target =
|
|
1058
|
+
record.kind === "epic"
|
|
1059
|
+
? destination
|
|
1060
|
+
: join(root, "tasks", record.id)
|
|
1061
|
+
const manifest = manifestFor(
|
|
1062
|
+
record.provenance,
|
|
1063
|
+
{
|
|
1064
|
+
kind: record.kind,
|
|
1065
|
+
id: record.id,
|
|
1066
|
+
...(record.provenance?.parent
|
|
1067
|
+
? { parent: record.provenance.parent }
|
|
1068
|
+
: {}),
|
|
1069
|
+
},
|
|
1070
|
+
event(root, "restore", at, record.path, target),
|
|
1071
|
+
)
|
|
1072
|
+
writes.push({
|
|
1073
|
+
path: lifecycleManifestPath(target),
|
|
1074
|
+
content: json(manifest),
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
yield* withLifecycleLock(root, applyMutation(moves, writes))
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
operation: "restore",
|
|
1081
|
+
kind: "epic",
|
|
1082
|
+
id,
|
|
1083
|
+
path: destination,
|
|
1084
|
+
affectedPaths: moves.map((move) => move.to),
|
|
1085
|
+
removedWorktrees: [],
|
|
1086
|
+
dryRun: options.dryRun === true,
|
|
1087
|
+
at,
|
|
1088
|
+
} satisfies LifecycleResult
|
|
1089
|
+
}),
|
|
218
1090
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
1091
|
+
restoreTask: (
|
|
1092
|
+
id: string,
|
|
1093
|
+
startPath: string = process.cwd(),
|
|
1094
|
+
options: LifecycleOptions = {},
|
|
1095
|
+
) =>
|
|
1096
|
+
Effect.gen(function* () {
|
|
1097
|
+
const workbase = yield* WorkbaseService
|
|
1098
|
+
const epics = yield* EpicService
|
|
1099
|
+
const service = yield* ArchiveService
|
|
1100
|
+
const root = yield* workbase.discover(startPath)
|
|
1101
|
+
const task = yield* service.show("task", id, undefined, root)
|
|
1102
|
+
const taskData = task.data as TaskData
|
|
1103
|
+
const destination = join(root, "tasks", id)
|
|
1104
|
+
yield* rejectExistingDestination(destination, "Restore")
|
|
1105
|
+
const activeEpics = yield* epics.list(root)
|
|
1106
|
+
const conflictingEpic = activeEpics.find(
|
|
1107
|
+
(candidate) =>
|
|
1108
|
+
candidate.id !== taskData.epic &&
|
|
1109
|
+
candidate.data.tasks.some((declaration) => declaration.id === id),
|
|
1110
|
+
)
|
|
1111
|
+
if (conflictingEpic) {
|
|
1112
|
+
return yield* new ArchiveError({
|
|
1113
|
+
message: `Active epic '${conflictingEpic.id}' already declares archived task '${id}'`,
|
|
1114
|
+
})
|
|
1115
|
+
}
|
|
1116
|
+
if (!taskData.epic && task.provenance?.parent) {
|
|
1117
|
+
return yield* new ArchiveError({
|
|
1118
|
+
message: `Archived task '${id}' is missing its epic backlink`,
|
|
1119
|
+
})
|
|
1120
|
+
}
|
|
1121
|
+
let parent: EpicRecord | undefined
|
|
1122
|
+
let declaration: DependencyData | undefined
|
|
1123
|
+
if (taskData.epic) {
|
|
1124
|
+
parent = yield* epics.show(taskData.epic, root)
|
|
1125
|
+
if (
|
|
1126
|
+
task.provenance?.parent &&
|
|
1127
|
+
(task.provenance.parent.kind !== "epic" ||
|
|
1128
|
+
task.provenance.parent.id !== taskData.epic)
|
|
1129
|
+
) {
|
|
1130
|
+
return yield* new ArchiveError({
|
|
1131
|
+
message: `Archived task '${id}' has conflicting epic backlink provenance`,
|
|
1132
|
+
})
|
|
1133
|
+
}
|
|
1134
|
+
declaration = task.provenance?.parent?.declaration ?? { id }
|
|
1135
|
+
if (declaration.id !== id) {
|
|
1136
|
+
return yield* new ArchiveError({
|
|
1137
|
+
message: `Archived task '${id}' has a conflicting parent declaration ID '${declaration.id}'`,
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
if (parent.data.tasks.some((child) => child.id === id)) {
|
|
1141
|
+
return yield* new ArchiveError({
|
|
1142
|
+
message: `Epic '${taskData.epic}' already declares task '${id}'`,
|
|
1143
|
+
})
|
|
1144
|
+
}
|
|
1145
|
+
const nodes = [...parent.data.tasks, declaration]
|
|
1146
|
+
const dependencyIssue = validateDependencies(
|
|
1147
|
+
nodes,
|
|
1148
|
+
`epic '${taskData.epic}'`,
|
|
1149
|
+
)
|
|
1150
|
+
if (dependencyIssue)
|
|
1151
|
+
return yield* new ArchiveError({ message: dependencyIssue })
|
|
1152
|
+
}
|
|
1153
|
+
const at = new Date().toISOString()
|
|
1154
|
+
if (!options.dryRun) {
|
|
1155
|
+
const writes: Write[] = [
|
|
223
1156
|
{
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
(
|
|
1157
|
+
path: lifecycleManifestPath(destination),
|
|
1158
|
+
content: json(
|
|
1159
|
+
manifestFor(
|
|
1160
|
+
task.provenance,
|
|
1161
|
+
{
|
|
1162
|
+
kind: "task",
|
|
1163
|
+
id,
|
|
1164
|
+
...(task.provenance?.parent
|
|
1165
|
+
? { parent: task.provenance.parent }
|
|
1166
|
+
: {}),
|
|
1167
|
+
},
|
|
1168
|
+
event(root, "restore", at, task.path, destination),
|
|
1169
|
+
),
|
|
227
1170
|
),
|
|
228
1171
|
},
|
|
229
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
1172
|
+
]
|
|
1173
|
+
if (parent && declaration) {
|
|
1174
|
+
writes.push({
|
|
1175
|
+
path: parent.path,
|
|
1176
|
+
content: yield* declarationContent(parent, {
|
|
1177
|
+
...parent.data,
|
|
1178
|
+
tasks: [...parent.data.tasks, declaration],
|
|
1179
|
+
}),
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
yield* withLifecycleLock(
|
|
1183
|
+
root,
|
|
1184
|
+
applyMutation([{ from: task.path, to: destination }], writes),
|
|
1185
|
+
)
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
operation: "restore",
|
|
1189
|
+
kind: "task",
|
|
1190
|
+
id,
|
|
1191
|
+
path: destination,
|
|
1192
|
+
affectedPaths: [destination],
|
|
1193
|
+
removedWorktrees: [],
|
|
1194
|
+
dryRun: options.dryRun === true,
|
|
1195
|
+
at,
|
|
1196
|
+
} satisfies LifecycleResult
|
|
1197
|
+
}),
|
|
234
1198
|
|
|
1199
|
+
restorePhase: (
|
|
1200
|
+
taskId: string,
|
|
1201
|
+
id: string,
|
|
1202
|
+
startPath: string = process.cwd(),
|
|
1203
|
+
options: LifecycleOptions = {},
|
|
1204
|
+
) =>
|
|
1205
|
+
Effect.gen(function* () {
|
|
1206
|
+
const workbase = yield* WorkbaseService
|
|
1207
|
+
const tasks = yield* TaskService
|
|
1208
|
+
const service = yield* ArchiveService
|
|
1209
|
+
const root = yield* workbase.discover(startPath)
|
|
1210
|
+
const task = yield* tasks.show(taskId, root)
|
|
1211
|
+
if (!("phases" in task.data)) {
|
|
1212
|
+
return yield* new ArchiveError({
|
|
1213
|
+
message: `Task '${taskId}' is single-phase and cannot receive a phase`,
|
|
1214
|
+
})
|
|
1215
|
+
}
|
|
1216
|
+
const phase = yield* service.show("phase", id, taskId, root)
|
|
1217
|
+
if (
|
|
1218
|
+
phase.provenance?.parent &&
|
|
1219
|
+
(phase.provenance.parent.kind !== "task" ||
|
|
1220
|
+
phase.provenance.parent.id !== taskId)
|
|
1221
|
+
) {
|
|
1222
|
+
return yield* new ArchiveError({
|
|
1223
|
+
message: `Archived phase '${id}' has conflicting task backlink provenance`,
|
|
1224
|
+
})
|
|
1225
|
+
}
|
|
1226
|
+
if (task.data.phases.some((candidate) => candidate.id === id)) {
|
|
1227
|
+
return yield* new ArchiveError({
|
|
1228
|
+
message: `Task '${taskId}' already declares phase '${id}'`,
|
|
1229
|
+
})
|
|
1230
|
+
}
|
|
1231
|
+
const destination = join(root, "tasks", taskId, "phases", id)
|
|
1232
|
+
yield* rejectExistingDestination(destination, "Restore")
|
|
1233
|
+
const declaration = phase.provenance?.parent?.declaration ?? { id }
|
|
1234
|
+
if (declaration.id !== id) {
|
|
1235
|
+
return yield* new ArchiveError({
|
|
1236
|
+
message: `Archived phase '${id}' has a conflicting parent declaration ID '${declaration.id}'`,
|
|
1237
|
+
})
|
|
1238
|
+
}
|
|
1239
|
+
const nodes = [...task.data.phases, declaration]
|
|
1240
|
+
const dependencyIssue = validateDependencies(
|
|
1241
|
+
nodes,
|
|
1242
|
+
`task '${taskId}'`,
|
|
1243
|
+
)
|
|
1244
|
+
if (dependencyIssue)
|
|
1245
|
+
return yield* new ArchiveError({ message: dependencyIssue })
|
|
1246
|
+
const at = new Date().toISOString()
|
|
1247
|
+
if (!options.dryRun) {
|
|
1248
|
+
yield* withLifecycleLock(
|
|
1249
|
+
root,
|
|
1250
|
+
applyMutation(
|
|
1251
|
+
[{ from: phase.path, to: destination }],
|
|
1252
|
+
[
|
|
1253
|
+
{
|
|
1254
|
+
path: task.path,
|
|
1255
|
+
content: yield* declarationContent(task, {
|
|
1256
|
+
...task.data,
|
|
1257
|
+
phases: [...task.data.phases, declaration],
|
|
1258
|
+
}),
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
path: lifecycleManifestPath(destination),
|
|
1262
|
+
content: json(
|
|
1263
|
+
manifestFor(
|
|
1264
|
+
phase.provenance,
|
|
1265
|
+
{
|
|
1266
|
+
kind: "phase",
|
|
1267
|
+
id,
|
|
1268
|
+
taskId,
|
|
1269
|
+
...(phase.provenance?.parent
|
|
1270
|
+
? { parent: phase.provenance.parent }
|
|
1271
|
+
: {}),
|
|
1272
|
+
},
|
|
1273
|
+
event(root, "restore", at, phase.path, destination),
|
|
1274
|
+
),
|
|
1275
|
+
),
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
),
|
|
1279
|
+
)
|
|
1280
|
+
}
|
|
235
1281
|
return {
|
|
1282
|
+
operation: "restore",
|
|
236
1283
|
kind: "phase",
|
|
237
1284
|
id,
|
|
238
1285
|
taskId,
|
|
239
1286
|
path: destination,
|
|
240
|
-
|
|
241
|
-
removedWorktrees,
|
|
242
|
-
|
|
1287
|
+
affectedPaths: [destination],
|
|
1288
|
+
removedWorktrees: [],
|
|
1289
|
+
dryRun: options.dryRun === true,
|
|
1290
|
+
at,
|
|
1291
|
+
} satisfies LifecycleResult
|
|
243
1292
|
}),
|
|
244
1293
|
}),
|
|
245
1294
|
},
|