@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
|
@@ -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 {
|
|
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 (
|
|
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 (
|
|
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 ${
|
|
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
|
|
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
|
|
215
|
+
const currentRevision = documentRevision(content)
|
|
215
216
|
const parsed = parseFrontmatterSync(content, target.path)
|
|
216
217
|
const current = decodeExecution(target, parsed.data)
|
|
217
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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:
|
|
163
|
+
sha256: documentRevision(content),
|
|
166
164
|
data: decoded.value,
|
|
167
165
|
body: parsed.body,
|
|
168
166
|
} satisfies Document<Schema.Schema.Type<S>>
|