@markjaquith/agency 2.22.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/task-phase.test.ts +16 -4
- package/src/commands/task.ts +13 -1
- package/src/protocol.test.ts +19 -0
- package/src/services/ClaimService.ts +27 -21
- package/src/services/ContextService.ts +4 -6
- 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 +2 -4
- package/src/services/PhaseService.ts +26 -4
- package/src/services/TaskService.ts +22 -3
- package/src/work-view.ts +2 -0
- package/src/workbase/document-revision.ts +16 -0
package/README.md
CHANGED
|
@@ -400,6 +400,8 @@ repository. Alias names are then used by all documents and commands.
|
|
|
400
400
|
Commands that print Agency-owned results accept `--json`, including initialization,
|
|
401
401
|
integration inspection/sync, repository mutations, entity creation/list/show,
|
|
402
402
|
status, validation, graph export, reconciliation, and PR creation.
|
|
403
|
+
Entity create, list, and show results include a stable SHA-256 `revision` of the
|
|
404
|
+
complete Markdown document.
|
|
403
405
|
|
|
404
406
|
### Epics
|
|
405
407
|
|
|
@@ -515,6 +517,13 @@ references as one rollback-capable mutation and refuse when a materialized
|
|
|
515
517
|
worktree would make the directory move unsafe. Mutation JSON includes changed
|
|
516
518
|
paths and the focused validation scope.
|
|
517
519
|
|
|
520
|
+
Epic, task, and phase update, rename, move, and dependency mutations accept
|
|
521
|
+
`--if-revision <hash>`. The option is optional for interactive human use. When
|
|
522
|
+
provided, Agency fails with a structured `REVISION_CONFLICT` containing the
|
|
523
|
+
expected and current revisions if the target changed. Multi-document mutations
|
|
524
|
+
also recheck every affected document after taking the mutation lock and before
|
|
525
|
+
writing anything.
|
|
526
|
+
|
|
518
527
|
Single-phase tasks and phases store status in YAML. New execution units start
|
|
519
528
|
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
520
529
|
before launch. Use claims for coordinated ownership and the status subcommands
|
package/cli.ts
CHANGED
|
@@ -238,6 +238,7 @@ const commands: Record<string, Command> = {
|
|
|
238
238
|
ticketUrl: options["ticket-url"],
|
|
239
239
|
description: options.description,
|
|
240
240
|
clearDescription: options["clear-description"],
|
|
241
|
+
ifRevision: options["if-revision"],
|
|
241
242
|
repos: options.repo,
|
|
242
243
|
json: options.json,
|
|
243
244
|
statuses: options.status,
|
|
@@ -289,6 +290,7 @@ const commands: Record<string, Command> = {
|
|
|
289
290
|
clearReferences: options["clear-references"],
|
|
290
291
|
prUrl: options["pr-url"],
|
|
291
292
|
clearPr: options["clear-pr"],
|
|
293
|
+
ifRevision: options["if-revision"],
|
|
292
294
|
dependsOn: options["depends-on"],
|
|
293
295
|
firstPhase: options["first-phase"],
|
|
294
296
|
json: options.json,
|
|
@@ -399,6 +401,7 @@ const commands: Record<string, Command> = {
|
|
|
399
401
|
clearReferences: options["clear-references"],
|
|
400
402
|
prUrl: options["pr-url"],
|
|
401
403
|
clearPr: options["clear-pr"],
|
|
404
|
+
ifRevision: options["if-revision"],
|
|
402
405
|
noEpic: options["no-epic"],
|
|
403
406
|
multiPhase: options["multi-phase"],
|
|
404
407
|
json: options.json,
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -296,6 +296,31 @@ describe("strict CLI parsing", () => {
|
|
|
296
296
|
).toThrow("must be 'done' or 'dropped'")
|
|
297
297
|
})
|
|
298
298
|
|
|
299
|
+
test("accepts valid mutation revisions and rejects malformed hashes", () => {
|
|
300
|
+
const revision = "a".repeat(64)
|
|
301
|
+
expect(
|
|
302
|
+
parseCli([
|
|
303
|
+
"task",
|
|
304
|
+
"move",
|
|
305
|
+
"example",
|
|
306
|
+
"--no-epic",
|
|
307
|
+
"--if-revision",
|
|
308
|
+
revision,
|
|
309
|
+
]),
|
|
310
|
+
).toMatchObject({ values: { "if-revision": revision } })
|
|
311
|
+
expect(() =>
|
|
312
|
+
parseCli([
|
|
313
|
+
"phase",
|
|
314
|
+
"rename",
|
|
315
|
+
"task",
|
|
316
|
+
"phase",
|
|
317
|
+
"renamed",
|
|
318
|
+
"--if-revision",
|
|
319
|
+
"stale",
|
|
320
|
+
]),
|
|
321
|
+
).toThrow("64-character SHA-256 hash")
|
|
322
|
+
})
|
|
323
|
+
|
|
299
324
|
test("accepts context projections and keeps compact command-local", () => {
|
|
300
325
|
expect(parseCli(["context", ".", "--json", "--compact"])).toMatchObject({
|
|
301
326
|
commandName: "context",
|
package/src/cli-parser.ts
CHANGED
|
@@ -91,6 +91,7 @@ const phaseCreateOptions = {
|
|
|
91
91
|
} satisfies OptionConfig
|
|
92
92
|
|
|
93
93
|
const mutationOptions = {
|
|
94
|
+
"if-revision": { type: "string" },
|
|
94
95
|
"clear-description": { type: "boolean" },
|
|
95
96
|
"clear-ticket": { type: "boolean" },
|
|
96
97
|
"clear-references": { type: "boolean" },
|
|
@@ -249,6 +250,7 @@ const commands = {
|
|
|
249
250
|
"clear-description",
|
|
250
251
|
"ticket-url",
|
|
251
252
|
"repo",
|
|
253
|
+
"if-revision",
|
|
252
254
|
"json",
|
|
253
255
|
],
|
|
254
256
|
repeatable: ["repo"],
|
|
@@ -258,7 +260,7 @@ const commands = {
|
|
|
258
260
|
usage: "agency epic rename <id> <new-id> [--json]",
|
|
259
261
|
minArgs: 2,
|
|
260
262
|
maxArgs: 2,
|
|
261
|
-
options: ["json"],
|
|
263
|
+
options: ["if-revision", "json"],
|
|
262
264
|
},
|
|
263
265
|
},
|
|
264
266
|
},
|
|
@@ -344,6 +346,7 @@ const commands = {
|
|
|
344
346
|
"base",
|
|
345
347
|
"pr-url",
|
|
346
348
|
"clear-pr",
|
|
349
|
+
"if-revision",
|
|
347
350
|
"json",
|
|
348
351
|
],
|
|
349
352
|
repeatable: ["reference"],
|
|
@@ -358,13 +361,13 @@ const commands = {
|
|
|
358
361
|
usage: "agency task rename <id> <new-id> [--json]",
|
|
359
362
|
minArgs: 2,
|
|
360
363
|
maxArgs: 2,
|
|
361
|
-
options: ["json"],
|
|
364
|
+
options: ["if-revision", "json"],
|
|
362
365
|
},
|
|
363
366
|
move: {
|
|
364
367
|
usage: "agency task move <id> (--epic <id> | --no-epic) [--json]",
|
|
365
368
|
minArgs: 1,
|
|
366
369
|
maxArgs: 1,
|
|
367
|
-
options: ["epic", "no-epic", "json"],
|
|
370
|
+
options: ["epic", "no-epic", "if-revision", "json"],
|
|
368
371
|
conflicts: [["epic", "no-epic"]],
|
|
369
372
|
},
|
|
370
373
|
dependency: {
|
|
@@ -372,7 +375,7 @@ const commands = {
|
|
|
372
375
|
"agency task dependency <add|remove> <task-id> <dependency-id> [--json]",
|
|
373
376
|
minArgs: 3,
|
|
374
377
|
maxArgs: 3,
|
|
375
|
-
options: ["json"],
|
|
378
|
+
options: ["if-revision", "json"],
|
|
376
379
|
},
|
|
377
380
|
},
|
|
378
381
|
},
|
|
@@ -441,6 +444,7 @@ const commands = {
|
|
|
441
444
|
"base",
|
|
442
445
|
"pr-url",
|
|
443
446
|
"clear-pr",
|
|
447
|
+
"if-revision",
|
|
444
448
|
"json",
|
|
445
449
|
],
|
|
446
450
|
repeatable: ["reference"],
|
|
@@ -454,14 +458,14 @@ const commands = {
|
|
|
454
458
|
usage: "agency phase rename <task-id> <phase-id> <new-id> [--json]",
|
|
455
459
|
minArgs: 3,
|
|
456
460
|
maxArgs: 3,
|
|
457
|
-
options: ["json"],
|
|
461
|
+
options: ["if-revision", "json"],
|
|
458
462
|
},
|
|
459
463
|
dependency: {
|
|
460
464
|
usage:
|
|
461
465
|
"agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id> [--json]",
|
|
462
466
|
minArgs: 4,
|
|
463
467
|
maxArgs: 4,
|
|
464
|
-
options: ["json"],
|
|
468
|
+
options: ["if-revision", "json"],
|
|
465
469
|
},
|
|
466
470
|
},
|
|
467
471
|
},
|
|
@@ -1125,11 +1129,22 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1125
1129
|
["epic", "task", "phase"].includes(commandName) &&
|
|
1126
1130
|
subcommand === "update"
|
|
1127
1131
|
) {
|
|
1128
|
-
const mutationNames = (spec.options ?? []).filter(
|
|
1132
|
+
const mutationNames = (spec.options ?? []).filter(
|
|
1133
|
+
(name) => name !== "json" && name !== "if-revision",
|
|
1134
|
+
)
|
|
1129
1135
|
if (!mutationNames.some((name) => parsed.values[name] !== undefined)) {
|
|
1130
1136
|
throw usageError("At least one update option is required.", spec.usage)
|
|
1131
1137
|
}
|
|
1132
1138
|
}
|
|
1139
|
+
if (
|
|
1140
|
+
typeof parsed.values["if-revision"] === "string" &&
|
|
1141
|
+
!/^[a-f0-9]{64}$/.test(parsed.values["if-revision"])
|
|
1142
|
+
) {
|
|
1143
|
+
throw usageError(
|
|
1144
|
+
"Option '--if-revision' must be a 64-character SHA-256 hash.",
|
|
1145
|
+
spec.usage,
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1133
1148
|
if (commandName === "graph") {
|
|
1134
1149
|
validateGraphOptions(parsed.values, spec)
|
|
1135
1150
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -203,6 +203,41 @@ describe("CLI", () => {
|
|
|
203
203
|
),
|
|
204
204
|
)
|
|
205
205
|
}
|
|
206
|
+
const observed = parseJson(
|
|
207
|
+
await runCli(["task", "show", "second", "--json"], root),
|
|
208
|
+
)
|
|
209
|
+
const taskPath = join(root, "tasks/second/TASK.md")
|
|
210
|
+
await Bun.write(
|
|
211
|
+
taskPath,
|
|
212
|
+
`${await Bun.file(taskPath).text()}\nConcurrent edit.\n`,
|
|
213
|
+
)
|
|
214
|
+
const conflict = await runCli(
|
|
215
|
+
[
|
|
216
|
+
"task",
|
|
217
|
+
"update",
|
|
218
|
+
"second",
|
|
219
|
+
"--description",
|
|
220
|
+
"Stale update",
|
|
221
|
+
"--if-revision",
|
|
222
|
+
observed.revision,
|
|
223
|
+
"--json",
|
|
224
|
+
],
|
|
225
|
+
root,
|
|
226
|
+
)
|
|
227
|
+
expect(conflict.exitCode).toBe(1)
|
|
228
|
+
expect(JSON.parse(conflict.stdout)).toMatchObject({
|
|
229
|
+
ok: false,
|
|
230
|
+
error: {
|
|
231
|
+
code: "REVISION_CONFLICT",
|
|
232
|
+
retryable: true,
|
|
233
|
+
fields: {
|
|
234
|
+
path: "tasks/second/TASK.md",
|
|
235
|
+
expectedRevision: observed.revision,
|
|
236
|
+
currentRevision: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
expect(await Bun.file(taskPath).text()).toContain("Concurrent edit.")
|
|
206
241
|
|
|
207
242
|
const updated = parseJson(
|
|
208
243
|
await runCli(
|
|
@@ -40,9 +40,12 @@ describe("epic command", () => {
|
|
|
40
40
|
),
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const created = JSON.parse(logs[0]!)
|
|
44
|
+
expect(created.revision).toMatch(/^[a-f0-9]{64}$/)
|
|
45
|
+
expect(created).toEqual({
|
|
44
46
|
id: "example",
|
|
45
47
|
path: join(root, "epics/example/EPIC.md"),
|
|
48
|
+
revision: created.revision,
|
|
46
49
|
data: {
|
|
47
50
|
ticketUrl: "https://example.com/epic",
|
|
48
51
|
repos: [{ repo: "agency", ref: "main" }],
|
|
@@ -68,10 +71,13 @@ describe("epic command", () => {
|
|
|
68
71
|
epic({ subcommand: "list", args: [], cwd: root, json: true }),
|
|
69
72
|
),
|
|
70
73
|
)
|
|
71
|
-
|
|
74
|
+
const listed = JSON.parse(listLogs[0]!)
|
|
75
|
+
expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
|
|
76
|
+
expect(listed).toEqual([
|
|
72
77
|
{
|
|
73
78
|
id: "example",
|
|
74
79
|
path: join(root, "epics/example/EPIC.md"),
|
|
80
|
+
revision: listed[0].revision,
|
|
75
81
|
data: {
|
|
76
82
|
ticketUrl: "https://example.com/epic",
|
|
77
83
|
repos: [{ repo: "agency", ref: "main" }],
|
|
@@ -90,9 +96,12 @@ describe("epic command", () => {
|
|
|
90
96
|
}),
|
|
91
97
|
),
|
|
92
98
|
)
|
|
93
|
-
|
|
99
|
+
const shown = JSON.parse(showLogs[0]!)
|
|
100
|
+
expect(shown.revision).toBe(listed[0].revision)
|
|
101
|
+
expect(shown).toEqual({
|
|
94
102
|
id: "example",
|
|
95
103
|
path: join(root, "epics/example/EPIC.md"),
|
|
104
|
+
revision: shown.revision,
|
|
96
105
|
data: {
|
|
97
106
|
ticketUrl: "https://example.com/epic",
|
|
98
107
|
repos: [{ repo: "agency", ref: "main" }],
|
package/src/commands/epic.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface EpicOptions extends BaseCommandOptions {
|
|
|
13
13
|
readonly ticketUrl?: string
|
|
14
14
|
readonly description?: string
|
|
15
15
|
readonly clearDescription?: boolean
|
|
16
|
+
readonly ifRevision?: string
|
|
16
17
|
readonly repos?: readonly string[]
|
|
17
18
|
readonly json?: boolean
|
|
18
19
|
readonly statuses?: readonly string[]
|
|
@@ -122,6 +123,7 @@ export const epic = (options: EpicOptions) =>
|
|
|
122
123
|
: parseRepositoryReferences(options.repos),
|
|
123
124
|
},
|
|
124
125
|
cwd,
|
|
126
|
+
options.ifRevision,
|
|
125
127
|
)
|
|
126
128
|
log(
|
|
127
129
|
options.json
|
|
@@ -138,7 +140,12 @@ export const epic = (options: EpicOptions) =>
|
|
|
138
140
|
new Error("Epic ID and new ID are required"),
|
|
139
141
|
)
|
|
140
142
|
}
|
|
141
|
-
const output = yield* mutations.renameEpic(
|
|
143
|
+
const output = yield* mutations.renameEpic(
|
|
144
|
+
id,
|
|
145
|
+
newId,
|
|
146
|
+
cwd,
|
|
147
|
+
options.ifRevision,
|
|
148
|
+
)
|
|
142
149
|
log(
|
|
143
150
|
options.json
|
|
144
151
|
? JSON.stringify(output, null, 2)
|
|
@@ -177,6 +184,9 @@ Update options:
|
|
|
177
184
|
--clear-description Remove the description
|
|
178
185
|
--repo <alias>:<ref> Replace repository references; repeatable
|
|
179
186
|
|
|
187
|
+
Mutation options:
|
|
188
|
+
--if-revision <hash> Require the target's current revision
|
|
189
|
+
|
|
180
190
|
Options:
|
|
181
191
|
--json Output results as JSON
|
|
182
192
|
--status <status> Filter list by status; repeatable
|
package/src/commands/phase.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface PhaseOptions extends BaseCommandOptions {
|
|
|
19
19
|
readonly clearReferences?: boolean
|
|
20
20
|
readonly prUrl?: string
|
|
21
21
|
readonly clearPr?: boolean
|
|
22
|
+
readonly ifRevision?: string
|
|
22
23
|
readonly dependsOn?: readonly string[]
|
|
23
24
|
readonly firstPhase?: string
|
|
24
25
|
readonly json?: boolean
|
|
@@ -181,6 +182,7 @@ export const phase = (options: PhaseOptions) =>
|
|
|
181
182
|
pr: options.clearPr ? null : options.prUrl,
|
|
182
183
|
},
|
|
183
184
|
cwd,
|
|
185
|
+
options.ifRevision,
|
|
184
186
|
)
|
|
185
187
|
log(
|
|
186
188
|
options.json
|
|
@@ -196,7 +198,13 @@ export const phase = (options: PhaseOptions) =>
|
|
|
196
198
|
new Error("Task ID, phase ID, and new ID are required"),
|
|
197
199
|
)
|
|
198
200
|
}
|
|
199
|
-
const output = yield* mutations.renamePhase(
|
|
201
|
+
const output = yield* mutations.renamePhase(
|
|
202
|
+
taskId,
|
|
203
|
+
phaseId,
|
|
204
|
+
newId,
|
|
205
|
+
cwd,
|
|
206
|
+
options.ifRevision,
|
|
207
|
+
)
|
|
200
208
|
log(
|
|
201
209
|
options.json
|
|
202
210
|
? JSON.stringify(output, null, 2)
|
|
@@ -225,6 +233,7 @@ export const phase = (options: PhaseOptions) =>
|
|
|
225
233
|
dependencyPhaseId,
|
|
226
234
|
dependencyId,
|
|
227
235
|
cwd,
|
|
236
|
+
options.ifRevision,
|
|
228
237
|
)
|
|
229
238
|
log(
|
|
230
239
|
options.json
|
|
@@ -257,6 +266,9 @@ Subcommands:
|
|
|
257
266
|
dependency <operation> <task> <phase> <dependency>
|
|
258
267
|
Add or remove a phase dependency
|
|
259
268
|
|
|
269
|
+
Mutation option:
|
|
270
|
+
--if-revision <hash> Require the target's current revision
|
|
271
|
+
|
|
260
272
|
Create options:
|
|
261
273
|
--description <text> Short description of the phase
|
|
262
274
|
--repo <alias> Writable repository
|
|
@@ -48,10 +48,13 @@ describe("task and phase command JSON output", () => {
|
|
|
48
48
|
task({ subcommand: "list", args: [], cwd: root, json: true }),
|
|
49
49
|
),
|
|
50
50
|
)
|
|
51
|
-
|
|
51
|
+
const listed = JSON.parse(listLogs[0]!)
|
|
52
|
+
expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
|
|
53
|
+
expect(listed).toEqual([
|
|
52
54
|
{
|
|
53
55
|
id: "multi",
|
|
54
56
|
path: join(root, "tasks/multi/TASK.md"),
|
|
57
|
+
revision: listed[0].revision,
|
|
55
58
|
data: {
|
|
56
59
|
ticketUrl: "https://example.com/task",
|
|
57
60
|
phases: [{ id: "first" }],
|
|
@@ -69,9 +72,12 @@ describe("task and phase command JSON output", () => {
|
|
|
69
72
|
}),
|
|
70
73
|
),
|
|
71
74
|
)
|
|
72
|
-
|
|
75
|
+
const shown = JSON.parse(showLogs[0]!)
|
|
76
|
+
expect(shown.revision).toBe(listed[0].revision)
|
|
77
|
+
expect(shown).toEqual({
|
|
73
78
|
id: "multi",
|
|
74
79
|
path: join(root, "tasks/multi/TASK.md"),
|
|
80
|
+
revision: shown.revision,
|
|
75
81
|
data: {
|
|
76
82
|
ticketUrl: "https://example.com/task",
|
|
77
83
|
phases: [{ id: "first" }],
|
|
@@ -85,11 +91,14 @@ describe("task and phase command JSON output", () => {
|
|
|
85
91
|
phase({ subcommand: "list", args: ["multi"], cwd: root, json: true }),
|
|
86
92
|
),
|
|
87
93
|
)
|
|
88
|
-
|
|
94
|
+
const listed = JSON.parse(listLogs[0]!)
|
|
95
|
+
expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
|
|
96
|
+
expect(listed).toEqual([
|
|
89
97
|
{
|
|
90
98
|
taskId: "multi",
|
|
91
99
|
id: "first",
|
|
92
100
|
path: join(root, "tasks/multi/phases/first/PHASE.md"),
|
|
101
|
+
revision: listed[0].revision,
|
|
93
102
|
data: {
|
|
94
103
|
repo: "agency",
|
|
95
104
|
branch: "task/first",
|
|
@@ -110,10 +119,13 @@ describe("task and phase command JSON output", () => {
|
|
|
110
119
|
}),
|
|
111
120
|
),
|
|
112
121
|
)
|
|
113
|
-
|
|
122
|
+
const shown = JSON.parse(showLogs[0]!)
|
|
123
|
+
expect(shown.revision).toBe(listed[0].revision)
|
|
124
|
+
expect(shown).toEqual({
|
|
114
125
|
taskId: "multi",
|
|
115
126
|
id: "first",
|
|
116
127
|
path: join(root, "tasks/multi/phases/first/PHASE.md"),
|
|
128
|
+
revision: shown.revision,
|
|
117
129
|
data: {
|
|
118
130
|
repo: "agency",
|
|
119
131
|
branch: "task/first",
|
package/src/commands/task.ts
CHANGED
|
@@ -27,6 +27,7 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
27
27
|
readonly clearReferences?: boolean
|
|
28
28
|
readonly prUrl?: string
|
|
29
29
|
readonly clearPr?: boolean
|
|
30
|
+
readonly ifRevision?: string
|
|
30
31
|
readonly noEpic?: boolean
|
|
31
32
|
readonly multiPhase?: boolean
|
|
32
33
|
readonly json?: boolean
|
|
@@ -318,6 +319,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
318
319
|
pr: options.clearPr ? null : options.prUrl,
|
|
319
320
|
},
|
|
320
321
|
cwd,
|
|
322
|
+
options.ifRevision,
|
|
321
323
|
)
|
|
322
324
|
log(
|
|
323
325
|
options.json
|
|
@@ -333,7 +335,12 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
333
335
|
new Error("Task ID and new ID are required"),
|
|
334
336
|
)
|
|
335
337
|
}
|
|
336
|
-
const output = yield* mutations.renameTask(
|
|
338
|
+
const output = yield* mutations.renameTask(
|
|
339
|
+
id,
|
|
340
|
+
newId,
|
|
341
|
+
cwd,
|
|
342
|
+
options.ifRevision,
|
|
343
|
+
)
|
|
337
344
|
log(
|
|
338
345
|
options.json
|
|
339
346
|
? JSON.stringify(output, null, 2)
|
|
@@ -348,6 +355,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
348
355
|
id,
|
|
349
356
|
options.noEpic ? null : (options.epic ?? null),
|
|
350
357
|
cwd,
|
|
358
|
+
options.ifRevision,
|
|
351
359
|
)
|
|
352
360
|
log(
|
|
353
361
|
options.json
|
|
@@ -376,6 +384,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
376
384
|
id,
|
|
377
385
|
dependencyId,
|
|
378
386
|
cwd,
|
|
387
|
+
options.ifRevision,
|
|
379
388
|
)
|
|
380
389
|
log(
|
|
381
390
|
options.json
|
|
@@ -408,6 +417,9 @@ Subcommands:
|
|
|
408
417
|
dependency <operation> <task> <dependency>
|
|
409
418
|
Add or remove a task dependency
|
|
410
419
|
|
|
420
|
+
Mutation option:
|
|
421
|
+
--if-revision <hash> Require the target's current revision
|
|
422
|
+
|
|
411
423
|
Create options:
|
|
412
424
|
--ticket-url <url> External ticket URL (optional)
|
|
413
425
|
--description <text> Short description of the task
|
package/src/protocol.test.ts
CHANGED
|
@@ -108,5 +108,24 @@ describe("machine protocol", () => {
|
|
|
108
108
|
},
|
|
109
109
|
},
|
|
110
110
|
})
|
|
111
|
+
expect(
|
|
112
|
+
errorEnvelope({
|
|
113
|
+
_tag: "RevisionConflictError",
|
|
114
|
+
message: "revision conflict",
|
|
115
|
+
path: "tasks/example/TASK.md",
|
|
116
|
+
expectedRevision: "a".repeat(64),
|
|
117
|
+
currentRevision: "b".repeat(64),
|
|
118
|
+
}),
|
|
119
|
+
).toMatchObject({
|
|
120
|
+
error: {
|
|
121
|
+
code: "REVISION_CONFLICT",
|
|
122
|
+
retryable: true,
|
|
123
|
+
fields: {
|
|
124
|
+
path: "tasks/example/TASK.md",
|
|
125
|
+
expectedRevision: "a".repeat(64),
|
|
126
|
+
currentRevision: "b".repeat(64),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
})
|
|
111
130
|
})
|
|
112
131
|
})
|
|
@@ -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,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",
|
|
@@ -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>>
|
|
@@ -16,16 +16,18 @@ import {
|
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
19
20
|
|
|
20
21
|
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
21
22
|
readonly message: string
|
|
22
23
|
}> {}
|
|
23
24
|
|
|
24
|
-
interface PhaseRecord {
|
|
25
|
+
export interface PhaseRecord {
|
|
25
26
|
readonly taskId: string
|
|
26
27
|
readonly id: string
|
|
27
28
|
readonly path: string
|
|
28
29
|
readonly content: string
|
|
30
|
+
readonly revision: string
|
|
29
31
|
readonly data: PhaseData
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -261,7 +263,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
261
263
|
task.path,
|
|
262
264
|
formatMarkdownDocument(convertedTaskData, parsedTask.body),
|
|
263
265
|
)
|
|
264
|
-
return {
|
|
266
|
+
return {
|
|
267
|
+
taskId,
|
|
268
|
+
id,
|
|
269
|
+
path,
|
|
270
|
+
content,
|
|
271
|
+
revision: documentRevision(content),
|
|
272
|
+
data,
|
|
273
|
+
} satisfies PhaseRecord
|
|
265
274
|
}
|
|
266
275
|
|
|
267
276
|
yield* fs.createDirectory(directory)
|
|
@@ -283,7 +292,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
283
292
|
formatMarkdownDocument(updatedTaskData, parsedTask.body),
|
|
284
293
|
)
|
|
285
294
|
|
|
286
|
-
return {
|
|
295
|
+
return {
|
|
296
|
+
taskId,
|
|
297
|
+
id,
|
|
298
|
+
path,
|
|
299
|
+
content,
|
|
300
|
+
revision: documentRevision(content),
|
|
301
|
+
data,
|
|
302
|
+
} satisfies PhaseRecord
|
|
287
303
|
}),
|
|
288
304
|
|
|
289
305
|
list: (taskId: string, startPath: string = process.cwd()) =>
|
|
@@ -311,6 +327,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
311
327
|
id: entry.name,
|
|
312
328
|
path,
|
|
313
329
|
content,
|
|
330
|
+
revision: documentRevision(content),
|
|
314
331
|
data,
|
|
315
332
|
})
|
|
316
333
|
}
|
|
@@ -363,7 +380,12 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
363
380
|
const data = { ...record.data, status: validStatus }
|
|
364
381
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
365
382
|
yield* fs.writeFile(record.path, content)
|
|
366
|
-
return {
|
|
383
|
+
return {
|
|
384
|
+
...record,
|
|
385
|
+
content,
|
|
386
|
+
revision: documentRevision(content),
|
|
387
|
+
data,
|
|
388
|
+
} satisfies PhaseRecord
|
|
367
389
|
}),
|
|
368
390
|
}),
|
|
369
391
|
},
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
19
20
|
|
|
20
21
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
21
22
|
readonly message: string
|
|
@@ -25,6 +26,7 @@ interface TaskRecord {
|
|
|
25
26
|
readonly id: string
|
|
26
27
|
readonly path: string
|
|
27
28
|
readonly content: string
|
|
29
|
+
readonly revision: string
|
|
28
30
|
readonly data: TaskData
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -158,7 +160,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
158
160
|
yield* fs.writeFile(parentEpic.path, updated)
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
path,
|
|
166
|
+
content,
|
|
167
|
+
revision: documentRevision(content),
|
|
168
|
+
data,
|
|
169
|
+
} satisfies TaskRecord
|
|
162
170
|
}),
|
|
163
171
|
|
|
164
172
|
list: (startPath: string = process.cwd()) =>
|
|
@@ -178,7 +186,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
178
186
|
const content = yield* fs.readFile(path)
|
|
179
187
|
const parsed = yield* parseFrontmatter(content, path)
|
|
180
188
|
const data = yield* decodeTask(parsed.data)
|
|
181
|
-
records.push({
|
|
189
|
+
records.push({
|
|
190
|
+
id: entry.name,
|
|
191
|
+
path,
|
|
192
|
+
content,
|
|
193
|
+
revision: documentRevision(content),
|
|
194
|
+
data,
|
|
195
|
+
})
|
|
182
196
|
}
|
|
183
197
|
return records
|
|
184
198
|
}),
|
|
@@ -233,7 +247,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
233
247
|
const data = { ...record.data, status: validStatus }
|
|
234
248
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
235
249
|
yield* fs.writeFile(record.path, content)
|
|
236
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
...record,
|
|
252
|
+
content,
|
|
253
|
+
revision: documentRevision(content),
|
|
254
|
+
data,
|
|
255
|
+
} satisfies TaskRecord
|
|
237
256
|
}),
|
|
238
257
|
}),
|
|
239
258
|
}) {}
|
package/src/work-view.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface WorkViewRow {
|
|
|
23
23
|
readonly kind: "epic" | "task" | "phase"
|
|
24
24
|
readonly id: string
|
|
25
25
|
readonly key: string
|
|
26
|
+
readonly revision: string
|
|
26
27
|
readonly parent: string
|
|
27
28
|
readonly status: WorkStatus
|
|
28
29
|
readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
|
|
@@ -91,6 +92,7 @@ const rowFor = (
|
|
|
91
92
|
kind: node.kind,
|
|
92
93
|
id,
|
|
93
94
|
key: node.key,
|
|
95
|
+
revision: node.data.sha256,
|
|
94
96
|
parent,
|
|
95
97
|
status: node.status,
|
|
96
98
|
readiness: readinessLabel(node),
|
|
@@ -1,2 +1,18 @@
|
|
|
1
|
+
import { Data } from "effect"
|
|
2
|
+
|
|
1
3
|
export const documentRevision = (content: string) =>
|
|
2
4
|
new Bun.CryptoHasher("sha256").update(content).digest("hex")
|
|
5
|
+
|
|
6
|
+
export const isDocumentRevision = (revision: string) =>
|
|
7
|
+
/^[a-f0-9]{64}$/.test(revision)
|
|
8
|
+
|
|
9
|
+
export class RevisionConflictError extends Data.TaggedError(
|
|
10
|
+
"RevisionConflictError",
|
|
11
|
+
)<{
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly path: string
|
|
14
|
+
readonly target?: string
|
|
15
|
+
readonly expectedRevision: string
|
|
16
|
+
readonly currentRevision: string
|
|
17
|
+
readonly claim?: unknown
|
|
18
|
+
}> {}
|