@markjaquith/agency 2.20.0 → 2.22.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 +23 -0
- package/cli.ts +13 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +45 -1
- package/src/cli-parser.ts +158 -5
- package/src/cli.test.ts +62 -0
- package/src/commands/epic.ts +51 -1
- package/src/commands/phase.ts +94 -1
- package/src/commands/pr.ts +2 -2
- package/src/commands/task.ts +108 -1
- package/src/graph-schema.ts +2 -10
- package/src/services/ClaimService.ts +7 -2
- package/src/services/ContextService.ts +4 -4
- package/src/services/GraphMutationService.test.ts +336 -0
- package/src/services/GraphMutationService.ts +952 -0
- package/src/services/GraphService.ts +2 -11
- package/src/services/PullRequestService.test.ts +91 -4
- package/src/services/PullRequestService.ts +108 -31
- package/src/services/SyncService.test.ts +95 -1
- package/src/services/SyncService.ts +150 -60
- package/src/services/TaskPhaseService.test.ts +9 -1
- package/src/services/WorkbaseService.ts +5 -37
- package/src/test-utils.ts +2 -0
- package/src/workbase/delivery-command.test.ts +54 -0
- package/src/workbase/delivery-command.ts +135 -0
- package/src/workbase/dependency-graph.ts +51 -0
- package/src/workbase/schemas.test.ts +35 -0
- package/src/workbase/schemas.ts +23 -1
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
+
import { Data, Effect, Either } from "effect"
|
|
3
|
+
import { open, rename, rm } from "node:fs/promises"
|
|
4
|
+
import { dirname, join, relative } from "node:path"
|
|
5
|
+
import { EpicService } from "./EpicService"
|
|
6
|
+
import { FileSystemService } from "./FileSystemService"
|
|
7
|
+
import { PhaseService } from "./PhaseService"
|
|
8
|
+
import { TaskService } from "./TaskService"
|
|
9
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
10
|
+
import {
|
|
11
|
+
EntityId,
|
|
12
|
+
EpicFrontmatter,
|
|
13
|
+
PhaseFrontmatter,
|
|
14
|
+
TaskFrontmatter,
|
|
15
|
+
type Dependency,
|
|
16
|
+
type EpicFrontmatter as EpicData,
|
|
17
|
+
type PhaseFrontmatter as PhaseData,
|
|
18
|
+
type RepositoryReference,
|
|
19
|
+
type TaskFrontmatter as TaskData,
|
|
20
|
+
} from "../workbase/schemas"
|
|
21
|
+
import {
|
|
22
|
+
formatMarkdownDocument,
|
|
23
|
+
parseFrontmatter,
|
|
24
|
+
} from "../workbase/frontmatter"
|
|
25
|
+
import { validateDependencies } from "../workbase/dependency-graph"
|
|
26
|
+
|
|
27
|
+
class GraphMutationError extends Data.TaggedError("GraphMutationError")<{
|
|
28
|
+
readonly message: string
|
|
29
|
+
readonly cause?: unknown
|
|
30
|
+
}> {}
|
|
31
|
+
|
|
32
|
+
interface MutationResult {
|
|
33
|
+
readonly operation: string
|
|
34
|
+
readonly changed: boolean
|
|
35
|
+
readonly entity: {
|
|
36
|
+
readonly kind: "epic" | "task" | "phase"
|
|
37
|
+
readonly id: string
|
|
38
|
+
}
|
|
39
|
+
readonly previousId?: string
|
|
40
|
+
readonly changedPaths: readonly string[]
|
|
41
|
+
readonly validation: {
|
|
42
|
+
readonly valid: true
|
|
43
|
+
readonly scope: readonly string[]
|
|
44
|
+
readonly checks: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface WritePlan {
|
|
49
|
+
readonly root: string
|
|
50
|
+
readonly writes: readonly {
|
|
51
|
+
readonly path: string
|
|
52
|
+
readonly content: string
|
|
53
|
+
}[]
|
|
54
|
+
readonly move?: { readonly from: string; readonly to: string }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EpicUpdates {
|
|
58
|
+
readonly description?: string | null
|
|
59
|
+
readonly ticketUrl?: string
|
|
60
|
+
readonly repos?: readonly RepositoryReference[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TaskUpdates {
|
|
64
|
+
readonly description?: string | null
|
|
65
|
+
readonly ticketUrl?: string | null
|
|
66
|
+
readonly repo?: string
|
|
67
|
+
readonly repos?: readonly RepositoryReference[] | null
|
|
68
|
+
readonly branch?: string
|
|
69
|
+
readonly base?: string
|
|
70
|
+
readonly pr?: string | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PhaseUpdates {
|
|
74
|
+
readonly description?: string | null
|
|
75
|
+
readonly repo?: string
|
|
76
|
+
readonly repos?: readonly RepositoryReference[] | null
|
|
77
|
+
readonly branch?: string
|
|
78
|
+
readonly base?: string
|
|
79
|
+
readonly pr?: string | null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
83
|
+
schema: S,
|
|
84
|
+
input: unknown,
|
|
85
|
+
label: string,
|
|
86
|
+
) => {
|
|
87
|
+
const result = Schema.decodeUnknownEither(schema, {
|
|
88
|
+
errors: "all",
|
|
89
|
+
onExcessProperty: "error",
|
|
90
|
+
})(input)
|
|
91
|
+
return Either.isLeft(result)
|
|
92
|
+
? Effect.fail(
|
|
93
|
+
new GraphMutationError({
|
|
94
|
+
message: `Invalid ${label}: ${TreeFormatter.formatErrorSync(result.left)}`,
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
: Effect.succeed(result.right)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const decodeId = (id: string, label: string) => {
|
|
101
|
+
const decoded = Schema.decodeUnknownEither(EntityId)(id)
|
|
102
|
+
return Either.isLeft(decoded)
|
|
103
|
+
? Effect.fail(
|
|
104
|
+
new GraphMutationError({ message: `Invalid ${label} ID '${id}'` }),
|
|
105
|
+
)
|
|
106
|
+
: Effect.succeed(decoded.right)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const exists = async (path: string) => {
|
|
110
|
+
try {
|
|
111
|
+
return await Bun.file(path).exists()
|
|
112
|
+
} catch {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const applyWritePlan = ({ root, writes, move }: WritePlan) =>
|
|
118
|
+
Effect.tryPromise({
|
|
119
|
+
try: async () => {
|
|
120
|
+
const lockPath = join(root, ".agency-graph-mutation.lock")
|
|
121
|
+
let lock: Awaited<ReturnType<typeof open>> | undefined
|
|
122
|
+
try {
|
|
123
|
+
lock = await open(lockPath, "wx")
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
throw new GraphMutationError({
|
|
126
|
+
message:
|
|
127
|
+
"Another graph mutation is in progress; wait for it to finish and retry",
|
|
128
|
+
cause,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const token = `${process.pid}-${Date.now()}`
|
|
133
|
+
const staged = writes.map((write) => ({
|
|
134
|
+
...write,
|
|
135
|
+
stage: `${write.path}.${token}.stage`,
|
|
136
|
+
backup: `${write.path}.${token}.backup`,
|
|
137
|
+
}))
|
|
138
|
+
const installed: typeof staged = []
|
|
139
|
+
let moved = false
|
|
140
|
+
let rollbackFailed = false
|
|
141
|
+
try {
|
|
142
|
+
for (const write of staged) await Bun.write(write.stage, write.content)
|
|
143
|
+
if (move) {
|
|
144
|
+
await rename(move.from, move.to)
|
|
145
|
+
moved = true
|
|
146
|
+
}
|
|
147
|
+
for (const write of staged) {
|
|
148
|
+
await rename(write.path, write.backup)
|
|
149
|
+
try {
|
|
150
|
+
await rename(write.stage, write.path)
|
|
151
|
+
installed.push(write)
|
|
152
|
+
} catch (cause) {
|
|
153
|
+
await rename(write.backup, write.path)
|
|
154
|
+
throw cause
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await Promise.allSettled(
|
|
158
|
+
installed.map((write) => rm(write.backup, { force: true })),
|
|
159
|
+
)
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
let rollbackCause: unknown
|
|
162
|
+
for (const write of [...installed].reverse()) {
|
|
163
|
+
try {
|
|
164
|
+
await rm(write.path, { force: true })
|
|
165
|
+
if (await exists(write.backup))
|
|
166
|
+
await rename(write.backup, write.path)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
rollbackCause ??= error
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (moved && move) {
|
|
172
|
+
try {
|
|
173
|
+
await rename(move.to, move.from)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
rollbackCause ??= error
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (rollbackCause) {
|
|
179
|
+
rollbackFailed = true
|
|
180
|
+
throw new GraphMutationError({
|
|
181
|
+
message: `Graph mutation rollback failed; recovery files with suffix '.${token}.backup' were preserved`,
|
|
182
|
+
cause: new AggregateError([cause, rollbackCause]),
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
throw cause
|
|
186
|
+
} finally {
|
|
187
|
+
await Promise.allSettled(
|
|
188
|
+
staged.flatMap((write) => [
|
|
189
|
+
rm(write.stage, { force: true }),
|
|
190
|
+
...(rollbackFailed ? [] : [rm(write.backup, { force: true })]),
|
|
191
|
+
]),
|
|
192
|
+
)
|
|
193
|
+
await lock.close().catch(() => undefined)
|
|
194
|
+
await rm(lockPath, { force: true }).catch(() => undefined)
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
catch: (cause) =>
|
|
198
|
+
cause instanceof GraphMutationError
|
|
199
|
+
? cause
|
|
200
|
+
: new GraphMutationError({
|
|
201
|
+
message:
|
|
202
|
+
"Graph mutation failed; all staged changes were rolled back",
|
|
203
|
+
cause,
|
|
204
|
+
}),
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const contentWith = (
|
|
208
|
+
record: { readonly content: string; readonly path: string },
|
|
209
|
+
data: EpicData | TaskData | PhaseData,
|
|
210
|
+
) =>
|
|
211
|
+
parseFrontmatter(record.content, record.path).pipe(
|
|
212
|
+
Effect.map((parsed) => formatMarkdownDocument(data, parsed.body)),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
const result = (
|
|
216
|
+
root: string,
|
|
217
|
+
operation: string,
|
|
218
|
+
kind: MutationResult["entity"]["kind"],
|
|
219
|
+
id: string,
|
|
220
|
+
paths: readonly string[],
|
|
221
|
+
previousId?: string,
|
|
222
|
+
): MutationResult => {
|
|
223
|
+
const scope = paths.map((path) => relative(root, path))
|
|
224
|
+
return {
|
|
225
|
+
operation,
|
|
226
|
+
changed: paths.length > 0 || previousId !== undefined,
|
|
227
|
+
entity: { kind, id },
|
|
228
|
+
...(previousId ? { previousId } : {}),
|
|
229
|
+
changedPaths: scope,
|
|
230
|
+
validation: {
|
|
231
|
+
valid: true,
|
|
232
|
+
scope,
|
|
233
|
+
checks: ["schema", "references", "dependencies"],
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const assertDependencies = (nodes: readonly Dependency[], label: string) => {
|
|
239
|
+
const issue = validateDependencies(nodes, label)
|
|
240
|
+
return issue
|
|
241
|
+
? Effect.fail(new GraphMutationError({ message: issue }))
|
|
242
|
+
: Effect.void
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export class GraphMutationService extends Effect.Service<GraphMutationService>()(
|
|
246
|
+
"GraphMutationService",
|
|
247
|
+
{
|
|
248
|
+
sync: () => ({
|
|
249
|
+
updateEpic: (
|
|
250
|
+
id: string,
|
|
251
|
+
updates: EpicUpdates,
|
|
252
|
+
startPath: string = process.cwd(),
|
|
253
|
+
) =>
|
|
254
|
+
Effect.gen(function* () {
|
|
255
|
+
const workbase = yield* WorkbaseService
|
|
256
|
+
const epics = yield* EpicService
|
|
257
|
+
const fs = yield* FileSystemService
|
|
258
|
+
const root = yield* workbase.discover(startPath)
|
|
259
|
+
const record = yield* epics.show(id, root)
|
|
260
|
+
const data: EpicData = yield* decode(
|
|
261
|
+
EpicFrontmatter,
|
|
262
|
+
{
|
|
263
|
+
...record.data,
|
|
264
|
+
...(updates.description === null
|
|
265
|
+
? { description: undefined }
|
|
266
|
+
: updates.description !== undefined
|
|
267
|
+
? { description: updates.description }
|
|
268
|
+
: {}),
|
|
269
|
+
...(updates.ticketUrl !== undefined
|
|
270
|
+
? { ticketUrl: updates.ticketUrl }
|
|
271
|
+
: {}),
|
|
272
|
+
...(updates.repos !== undefined ? { repos: updates.repos } : {}),
|
|
273
|
+
},
|
|
274
|
+
"epic metadata",
|
|
275
|
+
)
|
|
276
|
+
for (const reference of data.repos) {
|
|
277
|
+
if (!(yield* fs.exists(join(root, "repos", reference.repo)))) {
|
|
278
|
+
return yield* new GraphMutationError({
|
|
279
|
+
message: `Unknown repository alias '${reference.repo}'`,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (
|
|
284
|
+
new Set(data.repos.map((item) => item.repo)).size !==
|
|
285
|
+
data.repos.length
|
|
286
|
+
) {
|
|
287
|
+
return yield* new GraphMutationError({
|
|
288
|
+
message: "Repository references must be unique",
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
const content = yield* contentWith(record, data)
|
|
292
|
+
if (content === record.content)
|
|
293
|
+
return result(root, "epic.update", "epic", id, [])
|
|
294
|
+
yield* applyWritePlan({
|
|
295
|
+
root,
|
|
296
|
+
writes: [{ path: record.path, content }],
|
|
297
|
+
})
|
|
298
|
+
return result(root, "epic.update", "epic", id, [record.path])
|
|
299
|
+
}),
|
|
300
|
+
|
|
301
|
+
updateTask: (
|
|
302
|
+
id: string,
|
|
303
|
+
updates: TaskUpdates,
|
|
304
|
+
startPath: string = process.cwd(),
|
|
305
|
+
) =>
|
|
306
|
+
Effect.gen(function* () {
|
|
307
|
+
const workbase = yield* WorkbaseService
|
|
308
|
+
const tasks = yield* TaskService
|
|
309
|
+
const fs = yield* FileSystemService
|
|
310
|
+
const root = yield* workbase.discover(startPath)
|
|
311
|
+
const record = yield* tasks.show(id, root)
|
|
312
|
+
const executionChange = [
|
|
313
|
+
"repo",
|
|
314
|
+
"repos",
|
|
315
|
+
"branch",
|
|
316
|
+
"base",
|
|
317
|
+
"pr",
|
|
318
|
+
].some((key) => updates[key as keyof TaskUpdates] !== undefined)
|
|
319
|
+
if ("phases" in record.data && executionChange) {
|
|
320
|
+
return yield* new GraphMutationError({
|
|
321
|
+
message: `Task '${id}' has multiple phases; update execution metadata on a phase instead`,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
if (
|
|
325
|
+
executionChange &&
|
|
326
|
+
"claim" in record.data &&
|
|
327
|
+
record.data.claim?.state === "active"
|
|
328
|
+
) {
|
|
329
|
+
return yield* new GraphMutationError({
|
|
330
|
+
message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
if (
|
|
334
|
+
executionChange &&
|
|
335
|
+
(yield* fs.isDirectory(join(dirname(record.path), "code")))
|
|
336
|
+
) {
|
|
337
|
+
return yield* new GraphMutationError({
|
|
338
|
+
message: `Task '${id}' has materialized code; remove its worktree with Agency before changing execution metadata`,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
const data: TaskData = yield* decode(
|
|
342
|
+
TaskFrontmatter,
|
|
343
|
+
{
|
|
344
|
+
...record.data,
|
|
345
|
+
...(updates.description === null
|
|
346
|
+
? { description: undefined }
|
|
347
|
+
: updates.description !== undefined
|
|
348
|
+
? { description: updates.description }
|
|
349
|
+
: {}),
|
|
350
|
+
...(updates.ticketUrl !== undefined
|
|
351
|
+
? { ticketUrl: updates.ticketUrl }
|
|
352
|
+
: {}),
|
|
353
|
+
...(updates.repo !== undefined ? { repo: updates.repo } : {}),
|
|
354
|
+
...(updates.repos === null
|
|
355
|
+
? { repos: undefined }
|
|
356
|
+
: updates.repos !== undefined
|
|
357
|
+
? { repos: updates.repos }
|
|
358
|
+
: {}),
|
|
359
|
+
...(updates.branch !== undefined
|
|
360
|
+
? { branch: updates.branch }
|
|
361
|
+
: {}),
|
|
362
|
+
...(updates.base !== undefined ? { base: updates.base } : {}),
|
|
363
|
+
...(updates.pr !== undefined ? { pr: updates.pr } : {}),
|
|
364
|
+
},
|
|
365
|
+
"task metadata",
|
|
366
|
+
)
|
|
367
|
+
if ("repo" in data) {
|
|
368
|
+
const aliases = [
|
|
369
|
+
data.repo,
|
|
370
|
+
...(data.repos ?? []).map((item) => item.repo),
|
|
371
|
+
]
|
|
372
|
+
if (new Set(aliases).size !== aliases.length) {
|
|
373
|
+
return yield* new GraphMutationError({
|
|
374
|
+
message:
|
|
375
|
+
"Repository references must be unique and cannot include the writable repository",
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
for (const alias of aliases) {
|
|
379
|
+
if (!(yield* fs.exists(join(root, "repos", alias)))) {
|
|
380
|
+
return yield* new GraphMutationError({
|
|
381
|
+
message: `Unknown repository alias '${alias}'`,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const other of yield* tasks.list(root)) {
|
|
386
|
+
if (
|
|
387
|
+
other.id !== id &&
|
|
388
|
+
"repo" in other.data &&
|
|
389
|
+
other.data.repo === data.repo &&
|
|
390
|
+
other.data.branch === data.branch
|
|
391
|
+
) {
|
|
392
|
+
return yield* new GraphMutationError({
|
|
393
|
+
message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by task '${other.id}'`,
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
if (!("phases" in other.data)) continue
|
|
397
|
+
for (const phase of yield* (yield* PhaseService).list(
|
|
398
|
+
other.id,
|
|
399
|
+
root,
|
|
400
|
+
)) {
|
|
401
|
+
if (
|
|
402
|
+
phase.data.repo === data.repo &&
|
|
403
|
+
phase.data.branch === data.branch
|
|
404
|
+
) {
|
|
405
|
+
return yield* new GraphMutationError({
|
|
406
|
+
message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by phase '${other.id}/${phase.id}'`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const content = yield* contentWith(record, data)
|
|
413
|
+
if (content === record.content)
|
|
414
|
+
return result(root, "task.update", "task", id, [])
|
|
415
|
+
yield* applyWritePlan({
|
|
416
|
+
root,
|
|
417
|
+
writes: [{ path: record.path, content }],
|
|
418
|
+
})
|
|
419
|
+
return result(root, "task.update", "task", id, [record.path])
|
|
420
|
+
}),
|
|
421
|
+
|
|
422
|
+
updatePhase: (
|
|
423
|
+
taskId: string,
|
|
424
|
+
id: string,
|
|
425
|
+
updates: PhaseUpdates,
|
|
426
|
+
startPath: string = process.cwd(),
|
|
427
|
+
) =>
|
|
428
|
+
Effect.gen(function* () {
|
|
429
|
+
const workbase = yield* WorkbaseService
|
|
430
|
+
const phases = yield* PhaseService
|
|
431
|
+
const fs = yield* FileSystemService
|
|
432
|
+
const root = yield* workbase.discover(startPath)
|
|
433
|
+
const record = yield* phases.show(taskId, id, root)
|
|
434
|
+
const executionChange = [
|
|
435
|
+
"repo",
|
|
436
|
+
"repos",
|
|
437
|
+
"branch",
|
|
438
|
+
"base",
|
|
439
|
+
"pr",
|
|
440
|
+
].some((key) => updates[key as keyof PhaseUpdates] !== undefined)
|
|
441
|
+
if (
|
|
442
|
+
executionChange &&
|
|
443
|
+
(yield* fs.isDirectory(join(dirname(record.path), "code")))
|
|
444
|
+
) {
|
|
445
|
+
return yield* new GraphMutationError({
|
|
446
|
+
message: `Phase '${id}' has materialized code; remove its worktree with Agency before changing execution metadata`,
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
if (executionChange && record.data.claim?.state === "active") {
|
|
450
|
+
return yield* new GraphMutationError({
|
|
451
|
+
message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
const data: PhaseData = yield* decode(
|
|
455
|
+
PhaseFrontmatter,
|
|
456
|
+
{
|
|
457
|
+
...record.data,
|
|
458
|
+
...(updates.description === null
|
|
459
|
+
? { description: undefined }
|
|
460
|
+
: updates.description !== undefined
|
|
461
|
+
? { description: updates.description }
|
|
462
|
+
: {}),
|
|
463
|
+
...(updates.repo !== undefined ? { repo: updates.repo } : {}),
|
|
464
|
+
...(updates.repos === null
|
|
465
|
+
? { repos: undefined }
|
|
466
|
+
: updates.repos !== undefined
|
|
467
|
+
? { repos: updates.repos }
|
|
468
|
+
: {}),
|
|
469
|
+
...(updates.branch !== undefined
|
|
470
|
+
? { branch: updates.branch }
|
|
471
|
+
: {}),
|
|
472
|
+
...(updates.base !== undefined ? { base: updates.base } : {}),
|
|
473
|
+
...(updates.pr !== undefined ? { pr: updates.pr } : {}),
|
|
474
|
+
},
|
|
475
|
+
"phase metadata",
|
|
476
|
+
)
|
|
477
|
+
const aliases = [
|
|
478
|
+
data.repo,
|
|
479
|
+
...(data.repos ?? []).map((item) => item.repo),
|
|
480
|
+
]
|
|
481
|
+
if (new Set(aliases).size !== aliases.length) {
|
|
482
|
+
return yield* new GraphMutationError({
|
|
483
|
+
message:
|
|
484
|
+
"Repository references must be unique and cannot include the writable repository",
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
for (const alias of aliases) {
|
|
488
|
+
if (!(yield* fs.exists(join(root, "repos", alias)))) {
|
|
489
|
+
return yield* new GraphMutationError({
|
|
490
|
+
message: `Unknown repository alias '${alias}'`,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const task of yield* (yield* TaskService).list(root)) {
|
|
495
|
+
if (
|
|
496
|
+
"repo" in task.data &&
|
|
497
|
+
task.data.repo === data.repo &&
|
|
498
|
+
task.data.branch === data.branch
|
|
499
|
+
) {
|
|
500
|
+
return yield* new GraphMutationError({
|
|
501
|
+
message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by task '${task.id}'`,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
if (!("phases" in task.data)) continue
|
|
505
|
+
for (const other of yield* phases.list(task.id, root)) {
|
|
506
|
+
if (
|
|
507
|
+
(task.id !== taskId || other.id !== id) &&
|
|
508
|
+
other.data.repo === data.repo &&
|
|
509
|
+
other.data.branch === data.branch
|
|
510
|
+
) {
|
|
511
|
+
return yield* new GraphMutationError({
|
|
512
|
+
message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by phase '${task.id}/${other.id}'`,
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const content = yield* contentWith(record, data)
|
|
518
|
+
if (content === record.content)
|
|
519
|
+
return result(root, "phase.update", "phase", id, [])
|
|
520
|
+
yield* applyWritePlan({
|
|
521
|
+
root,
|
|
522
|
+
writes: [{ path: record.path, content }],
|
|
523
|
+
})
|
|
524
|
+
return result(root, "phase.update", "phase", id, [record.path])
|
|
525
|
+
}),
|
|
526
|
+
|
|
527
|
+
mutateTaskDependency: (
|
|
528
|
+
operation: "add" | "remove",
|
|
529
|
+
id: string,
|
|
530
|
+
dependencyId: string,
|
|
531
|
+
startPath: string = process.cwd(),
|
|
532
|
+
) =>
|
|
533
|
+
Effect.gen(function* () {
|
|
534
|
+
const workbase = yield* WorkbaseService
|
|
535
|
+
const tasks = yield* TaskService
|
|
536
|
+
const epics = yield* EpicService
|
|
537
|
+
const root = yield* workbase.discover(startPath)
|
|
538
|
+
const task = yield* tasks.show(id, root)
|
|
539
|
+
if (!task.data.epic)
|
|
540
|
+
return yield* new GraphMutationError({
|
|
541
|
+
message: `Task '${id}' has no parent epic; task dependencies are scoped to an epic`,
|
|
542
|
+
})
|
|
543
|
+
const epic = yield* epics.show(task.data.epic, root)
|
|
544
|
+
const declaration = epic.data.tasks.find((item) => item.id === id)
|
|
545
|
+
if (!declaration)
|
|
546
|
+
return yield* new GraphMutationError({
|
|
547
|
+
message: `Epic '${epic.id}' does not list task '${id}'; run agency validate for repair details`,
|
|
548
|
+
})
|
|
549
|
+
if (operation === "add") {
|
|
550
|
+
const dependency = yield* tasks.show(dependencyId, root)
|
|
551
|
+
if (dependency.data.epic !== epic.id) {
|
|
552
|
+
return yield* new GraphMutationError({
|
|
553
|
+
message: `Task dependency '${dependencyId}' does not belong to epic '${epic.id}'`,
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const current = declaration.dependsOn ?? []
|
|
558
|
+
if (operation === "add" && current.includes(dependencyId))
|
|
559
|
+
return yield* new GraphMutationError({
|
|
560
|
+
message: `Task '${id}' already depends on '${dependencyId}'`,
|
|
561
|
+
})
|
|
562
|
+
if (operation === "remove" && !current.includes(dependencyId))
|
|
563
|
+
return yield* new GraphMutationError({
|
|
564
|
+
message: `Task '${id}' does not depend on '${dependencyId}'`,
|
|
565
|
+
})
|
|
566
|
+
const dependencies =
|
|
567
|
+
operation === "add"
|
|
568
|
+
? [...current, dependencyId]
|
|
569
|
+
: current.filter((item) => item !== dependencyId)
|
|
570
|
+
const data = {
|
|
571
|
+
...epic.data,
|
|
572
|
+
tasks: epic.data.tasks.map((item) =>
|
|
573
|
+
item.id === id
|
|
574
|
+
? {
|
|
575
|
+
id: item.id,
|
|
576
|
+
...(dependencies.length ? { dependsOn: dependencies } : {}),
|
|
577
|
+
}
|
|
578
|
+
: item,
|
|
579
|
+
),
|
|
580
|
+
}
|
|
581
|
+
yield* assertDependencies(data.tasks, "Tasks")
|
|
582
|
+
const content = yield* contentWith(epic, data)
|
|
583
|
+
yield* applyWritePlan({
|
|
584
|
+
root,
|
|
585
|
+
writes: [{ path: epic.path, content }],
|
|
586
|
+
})
|
|
587
|
+
return result(root, `task.dependency.${operation}`, "task", id, [
|
|
588
|
+
epic.path,
|
|
589
|
+
])
|
|
590
|
+
}),
|
|
591
|
+
|
|
592
|
+
mutatePhaseDependency: (
|
|
593
|
+
operation: "add" | "remove",
|
|
594
|
+
taskId: string,
|
|
595
|
+
id: string,
|
|
596
|
+
dependencyId: string,
|
|
597
|
+
startPath: string = process.cwd(),
|
|
598
|
+
) =>
|
|
599
|
+
Effect.gen(function* () {
|
|
600
|
+
const workbase = yield* WorkbaseService
|
|
601
|
+
const tasks = yield* TaskService
|
|
602
|
+
const root = yield* workbase.discover(startPath)
|
|
603
|
+
const task = yield* tasks.show(taskId, root)
|
|
604
|
+
if (!("phases" in task.data))
|
|
605
|
+
return yield* new GraphMutationError({
|
|
606
|
+
message: `Task '${taskId}' does not have phases`,
|
|
607
|
+
})
|
|
608
|
+
const declaration = task.data.phases.find((item) => item.id === id)
|
|
609
|
+
if (!declaration)
|
|
610
|
+
return yield* new GraphMutationError({
|
|
611
|
+
message: `Phase '${id}' does not exist on task '${taskId}'`,
|
|
612
|
+
})
|
|
613
|
+
if (operation === "add") {
|
|
614
|
+
yield* (yield* PhaseService).show(taskId, dependencyId, root)
|
|
615
|
+
}
|
|
616
|
+
const current = declaration.dependsOn ?? []
|
|
617
|
+
if (operation === "add" && current.includes(dependencyId))
|
|
618
|
+
return yield* new GraphMutationError({
|
|
619
|
+
message: `Phase '${id}' already depends on '${dependencyId}'`,
|
|
620
|
+
})
|
|
621
|
+
if (operation === "remove" && !current.includes(dependencyId))
|
|
622
|
+
return yield* new GraphMutationError({
|
|
623
|
+
message: `Phase '${id}' does not depend on '${dependencyId}'`,
|
|
624
|
+
})
|
|
625
|
+
const dependencies =
|
|
626
|
+
operation === "add"
|
|
627
|
+
? [...current, dependencyId]
|
|
628
|
+
: current.filter((item) => item !== dependencyId)
|
|
629
|
+
const data = {
|
|
630
|
+
...task.data,
|
|
631
|
+
phases: task.data.phases.map((item) =>
|
|
632
|
+
item.id === id
|
|
633
|
+
? {
|
|
634
|
+
id: item.id,
|
|
635
|
+
...(dependencies.length ? { dependsOn: dependencies } : {}),
|
|
636
|
+
}
|
|
637
|
+
: item,
|
|
638
|
+
),
|
|
639
|
+
}
|
|
640
|
+
yield* assertDependencies(data.phases, "Phases")
|
|
641
|
+
const content = yield* contentWith(task, data)
|
|
642
|
+
yield* applyWritePlan({
|
|
643
|
+
root,
|
|
644
|
+
writes: [{ path: task.path, content }],
|
|
645
|
+
})
|
|
646
|
+
return result(root, `phase.dependency.${operation}`, "phase", id, [
|
|
647
|
+
task.path,
|
|
648
|
+
])
|
|
649
|
+
}),
|
|
650
|
+
|
|
651
|
+
renameEpic: (
|
|
652
|
+
id: string,
|
|
653
|
+
newId: string,
|
|
654
|
+
startPath: string = process.cwd(),
|
|
655
|
+
) =>
|
|
656
|
+
Effect.gen(function* () {
|
|
657
|
+
const workbase = yield* WorkbaseService
|
|
658
|
+
const epics = yield* EpicService
|
|
659
|
+
const tasks = yield* TaskService
|
|
660
|
+
const fs = yield* FileSystemService
|
|
661
|
+
const root = yield* workbase.discover(startPath)
|
|
662
|
+
yield* decodeId(newId, "epic")
|
|
663
|
+
const epic = yield* epics.show(id, root)
|
|
664
|
+
const from = dirname(epic.path)
|
|
665
|
+
const to = join(root, "epics", newId)
|
|
666
|
+
if (yield* fs.exists(to))
|
|
667
|
+
return yield* new GraphMutationError({
|
|
668
|
+
message: `Epic '${newId}' already exists`,
|
|
669
|
+
})
|
|
670
|
+
const allTasks = yield* tasks.list(root)
|
|
671
|
+
const declared = new Set(epic.data.tasks.map((item) => item.id))
|
|
672
|
+
for (const declaration of epic.data.tasks) {
|
|
673
|
+
const child = allTasks.find((item) => item.id === declaration.id)
|
|
674
|
+
if (!child || child.data.epic !== id) {
|
|
675
|
+
return yield* new GraphMutationError({
|
|
676
|
+
message: `Cannot rename epic '${id}': task '${declaration.id}' does not have a matching parent backlink; run agency validate for details`,
|
|
677
|
+
})
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const unlisted = allTasks.find(
|
|
681
|
+
(item) => item.data.epic === id && !declared.has(item.id),
|
|
682
|
+
)
|
|
683
|
+
if (unlisted) {
|
|
684
|
+
return yield* new GraphMutationError({
|
|
685
|
+
message: `Cannot rename epic '${id}': task '${unlisted.id}' is not listed by the epic; run agency validate for details`,
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
const writes: { path: string; content: string }[] = []
|
|
689
|
+
for (const task of allTasks) {
|
|
690
|
+
if (task.data.epic !== id) continue
|
|
691
|
+
const data = yield* decode(
|
|
692
|
+
TaskFrontmatter,
|
|
693
|
+
{ ...task.data, epic: newId },
|
|
694
|
+
"task metadata",
|
|
695
|
+
)
|
|
696
|
+
writes.push({
|
|
697
|
+
path: task.path,
|
|
698
|
+
content: yield* contentWith(task, data),
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
yield* applyWritePlan({ root, writes, move: { from, to } })
|
|
702
|
+
return result(
|
|
703
|
+
root,
|
|
704
|
+
"epic.rename",
|
|
705
|
+
"epic",
|
|
706
|
+
newId,
|
|
707
|
+
[join(to, "EPIC.md"), ...writes.map((write) => write.path)],
|
|
708
|
+
id,
|
|
709
|
+
)
|
|
710
|
+
}),
|
|
711
|
+
|
|
712
|
+
renameTask: (
|
|
713
|
+
id: string,
|
|
714
|
+
newId: string,
|
|
715
|
+
startPath: string = process.cwd(),
|
|
716
|
+
) =>
|
|
717
|
+
Effect.gen(function* () {
|
|
718
|
+
const workbase = yield* WorkbaseService
|
|
719
|
+
const epics = yield* EpicService
|
|
720
|
+
const tasks = yield* TaskService
|
|
721
|
+
const fs = yield* FileSystemService
|
|
722
|
+
const root = yield* workbase.discover(startPath)
|
|
723
|
+
yield* decodeId(newId, "task")
|
|
724
|
+
const task = yield* tasks.show(id, root)
|
|
725
|
+
const from = dirname(task.path)
|
|
726
|
+
const to = join(root, "tasks", newId)
|
|
727
|
+
if (yield* fs.exists(to))
|
|
728
|
+
return yield* new GraphMutationError({
|
|
729
|
+
message: `Task '${newId}' already exists`,
|
|
730
|
+
})
|
|
731
|
+
if ("claim" in task.data && task.data.claim?.state === "active")
|
|
732
|
+
return yield* new GraphMutationError({
|
|
733
|
+
message: `Task '${id}' has an active claim; release or finish it before renaming`,
|
|
734
|
+
})
|
|
735
|
+
if (yield* fs.isDirectory(join(from, "code")))
|
|
736
|
+
return yield* new GraphMutationError({
|
|
737
|
+
message: `Task '${id}' has a materialized worktree; remove it with Agency before renaming`,
|
|
738
|
+
})
|
|
739
|
+
if ("phases" in task.data) {
|
|
740
|
+
for (const phase of task.data.phases) {
|
|
741
|
+
const record = yield* (yield* PhaseService).show(
|
|
742
|
+
id,
|
|
743
|
+
phase.id,
|
|
744
|
+
root,
|
|
745
|
+
)
|
|
746
|
+
if (record.data.claim?.state === "active")
|
|
747
|
+
return yield* new GraphMutationError({
|
|
748
|
+
message: `Phase '${phase.id}' has an active claim; release or finish it before renaming task '${id}'`,
|
|
749
|
+
})
|
|
750
|
+
if (yield* fs.isDirectory(join(dirname(record.path), "code")))
|
|
751
|
+
return yield* new GraphMutationError({
|
|
752
|
+
message: `Phase '${phase.id}' has a materialized worktree; remove it with Agency before renaming task '${id}'`,
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (task.data.epic) {
|
|
757
|
+
const parent = yield* epics.show(task.data.epic, root)
|
|
758
|
+
if (!parent.data.tasks.some((item) => item.id === id)) {
|
|
759
|
+
return yield* new GraphMutationError({
|
|
760
|
+
message: `Cannot rename task '${id}': parent epic '${parent.id}' does not list it; run agency validate for details`,
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const writes: { path: string; content: string }[] = []
|
|
765
|
+
for (const epic of yield* epics.list(root)) {
|
|
766
|
+
if (
|
|
767
|
+
!epic.data.tasks.some(
|
|
768
|
+
(item) => item.id === id || item.dependsOn?.includes(id),
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
continue
|
|
772
|
+
const data = {
|
|
773
|
+
...epic.data,
|
|
774
|
+
tasks: epic.data.tasks.map((item) => ({
|
|
775
|
+
...item,
|
|
776
|
+
id: item.id === id ? newId : item.id,
|
|
777
|
+
...(item.dependsOn
|
|
778
|
+
? {
|
|
779
|
+
dependsOn: item.dependsOn.map((dependency) =>
|
|
780
|
+
dependency === id ? newId : dependency,
|
|
781
|
+
),
|
|
782
|
+
}
|
|
783
|
+
: {}),
|
|
784
|
+
})),
|
|
785
|
+
}
|
|
786
|
+
yield* assertDependencies(data.tasks, "Tasks")
|
|
787
|
+
writes.push({
|
|
788
|
+
path: epic.path,
|
|
789
|
+
content: yield* contentWith(epic, data),
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
yield* applyWritePlan({ root, writes, move: { from, to } })
|
|
793
|
+
return result(
|
|
794
|
+
root,
|
|
795
|
+
"task.rename",
|
|
796
|
+
"task",
|
|
797
|
+
newId,
|
|
798
|
+
[join(to, "TASK.md"), ...writes.map((write) => write.path)],
|
|
799
|
+
id,
|
|
800
|
+
)
|
|
801
|
+
}),
|
|
802
|
+
|
|
803
|
+
renamePhase: (
|
|
804
|
+
taskId: string,
|
|
805
|
+
id: string,
|
|
806
|
+
newId: string,
|
|
807
|
+
startPath: string = process.cwd(),
|
|
808
|
+
) =>
|
|
809
|
+
Effect.gen(function* () {
|
|
810
|
+
const workbase = yield* WorkbaseService
|
|
811
|
+
const tasks = yield* TaskService
|
|
812
|
+
const phases = yield* PhaseService
|
|
813
|
+
const fs = yield* FileSystemService
|
|
814
|
+
const root = yield* workbase.discover(startPath)
|
|
815
|
+
yield* decodeId(newId, "phase")
|
|
816
|
+
const task = yield* tasks.show(taskId, root)
|
|
817
|
+
if (!("phases" in task.data))
|
|
818
|
+
return yield* new GraphMutationError({
|
|
819
|
+
message: `Task '${taskId}' does not have phases`,
|
|
820
|
+
})
|
|
821
|
+
if (!task.data.phases.some((item) => item.id === id)) {
|
|
822
|
+
return yield* new GraphMutationError({
|
|
823
|
+
message: `Cannot rename phase '${id}': task '${taskId}' does not list it; run agency validate for details`,
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
const phase = yield* phases.show(taskId, id, root)
|
|
827
|
+
if (phase.data.claim?.state === "active")
|
|
828
|
+
return yield* new GraphMutationError({
|
|
829
|
+
message: `Phase '${id}' has an active claim; release or finish it before renaming`,
|
|
830
|
+
})
|
|
831
|
+
const from = dirname(phase.path)
|
|
832
|
+
const to = join(dirname(from), newId)
|
|
833
|
+
if (yield* fs.exists(to))
|
|
834
|
+
return yield* new GraphMutationError({
|
|
835
|
+
message: `Phase '${newId}' already exists on task '${taskId}'`,
|
|
836
|
+
})
|
|
837
|
+
if (yield* fs.isDirectory(join(from, "code")))
|
|
838
|
+
return yield* new GraphMutationError({
|
|
839
|
+
message: `Phase '${id}' has a materialized worktree; remove it with Agency before renaming`,
|
|
840
|
+
})
|
|
841
|
+
const data = {
|
|
842
|
+
...task.data,
|
|
843
|
+
phases: task.data.phases.map((item) => ({
|
|
844
|
+
...item,
|
|
845
|
+
id: item.id === id ? newId : item.id,
|
|
846
|
+
...(item.dependsOn
|
|
847
|
+
? {
|
|
848
|
+
dependsOn: item.dependsOn.map((dependency) =>
|
|
849
|
+
dependency === id ? newId : dependency,
|
|
850
|
+
),
|
|
851
|
+
}
|
|
852
|
+
: {}),
|
|
853
|
+
})),
|
|
854
|
+
}
|
|
855
|
+
yield* assertDependencies(data.phases, "Phases")
|
|
856
|
+
const content = yield* contentWith(task, data)
|
|
857
|
+
yield* applyWritePlan({
|
|
858
|
+
root,
|
|
859
|
+
writes: [{ path: task.path, content }],
|
|
860
|
+
move: { from, to },
|
|
861
|
+
})
|
|
862
|
+
return result(
|
|
863
|
+
root,
|
|
864
|
+
"phase.rename",
|
|
865
|
+
"phase",
|
|
866
|
+
newId,
|
|
867
|
+
[join(to, "PHASE.md"), task.path],
|
|
868
|
+
id,
|
|
869
|
+
)
|
|
870
|
+
}),
|
|
871
|
+
|
|
872
|
+
moveTask: (
|
|
873
|
+
id: string,
|
|
874
|
+
epicId: string | null,
|
|
875
|
+
startPath: string = process.cwd(),
|
|
876
|
+
) =>
|
|
877
|
+
Effect.gen(function* () {
|
|
878
|
+
const workbase = yield* WorkbaseService
|
|
879
|
+
const tasks = yield* TaskService
|
|
880
|
+
const epics = yield* EpicService
|
|
881
|
+
const root = yield* workbase.discover(startPath)
|
|
882
|
+
const task = yield* tasks.show(id, root)
|
|
883
|
+
const sourceId = task.data.epic
|
|
884
|
+
if (sourceId === epicId)
|
|
885
|
+
return result(root, "task.move", "task", id, [])
|
|
886
|
+
const source = sourceId
|
|
887
|
+
? yield* epics.show(sourceId, root)
|
|
888
|
+
: undefined
|
|
889
|
+
const target = epicId ? yield* epics.show(epicId, root) : undefined
|
|
890
|
+
const declaration = source?.data.tasks.find((item) => item.id === id)
|
|
891
|
+
if (source && !declaration)
|
|
892
|
+
return yield* new GraphMutationError({
|
|
893
|
+
message: `Epic '${source.id}' does not list task '${id}'; run agency validate for repair details`,
|
|
894
|
+
})
|
|
895
|
+
const dependent = source?.data.tasks.find((item) =>
|
|
896
|
+
item.dependsOn?.includes(id),
|
|
897
|
+
)
|
|
898
|
+
if ((declaration?.dependsOn?.length ?? 0) > 0 || dependent) {
|
|
899
|
+
return yield* new GraphMutationError({
|
|
900
|
+
message: `Cannot move task '${id}' while scoped dependencies exist; remove ${dependent ? `dependency from '${dependent.id}'` : `its dependencies (${declaration!.dependsOn!.join(", ")})`} first`,
|
|
901
|
+
})
|
|
902
|
+
}
|
|
903
|
+
if (target?.data.tasks.some((item) => item.id === id))
|
|
904
|
+
return yield* new GraphMutationError({
|
|
905
|
+
message: `Epic '${target.id}' already lists task '${id}'`,
|
|
906
|
+
})
|
|
907
|
+
const writes: { path: string; content: string }[] = []
|
|
908
|
+
if (source) {
|
|
909
|
+
const data = {
|
|
910
|
+
...source.data,
|
|
911
|
+
tasks: source.data.tasks.filter((item) => item.id !== id),
|
|
912
|
+
}
|
|
913
|
+
yield* assertDependencies(data.tasks, "Tasks")
|
|
914
|
+
writes.push({
|
|
915
|
+
path: source.path,
|
|
916
|
+
content: yield* contentWith(source, data),
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
if (target) {
|
|
920
|
+
const data = {
|
|
921
|
+
...target.data,
|
|
922
|
+
tasks: [...target.data.tasks, { id }],
|
|
923
|
+
}
|
|
924
|
+
yield* assertDependencies(data.tasks, "Tasks")
|
|
925
|
+
writes.push({
|
|
926
|
+
path: target.path,
|
|
927
|
+
content: yield* contentWith(target, data),
|
|
928
|
+
})
|
|
929
|
+
}
|
|
930
|
+
const taskData = yield* decode(
|
|
931
|
+
TaskFrontmatter,
|
|
932
|
+
epicId
|
|
933
|
+
? { ...task.data, epic: epicId }
|
|
934
|
+
: { ...task.data, epic: undefined },
|
|
935
|
+
"task metadata",
|
|
936
|
+
)
|
|
937
|
+
writes.push({
|
|
938
|
+
path: task.path,
|
|
939
|
+
content: yield* contentWith(task, taskData),
|
|
940
|
+
})
|
|
941
|
+
yield* applyWritePlan({ root, writes })
|
|
942
|
+
return result(
|
|
943
|
+
root,
|
|
944
|
+
"task.move",
|
|
945
|
+
"task",
|
|
946
|
+
id,
|
|
947
|
+
writes.map((write) => write.path),
|
|
948
|
+
)
|
|
949
|
+
}),
|
|
950
|
+
}),
|
|
951
|
+
},
|
|
952
|
+
) {}
|