@markjaquith/agency 2.21.0 → 2.23.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.
@@ -9,12 +9,16 @@ import {
9
9
  unlink,
10
10
  writeFile,
11
11
  } from "node:fs/promises"
12
- import { basename, dirname, join } from "node:path"
12
+ import { basename, dirname, join, relative } from "node:path"
13
13
  import { PhaseService } from "./PhaseService"
14
14
  import { TaskService } from "./TaskService"
15
15
  import { WorkbaseService } from "./WorkbaseService"
16
16
  import { FileSystemService } from "./FileSystemService"
17
- import { documentRevision } from "../workbase/document-revision"
17
+ import {
18
+ documentRevision,
19
+ isDocumentRevision,
20
+ RevisionConflictError,
21
+ } from "../workbase/document-revision"
18
22
  import {
19
23
  formatMarkdownDocument,
20
24
  parseFrontmatterSync,
@@ -23,6 +27,7 @@ import {
23
27
  PhaseFrontmatter,
24
28
  TaskFrontmatter,
25
29
  type ClaimRecord,
30
+ type PullRequestRecord,
26
31
  type PhaseFrontmatter as PhaseData,
27
32
  type TaskFrontmatter as TaskData,
28
33
  } from "../workbase/schemas"
@@ -32,14 +37,6 @@ class ClaimError extends Data.TaggedError("ClaimError")<{
32
37
  readonly target?: string
33
38
  }> {}
34
39
 
35
- class RevisionConflictError extends Data.TaggedError("RevisionConflictError")<{
36
- readonly message: string
37
- readonly target: string
38
- readonly expectedRevision: string
39
- readonly actualRevision: string
40
- readonly claim?: ClaimRecord
41
- }> {}
42
-
43
40
  class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
44
41
  readonly message: string
45
42
  readonly target: string
@@ -58,6 +55,7 @@ class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
58
55
 
59
56
  interface ClaimTarget {
60
57
  readonly kind: "task" | "phase"
58
+ readonly root: string
61
59
  readonly taskId: string
62
60
  readonly phaseId?: string
63
61
  readonly path: string
@@ -98,7 +96,7 @@ interface ReconcileInput {
98
96
  readonly taskId: string
99
97
  readonly phaseId?: string
100
98
  readonly revision: string
101
- readonly pr?: string
99
+ readonly pr?: string | PullRequestRecord
102
100
  readonly status?: "done"
103
101
  }
104
102
 
@@ -148,7 +146,7 @@ const decodeExecution = (target: ClaimTarget, input: unknown) => {
148
146
  }
149
147
 
150
148
  const assertRevision = (revision: string) => {
151
- if (!/^[a-f0-9]{64}$/.test(revision)) {
149
+ if (!isDocumentRevision(revision)) {
152
150
  throw new ClaimError({
153
151
  message: "Revision must be a 64-character SHA-256 hash",
154
152
  })
@@ -168,8 +166,7 @@ const isUnexpired = (claim: ClaimRecord, now: Date) =>
168
166
  claim.state === "active" &&
169
167
  (claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
170
168
 
171
- const acquireLock = async (path: string) => {
172
- const lockPath = `${path}.claim.lock`
169
+ const acquireLock = async (lockPath: string, label: string) => {
173
170
  for (let attempt = 0; attempt < 1_750; attempt += 1) {
174
171
  try {
175
172
  const handle = await open(lockPath, "wx")
@@ -191,7 +188,7 @@ const acquireLock = async (path: string) => {
191
188
  await Bun.sleep(20)
192
189
  }
193
190
  }
194
- throw new ClaimError({ message: `Timed out waiting to update ${path}` })
191
+ throw new ClaimError({ message: `Timed out waiting to update ${label}` })
195
192
  }
196
193
 
197
194
  const updateAtomically = async <T>(
@@ -206,18 +203,24 @@ const updateAtomically = async <T>(
206
203
  now: Date,
207
204
  ) => {
208
205
  assertRevision(expectedRevision)
209
- const { handle, lockPath } = await acquireLock(target.path)
206
+ const graphLock = await acquireLock(
207
+ join(target.root, ".agency-graph-mutation.lock"),
208
+ target.root,
209
+ )
210
+ let documentLock: Awaited<ReturnType<typeof acquireLock>> | undefined
210
211
  let temporaryPath: string | undefined
211
212
  try {
213
+ documentLock = await acquireLock(`${target.path}.claim.lock`, target.path)
212
214
  const content = await readFile(target.path, "utf8")
213
- const actualRevision = documentRevision(content)
215
+ const currentRevision = documentRevision(content)
214
216
  const parsed = parseFrontmatterSync(content, target.path)
215
217
  const current = decodeExecution(target, parsed.data)
216
- if (actualRevision !== expectedRevision) {
218
+ if (currentRevision !== expectedRevision) {
217
219
  throw new RevisionConflictError({
220
+ path: relative(target.root, target.path),
218
221
  target: target.label,
219
222
  expectedRevision,
220
- actualRevision,
223
+ currentRevision,
221
224
  claim: current.claim,
222
225
  message: `Revision conflict for ${target.label}`,
223
226
  })
@@ -234,13 +237,15 @@ const updateAtomically = async <T>(
234
237
  return {
235
238
  ...result,
236
239
  target: target.label,
237
- previousRevision: actualRevision,
240
+ previousRevision: currentRevision,
238
241
  revision: documentRevision(updatedContent),
239
242
  }
240
243
  } finally {
241
244
  if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
242
- await handle.close().catch(() => undefined)
243
- await unlink(lockPath).catch(() => undefined)
245
+ await documentLock?.handle.close().catch(() => undefined)
246
+ if (documentLock) await unlink(documentLock.lockPath).catch(() => undefined)
247
+ await graphLock.handle.close().catch(() => undefined)
248
+ await unlink(graphLock.lockPath).catch(() => undefined)
244
249
  }
245
250
  }
246
251
 
@@ -277,6 +282,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
277
282
  const target: ClaimTarget = phaseId
278
283
  ? {
279
284
  kind: "phase",
285
+ root,
280
286
  taskId: task.id,
281
287
  phaseId,
282
288
  path: phase!.path,
@@ -284,6 +290,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
284
290
  }
285
291
  : {
286
292
  kind: "task",
293
+ root,
287
294
  taskId: task.id,
288
295
  path: task.path,
289
296
  label: `task '${task.id}'`,
@@ -341,7 +348,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
341
348
 
342
349
  reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
343
350
  Effect.gen(function* () {
344
- if (input.pr !== undefined && !PR_URL.test(input.pr)) {
351
+ if (
352
+ typeof input.pr === "string" &&
353
+ input.pr !== undefined &&
354
+ !PR_URL.test(input.pr)
355
+ ) {
345
356
  return yield* new ClaimError({
346
357
  message: `Invalid GitHub pull request URL: ${input.pr}`,
347
358
  })
@@ -3,9 +3,11 @@ import { Data, Effect, Either } from "effect"
3
3
  import { join, relative, resolve, sep } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
6
+ import { normalizePullRequestRecord } from "../workbase/delivery-command"
6
7
  import { RepositoryService } from "./RepositoryService"
7
8
  import { aggregateProgress, readinessState } from "../readiness"
8
9
  import { parseFrontmatter } from "../workbase/frontmatter"
10
+ import { documentRevision } from "../workbase/document-revision"
9
11
  import {
10
12
  EpicFrontmatter,
11
13
  PhaseFrontmatter,
@@ -77,9 +79,6 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
77
79
  : { ok: true as const, value: result.right }
78
80
  }
79
81
 
80
- const hash = (content: string) =>
81
- new Bun.CryptoHasher("sha256").update(content).digest("hex")
82
-
83
82
  const isWithin = (root: string, path: string) => {
84
83
  const child = relative(root, path)
85
84
  return child === "" || (!child.startsWith(`..${sep}`) && child !== "..")
@@ -204,7 +203,7 @@ export class ContextService extends Effect.Service<ContextService>()(
204
203
  return {
205
204
  id,
206
205
  path,
207
- sha256: hash(content),
206
+ sha256: documentRevision(content),
208
207
  data: decoded.value,
209
208
  body: parsed.body,
210
209
  } satisfies Document<Schema.Schema.Type<S>>
@@ -278,7 +277,7 @@ export class ContextService extends Effect.Service<ContextService>()(
278
277
  taskDocuments.set(entry.name, {
279
278
  id: entry.name,
280
279
  path,
281
- sha256: hash(content),
280
+ sha256: documentRevision(content),
282
281
  data: decoded.value,
283
282
  body: parsed.right.body,
284
283
  })
@@ -302,7 +301,7 @@ export class ContextService extends Effect.Service<ContextService>()(
302
301
  phaseDocuments.set(`${entry.name}/${phaseEntry.name}`, {
303
302
  id: phaseEntry.name,
304
303
  path: phasePath,
305
- sha256: hash(phaseContent),
304
+ sha256: documentRevision(phaseContent),
306
305
  data: phaseDecoded.value,
307
306
  body: phaseParsed.right.body,
308
307
  })
@@ -857,10 +856,9 @@ export class ContextService extends Effect.Service<ContextService>()(
857
856
  references: referenceCheckouts,
858
857
  warnings: inspectionWarnings,
859
858
  },
860
- pr: {
861
- url: executionData?.pr ?? null,
862
- state: executionData?.pr ? "recorded" : "none",
863
- },
859
+ pr: executionData?.pr
860
+ ? normalizePullRequestRecord(executionData.pr)
861
+ : { url: null, state: "none" },
864
862
  validation: {
865
863
  valid: validation.valid,
866
864
  warnings: validation.issues,
@@ -12,6 +12,7 @@ import {
12
12
  formatMarkdownDocument,
13
13
  parseFrontmatter,
14
14
  } from "../workbase/frontmatter"
15
+ import { documentRevision } from "../workbase/document-revision"
15
16
 
16
17
  class EpicError extends Data.TaggedError("EpicError")<{
17
18
  readonly message: string
@@ -21,6 +22,7 @@ export interface EpicRecord {
21
22
  readonly id: string
22
23
  readonly path: string
23
24
  readonly content: string
25
+ readonly revision: string
24
26
  readonly data: Schema.Schema.Type<typeof EpicFrontmatter>
25
27
  }
26
28
 
@@ -90,7 +92,13 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
90
92
  `# ${title}\n\nDescribe the epic outcome.`,
91
93
  )
92
94
  yield* fs.writeFile(path, content)
93
- return { id: validId, path, content, data } satisfies EpicRecord
95
+ return {
96
+ id: validId,
97
+ path,
98
+ content,
99
+ revision: documentRevision(content),
100
+ data,
101
+ } satisfies EpicRecord
94
102
  }),
95
103
 
96
104
  list: (startPath: string = process.cwd()) =>
@@ -113,7 +121,13 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
113
121
  const content = yield* fs.readFile(path)
114
122
  const parsed = yield* parseFrontmatter(content, path)
115
123
  const data = yield* decodeEpic(parsed.data)
116
- records.push({ id: entry.name, path, content, data })
124
+ records.push({
125
+ id: entry.name,
126
+ path,
127
+ content,
128
+ revision: documentRevision(content),
129
+ data,
130
+ })
117
131
  }
118
132
  return records
119
133
  }),
@@ -280,6 +280,37 @@ describe("GraphMutationService", () => {
280
280
  )
281
281
  })
282
282
 
283
+ test("rejects stale guarded moves before changing any document", async () => {
284
+ const paths = [
285
+ join(root, "tasks/alpha/TASK.md"),
286
+ join(root, "epics/first-epic/EPIC.md"),
287
+ join(root, "epics/second-epic/EPIC.md"),
288
+ ]
289
+ const before = await Promise.all(paths.map((path) => Bun.file(path).text()))
290
+ let conflict: unknown
291
+ try {
292
+ await runTestEffect(
293
+ Effect.gen(function* () {
294
+ return yield* (yield* GraphMutationService).moveTask(
295
+ "alpha",
296
+ "second-epic",
297
+ root,
298
+ "0".repeat(64),
299
+ )
300
+ }),
301
+ )
302
+ } catch (error) {
303
+ conflict = error
304
+ }
305
+
306
+ expect(String(conflict)).toContain(
307
+ "Revision conflict for tasks/alpha/TASK.md",
308
+ )
309
+ expect(
310
+ await Promise.all(paths.map((path) => Bun.file(path).text())),
311
+ ).toEqual(before)
312
+ })
313
+
283
314
  test("refuses a rename that would invalidate a materialized worktree", async () => {
284
315
  await mkdir(join(root, "tasks/alpha/code/agency"), { recursive: true })
285
316
  await expect(
@@ -2,9 +2,9 @@ import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
3
  import { open, rename, rm } from "node:fs/promises"
4
4
  import { dirname, join, relative } from "node:path"
5
- import { EpicService } from "./EpicService"
5
+ import { EpicService, type EpicRecord } from "./EpicService"
6
6
  import { FileSystemService } from "./FileSystemService"
7
- import { PhaseService } from "./PhaseService"
7
+ import { PhaseService, type PhaseRecord } from "./PhaseService"
8
8
  import { TaskService } from "./TaskService"
9
9
  import { WorkbaseService } from "./WorkbaseService"
10
10
  import {
@@ -23,6 +23,10 @@ import {
23
23
  parseFrontmatter,
24
24
  } from "../workbase/frontmatter"
25
25
  import { validateDependencies } from "../workbase/dependency-graph"
26
+ import {
27
+ documentRevision,
28
+ RevisionConflictError,
29
+ } from "../workbase/document-revision"
26
30
 
27
31
  class GraphMutationError extends Data.TaggedError("GraphMutationError")<{
28
32
  readonly message: string
@@ -47,6 +51,10 @@ interface MutationResult {
47
51
 
48
52
  interface WritePlan {
49
53
  readonly root: string
54
+ readonly preconditions: readonly {
55
+ readonly path: string
56
+ readonly revision: string
57
+ }[]
50
58
  readonly writes: readonly {
51
59
  readonly path: string
52
60
  readonly content: string
@@ -114,7 +122,7 @@ const exists = async (path: string) => {
114
122
  }
115
123
  }
116
124
 
117
- const applyWritePlan = ({ root, writes, move }: WritePlan) =>
125
+ const applyWritePlan = ({ root, preconditions, writes, move }: WritePlan) =>
118
126
  Effect.tryPromise({
119
127
  try: async () => {
120
128
  const lockPath = join(root, ".agency-graph-mutation.lock")
@@ -139,6 +147,18 @@ const applyWritePlan = ({ root, writes, move }: WritePlan) =>
139
147
  let moved = false
140
148
  let rollbackFailed = false
141
149
  try {
150
+ for (const precondition of preconditions) {
151
+ const content = await Bun.file(precondition.path).text()
152
+ const currentRevision = documentRevision(content)
153
+ if (currentRevision !== precondition.revision) {
154
+ throw new RevisionConflictError({
155
+ path: relative(root, precondition.path),
156
+ expectedRevision: precondition.revision,
157
+ currentRevision,
158
+ message: `Revision conflict for ${relative(root, precondition.path)}`,
159
+ })
160
+ }
161
+ }
142
162
  for (const write of staged) await Bun.write(write.stage, write.content)
143
163
  if (move) {
144
164
  await rename(move.from, move.to)
@@ -195,7 +215,8 @@ const applyWritePlan = ({ root, writes, move }: WritePlan) =>
195
215
  }
196
216
  },
197
217
  catch: (cause) =>
198
- cause instanceof GraphMutationError
218
+ cause instanceof GraphMutationError ||
219
+ cause instanceof RevisionConflictError
199
220
  ? cause
200
221
  : new GraphMutationError({
201
222
  message:
@@ -212,6 +233,19 @@ const contentWith = (
212
233
  Effect.map((parsed) => formatMarkdownDocument(data, parsed.body)),
213
234
  )
214
235
 
236
+ type RevisionedRecord = {
237
+ readonly path: string
238
+ readonly revision: string
239
+ }
240
+
241
+ const precondition = (
242
+ record: RevisionedRecord,
243
+ ifRevision?: string,
244
+ ): { readonly path: string; readonly revision: string } => ({
245
+ path: record.path,
246
+ revision: ifRevision ?? record.revision,
247
+ })
248
+
215
249
  const result = (
216
250
  root: string,
217
251
  operation: string,
@@ -250,6 +284,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
250
284
  id: string,
251
285
  updates: EpicUpdates,
252
286
  startPath: string = process.cwd(),
287
+ ifRevision?: string,
253
288
  ) =>
254
289
  Effect.gen(function* () {
255
290
  const workbase = yield* WorkbaseService
@@ -289,10 +324,18 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
289
324
  })
290
325
  }
291
326
  const content = yield* contentWith(record, data)
292
- if (content === record.content)
327
+ if (content === record.content) {
328
+ if (ifRevision)
329
+ yield* applyWritePlan({
330
+ root,
331
+ preconditions: [precondition(record, ifRevision)],
332
+ writes: [],
333
+ })
293
334
  return result(root, "epic.update", "epic", id, [])
335
+ }
294
336
  yield* applyWritePlan({
295
337
  root,
338
+ preconditions: [precondition(record, ifRevision)],
296
339
  writes: [{ path: record.path, content }],
297
340
  })
298
341
  return result(root, "epic.update", "epic", id, [record.path])
@@ -302,6 +345,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
302
345
  id: string,
303
346
  updates: TaskUpdates,
304
347
  startPath: string = process.cwd(),
348
+ ifRevision?: string,
305
349
  ) =>
306
350
  Effect.gen(function* () {
307
351
  const workbase = yield* WorkbaseService
@@ -410,10 +454,18 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
410
454
  }
411
455
  }
412
456
  const content = yield* contentWith(record, data)
413
- if (content === record.content)
457
+ if (content === record.content) {
458
+ if (ifRevision)
459
+ yield* applyWritePlan({
460
+ root,
461
+ preconditions: [precondition(record, ifRevision)],
462
+ writes: [],
463
+ })
414
464
  return result(root, "task.update", "task", id, [])
465
+ }
415
466
  yield* applyWritePlan({
416
467
  root,
468
+ preconditions: [precondition(record, ifRevision)],
417
469
  writes: [{ path: record.path, content }],
418
470
  })
419
471
  return result(root, "task.update", "task", id, [record.path])
@@ -424,6 +476,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
424
476
  id: string,
425
477
  updates: PhaseUpdates,
426
478
  startPath: string = process.cwd(),
479
+ ifRevision?: string,
427
480
  ) =>
428
481
  Effect.gen(function* () {
429
482
  const workbase = yield* WorkbaseService
@@ -515,10 +568,18 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
515
568
  }
516
569
  }
517
570
  const content = yield* contentWith(record, data)
518
- if (content === record.content)
571
+ if (content === record.content) {
572
+ if (ifRevision)
573
+ yield* applyWritePlan({
574
+ root,
575
+ preconditions: [precondition(record, ifRevision)],
576
+ writes: [],
577
+ })
519
578
  return result(root, "phase.update", "phase", id, [])
579
+ }
520
580
  yield* applyWritePlan({
521
581
  root,
582
+ preconditions: [precondition(record, ifRevision)],
522
583
  writes: [{ path: record.path, content }],
523
584
  })
524
585
  return result(root, "phase.update", "phase", id, [record.path])
@@ -529,6 +590,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
529
590
  id: string,
530
591
  dependencyId: string,
531
592
  startPath: string = process.cwd(),
593
+ ifRevision?: string,
532
594
  ) =>
533
595
  Effect.gen(function* () {
534
596
  const workbase = yield* WorkbaseService
@@ -582,6 +644,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
582
644
  const content = yield* contentWith(epic, data)
583
645
  yield* applyWritePlan({
584
646
  root,
647
+ preconditions: [precondition(task, ifRevision), precondition(epic)],
585
648
  writes: [{ path: epic.path, content }],
586
649
  })
587
650
  return result(root, `task.dependency.${operation}`, "task", id, [
@@ -595,12 +658,14 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
595
658
  id: string,
596
659
  dependencyId: string,
597
660
  startPath: string = process.cwd(),
661
+ ifRevision?: string,
598
662
  ) =>
599
663
  Effect.gen(function* () {
600
664
  const workbase = yield* WorkbaseService
601
665
  const tasks = yield* TaskService
602
666
  const root = yield* workbase.discover(startPath)
603
667
  const task = yield* tasks.show(taskId, root)
668
+ const phase = yield* (yield* PhaseService).show(taskId, id, root)
604
669
  if (!("phases" in task.data))
605
670
  return yield* new GraphMutationError({
606
671
  message: `Task '${taskId}' does not have phases`,
@@ -641,6 +706,10 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
641
706
  const content = yield* contentWith(task, data)
642
707
  yield* applyWritePlan({
643
708
  root,
709
+ preconditions: [
710
+ precondition(phase, ifRevision),
711
+ precondition(task),
712
+ ],
644
713
  writes: [{ path: task.path, content }],
645
714
  })
646
715
  return result(root, `phase.dependency.${operation}`, "phase", id, [
@@ -652,6 +721,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
652
721
  id: string,
653
722
  newId: string,
654
723
  startPath: string = process.cwd(),
724
+ ifRevision?: string,
655
725
  ) =>
656
726
  Effect.gen(function* () {
657
727
  const workbase = yield* WorkbaseService
@@ -698,7 +768,17 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
698
768
  content: yield* contentWith(task, data),
699
769
  })
700
770
  }
701
- yield* applyWritePlan({ root, writes, move: { from, to } })
771
+ yield* applyWritePlan({
772
+ root,
773
+ preconditions: [
774
+ precondition(epic, ifRevision),
775
+ ...allTasks
776
+ .filter((task) => task.data.epic === id)
777
+ .map((task) => precondition(task)),
778
+ ],
779
+ writes,
780
+ move: { from, to },
781
+ })
702
782
  return result(
703
783
  root,
704
784
  "epic.rename",
@@ -713,6 +793,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
713
793
  id: string,
714
794
  newId: string,
715
795
  startPath: string = process.cwd(),
796
+ ifRevision?: string,
716
797
  ) =>
717
798
  Effect.gen(function* () {
718
799
  const workbase = yield* WorkbaseService
@@ -736,6 +817,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
736
817
  return yield* new GraphMutationError({
737
818
  message: `Task '${id}' has a materialized worktree; remove it with Agency before renaming`,
738
819
  })
820
+ const movedPhases: PhaseRecord[] = []
739
821
  if ("phases" in task.data) {
740
822
  for (const phase of task.data.phases) {
741
823
  const record = yield* (yield* PhaseService).show(
@@ -743,6 +825,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
743
825
  phase.id,
744
826
  root,
745
827
  )
828
+ movedPhases.push(record)
746
829
  if (record.data.claim?.state === "active")
747
830
  return yield* new GraphMutationError({
748
831
  message: `Phase '${phase.id}' has an active claim; release or finish it before renaming task '${id}'`,
@@ -762,6 +845,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
762
845
  }
763
846
  }
764
847
  const writes: { path: string; content: string }[] = []
848
+ const affectedEpics: EpicRecord[] = []
765
849
  for (const epic of yield* epics.list(root)) {
766
850
  if (
767
851
  !epic.data.tasks.some(
@@ -769,6 +853,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
769
853
  )
770
854
  )
771
855
  continue
856
+ affectedEpics.push(epic)
772
857
  const data = {
773
858
  ...epic.data,
774
859
  tasks: epic.data.tasks.map((item) => ({
@@ -789,7 +874,16 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
789
874
  content: yield* contentWith(epic, data),
790
875
  })
791
876
  }
792
- yield* applyWritePlan({ root, writes, move: { from, to } })
877
+ yield* applyWritePlan({
878
+ root,
879
+ preconditions: [
880
+ precondition(task, ifRevision),
881
+ ...movedPhases.map((phase) => precondition(phase)),
882
+ ...affectedEpics.map((epic) => precondition(epic)),
883
+ ],
884
+ writes,
885
+ move: { from, to },
886
+ })
793
887
  return result(
794
888
  root,
795
889
  "task.rename",
@@ -805,6 +899,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
805
899
  id: string,
806
900
  newId: string,
807
901
  startPath: string = process.cwd(),
902
+ ifRevision?: string,
808
903
  ) =>
809
904
  Effect.gen(function* () {
810
905
  const workbase = yield* WorkbaseService
@@ -856,6 +951,10 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
856
951
  const content = yield* contentWith(task, data)
857
952
  yield* applyWritePlan({
858
953
  root,
954
+ preconditions: [
955
+ precondition(phase, ifRevision),
956
+ precondition(task),
957
+ ],
859
958
  writes: [{ path: task.path, content }],
860
959
  move: { from, to },
861
960
  })
@@ -873,6 +972,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
873
972
  id: string,
874
973
  epicId: string | null,
875
974
  startPath: string = process.cwd(),
975
+ ifRevision?: string,
876
976
  ) =>
877
977
  Effect.gen(function* () {
878
978
  const workbase = yield* WorkbaseService
@@ -881,8 +981,15 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
881
981
  const root = yield* workbase.discover(startPath)
882
982
  const task = yield* tasks.show(id, root)
883
983
  const sourceId = task.data.epic
884
- if (sourceId === epicId)
984
+ if (sourceId === epicId) {
985
+ if (ifRevision)
986
+ yield* applyWritePlan({
987
+ root,
988
+ preconditions: [precondition(task, ifRevision)],
989
+ writes: [],
990
+ })
885
991
  return result(root, "task.move", "task", id, [])
992
+ }
886
993
  const source = sourceId
887
994
  ? yield* epics.show(sourceId, root)
888
995
  : undefined
@@ -938,7 +1045,15 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
938
1045
  path: task.path,
939
1046
  content: yield* contentWith(task, taskData),
940
1047
  })
941
- yield* applyWritePlan({ root, writes })
1048
+ yield* applyWritePlan({
1049
+ root,
1050
+ preconditions: [
1051
+ precondition(task, ifRevision),
1052
+ ...(source ? [precondition(source)] : []),
1053
+ ...(target ? [precondition(target)] : []),
1054
+ ],
1055
+ writes,
1056
+ })
942
1057
  return result(
943
1058
  root,
944
1059
  "task.move",