@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.
- package/README.md +22 -4
- package/cli.ts +27 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +9 -1
- package/src/cli-parser.test.ts +57 -0
- package/src/cli-parser.ts +85 -17
- package/src/cli.test.ts +36 -0
- package/src/commands/archive.test.ts +21 -3
- package/src/commands/archive.ts +86 -18
- package/src/commands/epic.test.ts +12 -3
- package/src/commands/epic.ts +11 -1
- package/src/commands/phase.ts +13 -1
- package/src/commands/restore.test.ts +98 -0
- package/src/commands/restore.ts +73 -0
- package/src/commands/task-phase.test.ts +16 -4
- package/src/commands/task.ts +13 -1
- package/src/protocol.test.ts +19 -0
- package/src/services/ArchiveService.test.ts +520 -1
- package/src/services/ArchiveService.ts +964 -84
- package/src/services/ClaimService.ts +27 -21
- package/src/services/ContextService.ts +4 -6
- package/src/services/EpicService.ts +22 -2
- package/src/services/GraphMutationService.test.ts +31 -0
- package/src/services/GraphMutationService.ts +126 -11
- package/src/services/GraphService.ts +2 -4
- package/src/services/PhaseService.ts +32 -4
- package/src/services/TaskService.ts +28 -3
- package/src/services/WorktreeService.ts +35 -5
- package/src/work-view.ts +2 -0
- package/src/workbase/archive.ts +18 -0
- package/src/workbase/document-revision.ts +16 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
556
|
+
archivedTaskDirectory(root, task.id),
|
|
557
|
+
"Archive",
|
|
74
558
|
)
|
|
75
|
-
}
|
|
76
559
|
|
|
77
|
-
|
|
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
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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:
|
|
107
|
-
|
|
631
|
+
path: destination,
|
|
632
|
+
affectedPaths: moves.map((move) => move.to),
|
|
108
633
|
removedWorktrees,
|
|
109
|
-
|
|
634
|
+
dryRun: options.dryRun === true,
|
|
635
|
+
at,
|
|
636
|
+
} satisfies LifecycleResult
|
|
110
637
|
}),
|
|
111
638
|
|
|
112
|
-
archiveTask: (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
parentEpic.
|
|
153
|
-
parentEpic
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
removedWorktrees,
|
|
177
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
(
|
|
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
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
241
|
-
removedWorktrees,
|
|
242
|
-
|
|
1118
|
+
affectedPaths: [destination],
|
|
1119
|
+
removedWorktrees: [],
|
|
1120
|
+
dryRun: options.dryRun === true,
|
|
1121
|
+
at,
|
|
1122
|
+
} satisfies LifecycleResult
|
|
243
1123
|
}),
|
|
244
1124
|
}),
|
|
245
1125
|
},
|