@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.
@@ -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,
@@ -33,14 +37,6 @@ class ClaimError extends Data.TaggedError("ClaimError")<{
33
37
  readonly target?: string
34
38
  }> {}
35
39
 
36
- class RevisionConflictError extends Data.TaggedError("RevisionConflictError")<{
37
- readonly message: string
38
- readonly target: string
39
- readonly expectedRevision: string
40
- readonly actualRevision: string
41
- readonly claim?: ClaimRecord
42
- }> {}
43
-
44
40
  class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
45
41
  readonly message: string
46
42
  readonly target: string
@@ -59,6 +55,7 @@ class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
59
55
 
60
56
  interface ClaimTarget {
61
57
  readonly kind: "task" | "phase"
58
+ readonly root: string
62
59
  readonly taskId: string
63
60
  readonly phaseId?: string
64
61
  readonly path: string
@@ -149,7 +146,7 @@ const decodeExecution = (target: ClaimTarget, input: unknown) => {
149
146
  }
150
147
 
151
148
  const assertRevision = (revision: string) => {
152
- if (!/^[a-f0-9]{64}$/.test(revision)) {
149
+ if (!isDocumentRevision(revision)) {
153
150
  throw new ClaimError({
154
151
  message: "Revision must be a 64-character SHA-256 hash",
155
152
  })
@@ -169,8 +166,7 @@ const isUnexpired = (claim: ClaimRecord, now: Date) =>
169
166
  claim.state === "active" &&
170
167
  (claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
171
168
 
172
- const acquireLock = async (path: string) => {
173
- const lockPath = `${path}.claim.lock`
169
+ const acquireLock = async (lockPath: string, label: string) => {
174
170
  for (let attempt = 0; attempt < 1_750; attempt += 1) {
175
171
  try {
176
172
  const handle = await open(lockPath, "wx")
@@ -192,7 +188,7 @@ const acquireLock = async (path: string) => {
192
188
  await Bun.sleep(20)
193
189
  }
194
190
  }
195
- throw new ClaimError({ message: `Timed out waiting to update ${path}` })
191
+ throw new ClaimError({ message: `Timed out waiting to update ${label}` })
196
192
  }
197
193
 
198
194
  const updateAtomically = async <T>(
@@ -207,18 +203,24 @@ const updateAtomically = async <T>(
207
203
  now: Date,
208
204
  ) => {
209
205
  assertRevision(expectedRevision)
210
- 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
211
211
  let temporaryPath: string | undefined
212
212
  try {
213
+ documentLock = await acquireLock(`${target.path}.claim.lock`, target.path)
213
214
  const content = await readFile(target.path, "utf8")
214
- const actualRevision = documentRevision(content)
215
+ const currentRevision = documentRevision(content)
215
216
  const parsed = parseFrontmatterSync(content, target.path)
216
217
  const current = decodeExecution(target, parsed.data)
217
- if (actualRevision !== expectedRevision) {
218
+ if (currentRevision !== expectedRevision) {
218
219
  throw new RevisionConflictError({
220
+ path: relative(target.root, target.path),
219
221
  target: target.label,
220
222
  expectedRevision,
221
- actualRevision,
223
+ currentRevision,
222
224
  claim: current.claim,
223
225
  message: `Revision conflict for ${target.label}`,
224
226
  })
@@ -235,13 +237,15 @@ const updateAtomically = async <T>(
235
237
  return {
236
238
  ...result,
237
239
  target: target.label,
238
- previousRevision: actualRevision,
240
+ previousRevision: currentRevision,
239
241
  revision: documentRevision(updatedContent),
240
242
  }
241
243
  } finally {
242
244
  if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
243
- await handle.close().catch(() => undefined)
244
- 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)
245
249
  }
246
250
  }
247
251
 
@@ -278,6 +282,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
278
282
  const target: ClaimTarget = phaseId
279
283
  ? {
280
284
  kind: "phase",
285
+ root,
281
286
  taskId: task.id,
282
287
  phaseId,
283
288
  path: phase!.path,
@@ -285,6 +290,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
285
290
  }
286
291
  : {
287
292
  kind: "task",
293
+ root,
288
294
  taskId: task.id,
289
295
  path: task.path,
290
296
  label: `task '${task.id}'`,
@@ -7,6 +7,7 @@ import { normalizePullRequestRecord } from "../workbase/delivery-command"
7
7
  import { RepositoryService } from "./RepositoryService"
8
8
  import { aggregateProgress, readinessState } from "../readiness"
9
9
  import { parseFrontmatter } from "../workbase/frontmatter"
10
+ import { documentRevision } from "../workbase/document-revision"
10
11
  import {
11
12
  EpicFrontmatter,
12
13
  PhaseFrontmatter,
@@ -78,9 +79,6 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
78
79
  : { ok: true as const, value: result.right }
79
80
  }
80
81
 
81
- const hash = (content: string) =>
82
- new Bun.CryptoHasher("sha256").update(content).digest("hex")
83
-
84
82
  const isWithin = (root: string, path: string) => {
85
83
  const child = relative(root, path)
86
84
  return child === "" || (!child.startsWith(`..${sep}`) && child !== "..")
@@ -205,7 +203,7 @@ export class ContextService extends Effect.Service<ContextService>()(
205
203
  return {
206
204
  id,
207
205
  path,
208
- sha256: hash(content),
206
+ sha256: documentRevision(content),
209
207
  data: decoded.value,
210
208
  body: parsed.body,
211
209
  } satisfies Document<Schema.Schema.Type<S>>
@@ -279,7 +277,7 @@ export class ContextService extends Effect.Service<ContextService>()(
279
277
  taskDocuments.set(entry.name, {
280
278
  id: entry.name,
281
279
  path,
282
- sha256: hash(content),
280
+ sha256: documentRevision(content),
283
281
  data: decoded.value,
284
282
  body: parsed.right.body,
285
283
  })
@@ -303,7 +301,7 @@ export class ContextService extends Effect.Service<ContextService>()(
303
301
  phaseDocuments.set(`${entry.name}/${phaseEntry.name}`, {
304
302
  id: phaseEntry.name,
305
303
  path: phasePath,
306
- sha256: hash(phaseContent),
304
+ sha256: documentRevision(phaseContent),
307
305
  data: phaseDecoded.value,
308
306
  body: phaseParsed.right.body,
309
307
  })
@@ -12,6 +12,8 @@ import {
12
12
  formatMarkdownDocument,
13
13
  parseFrontmatter,
14
14
  } from "../workbase/frontmatter"
15
+ import { documentRevision } from "../workbase/document-revision"
16
+ import { archivedEpicDirectory } from "../workbase/archive"
15
17
 
16
18
  class EpicError extends Data.TaggedError("EpicError")<{
17
19
  readonly message: string
@@ -21,6 +23,7 @@ export interface EpicRecord {
21
23
  readonly id: string
22
24
  readonly path: string
23
25
  readonly content: string
26
+ readonly revision: string
24
27
  readonly data: Schema.Schema.Type<typeof EpicFrontmatter>
25
28
  }
26
29
 
@@ -71,6 +74,11 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
71
74
  message: `Epic '${validId}' already exists`,
72
75
  })
73
76
  }
77
+ if (yield* fs.exists(archivedEpicDirectory(root, validId))) {
78
+ return yield* new EpicError({
79
+ message: `Epic '${validId}' is archived; restore it before reusing this ID`,
80
+ })
81
+ }
74
82
 
75
83
  for (const { repo: alias } of data.repos) {
76
84
  if (!(yield* fs.exists(join(root, "repos", alias)))) {
@@ -90,7 +98,13 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
90
98
  `# ${title}\n\nDescribe the epic outcome.`,
91
99
  )
92
100
  yield* fs.writeFile(path, content)
93
- return { id: validId, path, content, data } satisfies EpicRecord
101
+ return {
102
+ id: validId,
103
+ path,
104
+ content,
105
+ revision: documentRevision(content),
106
+ data,
107
+ } satisfies EpicRecord
94
108
  }),
95
109
 
96
110
  list: (startPath: string = process.cwd()) =>
@@ -113,7 +127,13 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
113
127
  const content = yield* fs.readFile(path)
114
128
  const parsed = yield* parseFrontmatter(content, path)
115
129
  const data = yield* decodeEpic(parsed.data)
116
- records.push({ id: entry.name, path, content, data })
130
+ records.push({
131
+ id: entry.name,
132
+ path,
133
+ content,
134
+ revision: documentRevision(content),
135
+ data,
136
+ })
117
137
  }
118
138
  return records
119
139
  }),
@@ -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",
@@ -21,6 +21,7 @@ import {
21
21
  } from "../readiness"
22
22
  import { parseFrontmatter } from "../workbase/frontmatter"
23
23
  import { normalizePullRequestRecord } from "../workbase/delivery-command"
24
+ import { documentRevision } from "../workbase/document-revision"
24
25
  import {
25
26
  EpicFrontmatter,
26
27
  PhaseFrontmatter,
@@ -74,9 +75,6 @@ const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}`
74
75
  const phaseExecutionNodeId = (taskId: string, phaseId: string) =>
75
76
  `execution-unit:phase/${taskId}/${phaseId}`
76
77
 
77
- const hash = (content: string) =>
78
- new Bun.CryptoHasher("sha256").update(content).digest("hex")
79
-
80
78
  const decode = <S extends Schema.Schema.AnyNoContext>(
81
79
  schema: S,
82
80
  input: unknown,
@@ -162,7 +160,7 @@ export class GraphService extends Effect.Service<GraphService>()(
162
160
  return {
163
161
  id,
164
162
  path,
165
- sha256: hash(content),
163
+ sha256: documentRevision(content),
166
164
  data: decoded.value,
167
165
  body: parsed.body,
168
166
  } satisfies Document<Schema.Schema.Type<S>>