@markjaquith/agency 2.11.0 → 2.12.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 +7 -4
- package/index.ts +1 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +6 -1
- package/src/graph-schema.test.ts +7 -0
- package/src/graph-schema.ts +2 -0
- package/src/readiness.test.ts +47 -0
- package/src/readiness.ts +58 -0
- package/src/services/ContextService.ts +6 -32
- package/src/services/GraphService.test.ts +55 -1
- package/src/services/GraphService.ts +14 -40
- package/src/services/PhaseService.ts +6 -0
- package/src/services/TaskPhaseService.test.ts +43 -0
- package/src/services/TaskService.ts +6 -0
package/README.md
CHANGED
|
@@ -262,9 +262,10 @@ Nodes use stable IDs (`epic:<id>`, `task:<id>`, `phase:<task>/<phase>`,
|
|
|
262
262
|
`repository:<alias>`, and `execution-unit:<kind>/<id>`). Typed edges are `owns`,
|
|
263
263
|
`depends_on`, `writes`, and `references`.
|
|
264
264
|
|
|
265
|
-
Every work node includes status, readiness,
|
|
266
|
-
aggregate progress. Only `done` satisfies
|
|
267
|
-
|
|
265
|
+
Every work node includes status, readiness, `blockedBy`, detailed blockers,
|
|
266
|
+
terminal state, reverse dependents, and aggregate progress. Only `done` satisfies
|
|
267
|
+
a dependency; `dropped` is terminal but does not satisfy dependents. The graph
|
|
268
|
+
summary counts the statuses of all execution units, independent of filters.
|
|
268
269
|
|
|
269
270
|
```text
|
|
270
271
|
agency graph [--json | --jsonl] [--ready | --blocked]
|
|
@@ -390,7 +391,9 @@ Single-phase tasks and phases store status in YAML. New execution units start
|
|
|
390
391
|
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
391
392
|
before launch. Use the status subcommands to mark work `delegated`, `done`,
|
|
392
393
|
`dropped`, or open it again. The interactive work selector displays status
|
|
393
|
-
markers before execution units.
|
|
394
|
+
markers before execution units. Open, working, and delegated work may transition
|
|
395
|
+
to any status. Done and dropped work are terminal and may only remain unchanged
|
|
396
|
+
or transition to open; reopen terminal work before changing its outcome.
|
|
394
397
|
|
|
395
398
|
### Archive
|
|
396
399
|
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -125,10 +125,15 @@
|
|
|
125
125
|
"readiness": {
|
|
126
126
|
"type": "object",
|
|
127
127
|
"additionalProperties": false,
|
|
128
|
-
"required": ["ready", "blocked", "blockers"],
|
|
128
|
+
"required": ["ready", "blocked", "blockedBy", "terminal", "blockers"],
|
|
129
129
|
"properties": {
|
|
130
130
|
"ready": { "type": "boolean" },
|
|
131
131
|
"blocked": { "type": "boolean" },
|
|
132
|
+
"blockedBy": {
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": { "type": "string" }
|
|
135
|
+
},
|
|
136
|
+
"terminal": { "type": "boolean" },
|
|
132
137
|
"blockers": {
|
|
133
138
|
"type": "array",
|
|
134
139
|
"items": { "$ref": "#/$defs/blocker" }
|
package/src/graph-schema.test.ts
CHANGED
|
@@ -14,6 +14,13 @@ describe("graph contract", () => {
|
|
|
14
14
|
required: ["ready", "blocked", "statuses", "repositories", "kinds"],
|
|
15
15
|
})
|
|
16
16
|
expect(jsonSchema.$defs.node.allOf).toHaveLength(5)
|
|
17
|
+
expect(jsonSchema.$defs.readiness.required).toEqual([
|
|
18
|
+
"ready",
|
|
19
|
+
"blocked",
|
|
20
|
+
"blockedBy",
|
|
21
|
+
"terminal",
|
|
22
|
+
"blockers",
|
|
23
|
+
])
|
|
17
24
|
expect(jsonSchema.$defs.node.allOf[3]?.then?.properties).toMatchObject({
|
|
18
25
|
status: { type: "null" },
|
|
19
26
|
readiness: { type: "null" },
|
package/src/graph-schema.ts
CHANGED
|
@@ -46,6 +46,8 @@ export const GraphProgress = Schema.Struct({
|
|
|
46
46
|
export const GraphReadiness = Schema.Struct({
|
|
47
47
|
ready: Schema.Boolean,
|
|
48
48
|
blocked: Schema.Boolean,
|
|
49
|
+
blockedBy: Schema.Array(Schema.String),
|
|
50
|
+
terminal: Schema.Boolean,
|
|
49
51
|
blockers: Schema.Array(GraphBlocker),
|
|
50
52
|
})
|
|
51
53
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
aggregateProgress,
|
|
4
|
+
canTransitionStatus,
|
|
5
|
+
isDependencySatisfied,
|
|
6
|
+
readinessState,
|
|
7
|
+
} from "./readiness"
|
|
8
|
+
|
|
9
|
+
describe("readiness model", () => {
|
|
10
|
+
test("only done satisfies dependencies and terminal states remain distinct", () => {
|
|
11
|
+
expect(isDependencySatisfied("done")).toBe(true)
|
|
12
|
+
expect(isDependencySatisfied("dropped")).toBe(false)
|
|
13
|
+
expect(readinessState("done", [{ id: "task:one" }])).toEqual({
|
|
14
|
+
ready: false,
|
|
15
|
+
blocked: true,
|
|
16
|
+
blockedBy: ["task:one"],
|
|
17
|
+
terminal: true,
|
|
18
|
+
})
|
|
19
|
+
expect(readinessState("working", [{ id: "claim:self" }])).toEqual({
|
|
20
|
+
ready: false,
|
|
21
|
+
blocked: true,
|
|
22
|
+
blockedBy: ["claim:self"],
|
|
23
|
+
terminal: false,
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("rolls child statuses into deterministic aggregate progress", () => {
|
|
28
|
+
expect(aggregateProgress(["done", "dropped"])).toEqual({
|
|
29
|
+
status: "dropped",
|
|
30
|
+
total: 2,
|
|
31
|
+
open: 0,
|
|
32
|
+
working: 0,
|
|
33
|
+
delegated: 0,
|
|
34
|
+
done: 1,
|
|
35
|
+
dropped: 1,
|
|
36
|
+
terminal: 2,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("requires terminal work to reopen before changing its outcome", () => {
|
|
41
|
+
expect(canTransitionStatus("open", "done")).toBe(true)
|
|
42
|
+
expect(canTransitionStatus("working", "delegated")).toBe(true)
|
|
43
|
+
expect(canTransitionStatus("done", "dropped")).toBe(false)
|
|
44
|
+
expect(canTransitionStatus("dropped", "done")).toBe(false)
|
|
45
|
+
expect(canTransitionStatus("done", "open")).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { WorkStatus } from "./workbase/schemas"
|
|
2
|
+
|
|
3
|
+
export interface ReadinessBlocker {
|
|
4
|
+
readonly id: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const WORK_STATUS_TRANSITIONS = {
|
|
8
|
+
open: ["open", "working", "delegated", "done", "dropped"],
|
|
9
|
+
working: ["open", "working", "delegated", "done", "dropped"],
|
|
10
|
+
delegated: ["open", "working", "delegated", "done", "dropped"],
|
|
11
|
+
done: ["open", "done"],
|
|
12
|
+
dropped: ["open", "dropped"],
|
|
13
|
+
} as const satisfies Record<WorkStatus, readonly WorkStatus[]>
|
|
14
|
+
|
|
15
|
+
export const isTerminalStatus = (status: WorkStatus) =>
|
|
16
|
+
status === "done" || status === "dropped"
|
|
17
|
+
|
|
18
|
+
export const isDependencySatisfied = (status: WorkStatus | undefined) =>
|
|
19
|
+
status === "done"
|
|
20
|
+
|
|
21
|
+
export const canTransitionStatus = (from: WorkStatus, to: WorkStatus) =>
|
|
22
|
+
(WORK_STATUS_TRANSITIONS[from] as readonly WorkStatus[]).includes(to)
|
|
23
|
+
|
|
24
|
+
export const aggregateProgress = (statuses: readonly WorkStatus[]) => {
|
|
25
|
+
const counts = {
|
|
26
|
+
total: statuses.length,
|
|
27
|
+
open: statuses.filter((status) => status === "open").length,
|
|
28
|
+
working: statuses.filter((status) => status === "working").length,
|
|
29
|
+
delegated: statuses.filter((status) => status === "delegated").length,
|
|
30
|
+
done: statuses.filter((status) => status === "done").length,
|
|
31
|
+
dropped: statuses.filter((status) => status === "dropped").length,
|
|
32
|
+
terminal: statuses.filter(isTerminalStatus).length,
|
|
33
|
+
}
|
|
34
|
+
const status: WorkStatus =
|
|
35
|
+
statuses.length === 0
|
|
36
|
+
? "open"
|
|
37
|
+
: statuses.every((value) => value === "done")
|
|
38
|
+
? "done"
|
|
39
|
+
: statuses.every(isTerminalStatus)
|
|
40
|
+
? "dropped"
|
|
41
|
+
: statuses.includes("working")
|
|
42
|
+
? "working"
|
|
43
|
+
: statuses.includes("delegated")
|
|
44
|
+
? "delegated"
|
|
45
|
+
: "open"
|
|
46
|
+
return { status, ...counts }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const readinessState = (
|
|
50
|
+
status: WorkStatus,
|
|
51
|
+
blockers: readonly ReadinessBlocker[],
|
|
52
|
+
ready = status === "open" && blockers.length === 0,
|
|
53
|
+
) => ({
|
|
54
|
+
ready,
|
|
55
|
+
blocked: !ready && blockers.length > 0,
|
|
56
|
+
blockedBy: [...new Set(blockers.map((blocker) => blocker.id))].sort(),
|
|
57
|
+
terminal: isTerminalStatus(status),
|
|
58
|
+
})
|
|
@@ -4,6 +4,7 @@ import { join, relative, resolve, sep } from "node:path"
|
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
5
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
6
|
import { RepositoryService } from "./RepositoryService"
|
|
7
|
+
import { aggregateProgress, readinessState } from "../readiness"
|
|
7
8
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
8
9
|
import {
|
|
9
10
|
EpicFrontmatter,
|
|
@@ -63,29 +64,6 @@ interface ReferenceCheckout extends CheckoutInspection {
|
|
|
63
64
|
readonly resolvedCommit: string | null
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
const statusCounts = (statuses: readonly WorkStatus[]) => ({
|
|
67
|
-
total: statuses.length,
|
|
68
|
-
open: statuses.filter((status) => status === "open").length,
|
|
69
|
-
working: statuses.filter((status) => status === "working").length,
|
|
70
|
-
delegated: statuses.filter((status) => status === "delegated").length,
|
|
71
|
-
done: statuses.filter((status) => status === "done").length,
|
|
72
|
-
dropped: statuses.filter((status) => status === "dropped").length,
|
|
73
|
-
terminal: statuses.filter(
|
|
74
|
-
(status) => status === "done" || status === "dropped",
|
|
75
|
-
).length,
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
const aggregateStatus = (statuses: readonly WorkStatus[]): WorkStatus => {
|
|
79
|
-
if (statuses.length === 0) return "open"
|
|
80
|
-
if (statuses.every((status) => status === "done")) return "done"
|
|
81
|
-
if (statuses.every((status) => status === "done" || status === "dropped")) {
|
|
82
|
-
return "dropped"
|
|
83
|
-
}
|
|
84
|
-
if (statuses.includes("working")) return "working"
|
|
85
|
-
if (statuses.includes("delegated")) return "delegated"
|
|
86
|
-
return "open"
|
|
87
|
-
}
|
|
88
|
-
|
|
89
67
|
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
90
68
|
schema: S,
|
|
91
69
|
input: unknown,
|
|
@@ -337,13 +315,13 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
337
315
|
return (record.data as PhaseData).status
|
|
338
316
|
const data = record.data as TaskData
|
|
339
317
|
if (!("phases" in data)) return data.status
|
|
340
|
-
return
|
|
318
|
+
return aggregateProgress(
|
|
341
319
|
data.phases.map(
|
|
342
320
|
(item) =>
|
|
343
321
|
phaseDocuments.get(`${record.taskId}/${item.id}`)?.data
|
|
344
322
|
.status ?? "open",
|
|
345
323
|
),
|
|
346
|
-
)
|
|
324
|
+
).status
|
|
347
325
|
}
|
|
348
326
|
|
|
349
327
|
const taskDependencyEntries = (
|
|
@@ -551,7 +529,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
551
529
|
)
|
|
552
530
|
: [child.data.status]
|
|
553
531
|
})
|
|
554
|
-
targetStatus =
|
|
532
|
+
targetStatus = aggregateProgress(aggregateStatuses).status
|
|
555
533
|
descendantsReady = epic.data.tasks.some((item: Dependency) =>
|
|
556
534
|
taskReady(item.id),
|
|
557
535
|
)
|
|
@@ -832,14 +810,10 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
832
810
|
dependencies,
|
|
833
811
|
dependents,
|
|
834
812
|
readiness: {
|
|
835
|
-
ready,
|
|
836
|
-
blocked: !ready && blockers.length > 0,
|
|
813
|
+
...readinessState(targetStatus, blockers, ready),
|
|
837
814
|
blockers,
|
|
838
815
|
},
|
|
839
|
-
aggregate:
|
|
840
|
-
status: aggregateStatus(aggregateStatuses),
|
|
841
|
-
...statusCounts(aggregateStatuses),
|
|
842
|
-
},
|
|
816
|
+
aggregate: aggregateProgress(aggregateStatuses),
|
|
843
817
|
},
|
|
844
818
|
authority: {
|
|
845
819
|
mode: executionData ? "execution" : "orchestration",
|
|
@@ -154,12 +154,33 @@ describe("GraphService", () => {
|
|
|
154
154
|
)!
|
|
155
155
|
const verify = graph.nodes.find((node) => node.id === "phase:ship/verify")!
|
|
156
156
|
expect(prepare.dependents).toEqual(["task:ship"])
|
|
157
|
-
expect(
|
|
157
|
+
expect(prepare.readiness).toMatchObject({
|
|
158
|
+
ready: false,
|
|
159
|
+
terminal: true,
|
|
160
|
+
})
|
|
161
|
+
expect(ship.readiness).toMatchObject({
|
|
162
|
+
ready: true,
|
|
163
|
+
blocked: false,
|
|
164
|
+
terminal: false,
|
|
165
|
+
})
|
|
166
|
+
expect(ship.aggregate).toMatchObject({ total: 2, open: 2, terminal: 0 })
|
|
158
167
|
expect(implement.dependents).toEqual(["phase:ship/verify"])
|
|
159
168
|
expect(implement.readiness).toMatchObject({ ready: true, blocked: false })
|
|
169
|
+
expect(
|
|
170
|
+
graph.nodes.find(
|
|
171
|
+
(node) => node.id === "execution-unit:phase/ship/implement",
|
|
172
|
+
)?.readiness,
|
|
173
|
+
).toMatchObject({
|
|
174
|
+
ready: true,
|
|
175
|
+
blocked: false,
|
|
176
|
+
blockedBy: [],
|
|
177
|
+
terminal: false,
|
|
178
|
+
})
|
|
160
179
|
expect(verify.readiness).toMatchObject({
|
|
161
180
|
ready: false,
|
|
162
181
|
blocked: true,
|
|
182
|
+
blockedBy: ["phase:ship/implement"],
|
|
183
|
+
terminal: false,
|
|
163
184
|
blockers: [
|
|
164
185
|
{
|
|
165
186
|
kind: "dependency",
|
|
@@ -168,6 +189,8 @@ describe("GraphService", () => {
|
|
|
168
189
|
},
|
|
169
190
|
],
|
|
170
191
|
})
|
|
192
|
+
const epic = graph.nodes.find((node) => node.id === "epic:delivery")!
|
|
193
|
+
expect(epic.aggregate).toMatchObject({ total: 3, done: 1, open: 2 })
|
|
171
194
|
expect(graph.summary).toEqual({
|
|
172
195
|
status: "open",
|
|
173
196
|
total: 3,
|
|
@@ -181,6 +204,36 @@ describe("GraphService", () => {
|
|
|
181
204
|
expect(await getGraph(root)).toEqual(graph)
|
|
182
205
|
})
|
|
183
206
|
|
|
207
|
+
test("never reports claimed or terminal execution units as ready", async () => {
|
|
208
|
+
const root = await createWorkbase()
|
|
209
|
+
roots.push(root)
|
|
210
|
+
const path = "tasks/ship/phases/implement/PHASE.md"
|
|
211
|
+
|
|
212
|
+
for (const status of ["working", "delegated", "done", "dropped"]) {
|
|
213
|
+
await write(
|
|
214
|
+
root,
|
|
215
|
+
path,
|
|
216
|
+
`---
|
|
217
|
+
repo: agency
|
|
218
|
+
branch: feat/implement
|
|
219
|
+
base: main
|
|
220
|
+
pr: null
|
|
221
|
+
status: ${status}
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
# Implement
|
|
225
|
+
`,
|
|
226
|
+
)
|
|
227
|
+
const graph = await getGraph(root, {
|
|
228
|
+
kinds: ["execution-unit"],
|
|
229
|
+
ready: true,
|
|
230
|
+
})
|
|
231
|
+
expect(graph.nodes.map((node) => node.key)).not.toContain(
|
|
232
|
+
"phase/ship/implement",
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
184
237
|
test("applies filters after computing graph state", async () => {
|
|
185
238
|
const root = await createWorkbase()
|
|
186
239
|
roots.push(root)
|
|
@@ -235,6 +288,7 @@ status: open
|
|
|
235
288
|
reason: "Unlisted phase 'orphan'",
|
|
236
289
|
},
|
|
237
290
|
],
|
|
291
|
+
blockedBy: ["tasks/ship/TASK.md"],
|
|
238
292
|
})
|
|
239
293
|
})
|
|
240
294
|
|
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
type GraphNode,
|
|
13
13
|
type GraphNodeKind,
|
|
14
14
|
type GraphPr,
|
|
15
|
-
type GraphProgress,
|
|
16
15
|
type GraphRepositoryGit,
|
|
17
16
|
} from "../graph-schema"
|
|
17
|
+
import {
|
|
18
|
+
aggregateProgress,
|
|
19
|
+
isDependencySatisfied,
|
|
20
|
+
readinessState,
|
|
21
|
+
} from "../readiness"
|
|
18
22
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
19
23
|
import {
|
|
20
24
|
EpicFrontmatter,
|
|
@@ -69,33 +73,6 @@ const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}`
|
|
|
69
73
|
const phaseExecutionNodeId = (taskId: string, phaseId: string) =>
|
|
70
74
|
`execution-unit:phase/${taskId}/${phaseId}`
|
|
71
75
|
|
|
72
|
-
const progress = (statuses: readonly WorkStatus[]): GraphProgress => {
|
|
73
|
-
const counts = {
|
|
74
|
-
total: statuses.length,
|
|
75
|
-
open: statuses.filter((status) => status === "open").length,
|
|
76
|
-
working: statuses.filter((status) => status === "working").length,
|
|
77
|
-
delegated: statuses.filter((status) => status === "delegated").length,
|
|
78
|
-
done: statuses.filter((status) => status === "done").length,
|
|
79
|
-
dropped: statuses.filter((status) => status === "dropped").length,
|
|
80
|
-
terminal: statuses.filter(
|
|
81
|
-
(status) => status === "done" || status === "dropped",
|
|
82
|
-
).length,
|
|
83
|
-
}
|
|
84
|
-
const status: WorkStatus =
|
|
85
|
-
statuses.length === 0
|
|
86
|
-
? "open"
|
|
87
|
-
: statuses.every((value) => value === "done")
|
|
88
|
-
? "done"
|
|
89
|
-
: statuses.every((value) => value === "done" || value === "dropped")
|
|
90
|
-
? "dropped"
|
|
91
|
-
: statuses.includes("working")
|
|
92
|
-
? "working"
|
|
93
|
-
: statuses.includes("delegated")
|
|
94
|
-
? "delegated"
|
|
95
|
-
: "open"
|
|
96
|
-
return { status, ...counts }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
76
|
const hash = (content: string) =>
|
|
100
77
|
new Bun.CryptoHasher("sha256").update(content).digest("hex")
|
|
101
78
|
|
|
@@ -256,7 +233,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
256
233
|
: [task.data.status]
|
|
257
234
|
}
|
|
258
235
|
const taskStatus = (taskId: string) =>
|
|
259
|
-
|
|
236
|
+
aggregateProgress(taskLeafStatuses(taskId)).status
|
|
260
237
|
const dependencyBlockers = (
|
|
261
238
|
dependencies: readonly string[],
|
|
262
239
|
toId: (id: string) => string,
|
|
@@ -265,7 +242,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
265
242
|
): GraphBlocker[] =>
|
|
266
243
|
dependencies.flatMap((dependency) => {
|
|
267
244
|
const value = status(dependency)
|
|
268
|
-
return value
|
|
245
|
+
return isDependencySatisfied(value)
|
|
269
246
|
? []
|
|
270
247
|
: [
|
|
271
248
|
{
|
|
@@ -336,10 +313,9 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
336
313
|
}
|
|
337
314
|
return {
|
|
338
315
|
status,
|
|
339
|
-
aggregate:
|
|
316
|
+
aggregate: aggregateProgress([status]),
|
|
340
317
|
readiness: {
|
|
341
|
-
|
|
342
|
-
blocked: status === "open" && blockers.length > 0,
|
|
318
|
+
...readinessState(status, blockers),
|
|
343
319
|
blockers: uniqueBlockers(blockers),
|
|
344
320
|
},
|
|
345
321
|
}
|
|
@@ -348,7 +324,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
348
324
|
const taskState = (taskId: string) => {
|
|
349
325
|
const task = tasks.get(taskId)
|
|
350
326
|
const statuses = taskLeafStatuses(taskId)
|
|
351
|
-
const aggregate =
|
|
327
|
+
const aggregate = aggregateProgress(statuses)
|
|
352
328
|
const descendantPaths = task
|
|
353
329
|
? [
|
|
354
330
|
relative(root, task.path),
|
|
@@ -410,8 +386,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
410
386
|
status: aggregate.status,
|
|
411
387
|
aggregate,
|
|
412
388
|
readiness: {
|
|
413
|
-
ready,
|
|
414
|
-
blocked: !ready && taskBlockers.length > 0,
|
|
389
|
+
...readinessState(aggregate.status, taskBlockers, ready),
|
|
415
390
|
blockers: taskBlockers,
|
|
416
391
|
},
|
|
417
392
|
}
|
|
@@ -422,7 +397,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
422
397
|
const statuses =
|
|
423
398
|
epic?.data.tasks.flatMap((item) => taskLeafStatuses(item.id)) ??
|
|
424
399
|
[]
|
|
425
|
-
const aggregate =
|
|
400
|
+
const aggregate = aggregateProgress(statuses)
|
|
426
401
|
const paths = epic
|
|
427
402
|
? [
|
|
428
403
|
relative(root, epic.path),
|
|
@@ -467,8 +442,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
467
442
|
status: aggregate.status,
|
|
468
443
|
aggregate,
|
|
469
444
|
readiness: {
|
|
470
|
-
ready,
|
|
471
|
-
blocked: !ready && epicBlockers.length > 0,
|
|
445
|
+
...readinessState(aggregate.status, epicBlockers, ready),
|
|
472
446
|
blockers: epicBlockers,
|
|
473
447
|
},
|
|
474
448
|
}
|
|
@@ -874,7 +848,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
874
848
|
includes,
|
|
875
849
|
nodes: filteredNodes,
|
|
876
850
|
edges: filteredEdges,
|
|
877
|
-
summary:
|
|
851
|
+
summary: aggregateProgress(leafStatuses),
|
|
878
852
|
validation: {
|
|
879
853
|
valid: validation.valid,
|
|
880
854
|
issues: validation.issues,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
formatMarkdownDocument,
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
|
+
import { canTransitionStatus } from "../readiness"
|
|
18
19
|
|
|
19
20
|
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
20
21
|
readonly message: string
|
|
@@ -340,6 +341,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
340
341
|
const service = yield* PhaseService
|
|
341
342
|
const validStatus = yield* decodeStatus(status)
|
|
342
343
|
const record = yield* service.show(taskId, id, startPath)
|
|
344
|
+
if (!canTransitionStatus(record.data.status, validStatus)) {
|
|
345
|
+
return yield* new PhaseError({
|
|
346
|
+
message: `Cannot transition phase '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
343
349
|
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
344
350
|
const data = { ...record.data, status: validStatus }
|
|
345
351
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
@@ -244,6 +244,22 @@ describe("task and phase services", () => {
|
|
|
244
244
|
expect(task.data.status).toBe("delegated")
|
|
245
245
|
expect(task.content).toContain("status: delegated")
|
|
246
246
|
expect(task.content).toContain("Describe the task outcome.")
|
|
247
|
+
await runTestEffect(
|
|
248
|
+
TaskService.pipe(
|
|
249
|
+
Effect.flatMap((service) =>
|
|
250
|
+
service.setStatus("single-status", "done", root),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
await expect(
|
|
255
|
+
runTestEffect(
|
|
256
|
+
TaskService.pipe(
|
|
257
|
+
Effect.flatMap((service) =>
|
|
258
|
+
service.setStatus("single-status", "dropped", root),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
).rejects.toThrow("reopen it first")
|
|
247
263
|
|
|
248
264
|
await runTestEffect(
|
|
249
265
|
TaskService.pipe(
|
|
@@ -283,6 +299,33 @@ describe("task and phase services", () => {
|
|
|
283
299
|
),
|
|
284
300
|
)
|
|
285
301
|
expect(phase.data.status).toBe("dropped")
|
|
302
|
+
await expect(
|
|
303
|
+
runTestEffect(
|
|
304
|
+
PhaseService.pipe(
|
|
305
|
+
Effect.flatMap((service) =>
|
|
306
|
+
service.setStatus("multi-status", "implementation", "done", root),
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
).rejects.toThrow("reopen it first")
|
|
311
|
+
await runTestEffect(
|
|
312
|
+
PhaseService.pipe(
|
|
313
|
+
Effect.flatMap((service) =>
|
|
314
|
+
service.setStatus("multi-status", "implementation", "open", root),
|
|
315
|
+
),
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
expect(
|
|
319
|
+
(
|
|
320
|
+
await runTestEffect(
|
|
321
|
+
PhaseService.pipe(
|
|
322
|
+
Effect.flatMap((service) =>
|
|
323
|
+
service.setStatus("multi-status", "implementation", "done", root),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
).data.status,
|
|
328
|
+
).toBe("done")
|
|
286
329
|
await expect(
|
|
287
330
|
runTestEffect(
|
|
288
331
|
TaskService.pipe(
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
formatMarkdownDocument,
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
|
+
import { canTransitionStatus } from "../readiness"
|
|
18
19
|
|
|
19
20
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
20
21
|
readonly message: string
|
|
@@ -212,6 +213,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
212
213
|
message: `Task '${id}' has multiple phases; set status on a phase instead`,
|
|
213
214
|
})
|
|
214
215
|
}
|
|
216
|
+
if (!canTransitionStatus(record.data.status, validStatus)) {
|
|
217
|
+
return yield* new TaskError({
|
|
218
|
+
message: `Cannot transition task '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
215
221
|
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
216
222
|
const data = { ...record.data, status: validStatus }
|
|
217
223
|
const content = formatMarkdownDocument(data, parsed.body)
|