@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.
- package/README.md +9 -0
- package/cli.ts +3 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +25 -0
- package/src/cli-parser.ts +22 -7
- package/src/cli.test.ts +35 -0
- 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/pr.ts +2 -2
- package/src/commands/task-phase.test.ts +16 -4
- package/src/commands/task.ts +13 -1
- package/src/graph-schema.ts +2 -10
- package/src/protocol.test.ts +19 -0
- package/src/services/ClaimService.ts +34 -23
- package/src/services/ContextService.ts +8 -10
- package/src/services/EpicService.ts +16 -2
- package/src/services/GraphMutationService.test.ts +31 -0
- package/src/services/GraphMutationService.ts +126 -11
- package/src/services/GraphService.ts +4 -15
- package/src/services/PhaseService.ts +26 -4
- 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/TaskService.ts +22 -3
- package/src/services/WorkbaseService.ts +2 -0
- package/src/work-view.ts +2 -0
- package/src/workbase/delivery-command.test.ts +54 -0
- package/src/workbase/delivery-command.ts +135 -0
- package/src/workbase/document-revision.ts +16 -0
- package/src/workbase/schemas.test.ts +35 -0
- package/src/workbase/schemas.ts +23 -1
|
@@ -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,
|
|
@@ -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 (
|
|
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 (
|
|
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 ${
|
|
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
|
|
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
|
|
215
|
+
const currentRevision = documentRevision(content)
|
|
214
216
|
const parsed = parseFrontmatterSync(content, target.path)
|
|
215
217
|
const current = decodeExecution(target, parsed.data)
|
|
216
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
862
|
-
|
|
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 {
|
|
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({
|
|
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({
|
|
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",
|