@nanobpm/nano-workforce 0.91.0 → 0.92.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/CHANGELOG.md +12 -0
- package/SPEC.md +10 -4
- package/app/admittedStaging.test.ts +98 -0
- package/app/contracts.ts +10 -1
- package/app/delivery.ts +61 -0
- package/app/epicBucket.test.ts +57 -0
- package/app/epicSetValidation.test.ts +169 -0
- package/app/migration045.test.ts +97 -0
- package/app/plan.ts +401 -12
- package/app/planGateway.test.ts +106 -0
- package/app/service.ts +13 -1
- package/db/migrations/044_plan_list_bucket.sql +32 -0
- package/db/migrations/045_epic_set_admission_staging.sql +56 -0
- package/e2e/support/engine-client.ts +24 -8
- package/openapi.yaml +245 -0
- package/operations/acknowledgeEpic.test.ts +130 -0
- package/operations/acknowledgeEpic.ts +63 -0
- package/operations/startEpicSet.admission.integration.test.ts +572 -0
- package/operations/startEpicSet.ts +217 -0
- package/operations/startPlanFanout.ts +5 -56
- package/package.json +1 -1
- package/pages/epic.page.json +15 -3
- package/pages/overview.page.json +13 -1
- package/scripts/pages-contract.test.ts +17 -18
- package/workers/record-plan/worker.test.ts +1 -0
- package/workers/record-plan/worker.ts +2 -2
- package/workers/record-results/worker.test.ts +19 -1
- package/workers/record-results/worker.ts +3 -3
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// POST /app/api/actions/start/epic-set → operationId `startEpicSet` (issue #292, slice S2). The
|
|
2
|
+
// set/batch admission door: it admits a WHOLE set of epics plus the inter-epic dependency edges
|
|
3
|
+
// between them in ONE all-or-nothing call, whereas `startPlanFanout` admits exactly one issue.
|
|
4
|
+
//
|
|
5
|
+
// The door is transactional at the durable layer: it VALIDATES the entire submission before it
|
|
6
|
+
// persists anything. The order is load-bearing so a bad set fails "at the offending edge with nothing
|
|
7
|
+
// half-started":
|
|
8
|
+
// 1. Parse every epic reference and collect the submitted set's plan keys (400 on an unparseable
|
|
9
|
+
// reference, or on EXACTLY-ONE-of issue|url being violated).
|
|
10
|
+
// 2. Pure, side-effect-free set validation (`validateEpicSet`): reference integrity (every edge
|
|
11
|
+
// connects two epics IN the set), no self-edge, non-blank capability descriptor, and an acyclic
|
|
12
|
+
// DAG. This runs BEFORE any `admitPlan` call, so a cycle / dangling edge is a clean 400 with no
|
|
13
|
+
// base branch created and no edge written.
|
|
14
|
+
// 3. Run the existing `admitPlan` gate PER epic (base-branch rules + shared-base guard), PLUS an
|
|
15
|
+
// in-request intra-set shared-base guard (two members of the same set cannot silently grab the
|
|
16
|
+
// same custom base, which admitPlan's durable-only rule 4 would miss). The first failure maps to
|
|
17
|
+
// its 4xx (400/409) via the shared `admitPlanErrorResponse`, before anything is persisted.
|
|
18
|
+
// 4. Only once every epic admits: STAGE the admitted set — each epic into `admitted_epics` and each
|
|
19
|
+
// validated edge into `admitted_plan_deps` — then return the admitted epics, the roots, and the
|
|
20
|
+
// staged edges.
|
|
21
|
+
//
|
|
22
|
+
// This slice deliberately does NOT start any epic or seed any readiness gate, and — per the #292
|
|
23
|
+
// design decision — it MATERIALIZES neither a `plans` row nor a `plan_deps` edge. Both are owned by
|
|
24
|
+
// slice S3 (planner lowering: schedule roots, seed the capability gate, bind the resolved version),
|
|
25
|
+
// which reads this staging and creates `plans` + `plan_deps` when it schedules roots — where the
|
|
26
|
+
// `plan_deps.plan_key REFERENCES plans(plan_key)` FK is satisfied by construction. S2 persists into
|
|
27
|
+
// its OWN FK-FREE staging tables instead, so a first-time set submission can never FK-fail here.
|
|
28
|
+
// Re-submitting the identical set is a no-op (admitPlan is idempotent on an already-created base + an
|
|
29
|
+
// inactive plan; the staging records collapse a duplicate epic/edge).
|
|
30
|
+
|
|
31
|
+
import { fetchDefaultBranch } from "../app/github.ts";
|
|
32
|
+
import {
|
|
33
|
+
admitPlan,
|
|
34
|
+
admitPlanErrorResponse,
|
|
35
|
+
EpicSetValidationError,
|
|
36
|
+
type ParsedIssue,
|
|
37
|
+
parseIssue,
|
|
38
|
+
recordAdmittedEpic,
|
|
39
|
+
recordAdmittedPlanDep,
|
|
40
|
+
SharedBaseError,
|
|
41
|
+
validateEpicSet,
|
|
42
|
+
} from "../app/plan.ts";
|
|
43
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
44
|
+
|
|
45
|
+
/** One parsed, admission-ready epic member: its parsed issue reference plus the per-epic admission
|
|
46
|
+
* inputs (`baseBranch` and the two opt-in acknowledgements) `admitPlan` consumes. */
|
|
47
|
+
interface EpicMember {
|
|
48
|
+
parsed: ParsedIssue;
|
|
49
|
+
baseBranch: string;
|
|
50
|
+
allowSharedBase: boolean;
|
|
51
|
+
confirmDefaultBase: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
55
|
+
// A directly-invoked delegate (or a missing body) leaves `body` undefined — guard so that is a 400,
|
|
56
|
+
// not a 500 from destructuring. The runtime validates a well-formed body against openapi.yaml.
|
|
57
|
+
if (!body || typeof body !== "object" || !Array.isArray(body.epics)) {
|
|
58
|
+
app.log.warn("start-epic-set rejected: missing or malformed request body");
|
|
59
|
+
return { status: 400, body: { error: "request body is required: { epics: [...], deps?: [...] }" } };
|
|
60
|
+
}
|
|
61
|
+
// `deps` is optional, but when provided it MUST be an array. A non-array `deps` (e.g. `deps: {…}`)
|
|
62
|
+
// would otherwise be silently coerced to `[]` — admitting the set while dropping every declared
|
|
63
|
+
// edge — so reject it with a clean 400 rather than losing the caller's intent.
|
|
64
|
+
if (body.deps != null && !Array.isArray(body.deps)) {
|
|
65
|
+
app.log.warn("start-epic-set rejected: deps is not an array");
|
|
66
|
+
return { status: 400, body: { error: "deps must be an array of dependency edges when provided" } };
|
|
67
|
+
}
|
|
68
|
+
const depsRaw = Array.isArray(body.deps) ? body.deps : [];
|
|
69
|
+
|
|
70
|
+
// ── Step 1: parse every epic reference into an admission-ready member ───────────────────────────
|
|
71
|
+
const members: EpicMember[] = [];
|
|
72
|
+
const planKeys: string[] = [];
|
|
73
|
+
for (const m of body.epics) {
|
|
74
|
+
if (!m || typeof m !== "object") {
|
|
75
|
+
app.log.warn("start-epic-set rejected: malformed epic entry");
|
|
76
|
+
return { status: 400, body: { error: "each epic must be an object with issue|url and baseBranch" } };
|
|
77
|
+
}
|
|
78
|
+
// Enforce EXACTLY-ONE-of issue|url (the operation contract + the error message below). Read each
|
|
79
|
+
// field through `in`-narrowing and validate it is a NON-BLANK STRING, so a key that is present
|
|
80
|
+
// but null/blank (e.g. `{ issue: null, url: "…" }`) does NOT count as provided — it falls through
|
|
81
|
+
// to the other field instead of bare key-presence silently winning.
|
|
82
|
+
const issueVal = "issue" in m ? m.issue : undefined;
|
|
83
|
+
const urlVal = "url" in m ? m.url : undefined;
|
|
84
|
+
const hasIssue = typeof issueVal === "string" && issueVal.trim().length > 0;
|
|
85
|
+
const hasUrl = typeof urlVal === "string" && urlVal.trim().length > 0;
|
|
86
|
+
if (hasIssue && hasUrl) {
|
|
87
|
+
app.log.warn("start-epic-set rejected: epic names both issue and url");
|
|
88
|
+
return { status: 400, body: { error: "each epic needs exactly one of issue or url, not both" } };
|
|
89
|
+
}
|
|
90
|
+
const ref = hasIssue ? issueVal : hasUrl ? urlVal : undefined;
|
|
91
|
+
if (typeof ref !== "string" || ref.trim().length === 0) {
|
|
92
|
+
app.log.warn("start-epic-set rejected: epic missing issue/url");
|
|
93
|
+
return { status: 400, body: { error: "each epic needs exactly one of issue or url (owner/repo#123 or an issue URL)" } };
|
|
94
|
+
}
|
|
95
|
+
const parsed = parseIssue(ref.trim());
|
|
96
|
+
if (!parsed) {
|
|
97
|
+
app.log.warn("start-epic-set rejected: unparseable epic reference", { ref });
|
|
98
|
+
return { status: 400, body: { error: `could not parse epic "${ref}" (use owner/repo#123 or an issue URL)` } };
|
|
99
|
+
}
|
|
100
|
+
members.push({
|
|
101
|
+
parsed,
|
|
102
|
+
baseBranch: typeof m.baseBranch === "string" ? m.baseBranch : "",
|
|
103
|
+
allowSharedBase: m.allowSharedBase === true,
|
|
104
|
+
confirmDefaultBase: m.confirmDefaultBase === true,
|
|
105
|
+
});
|
|
106
|
+
planKeys.push(parsed.planKey);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Step 2: pure set validation (reference integrity + DAG) — BEFORE any admitPlan side effect ──
|
|
110
|
+
let edges: ReturnType<typeof validateEpicSet>;
|
|
111
|
+
try {
|
|
112
|
+
edges = validateEpicSet(planKeys, depsRaw);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err instanceof EpicSetValidationError) {
|
|
115
|
+
app.log.warn("start-epic-set rejected: invalid set", { status: err.status, error: err.message });
|
|
116
|
+
return { status: err.status, body: { error: err.message } };
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Step 3: admit every epic through the existing gate (base rules + shared-base) ───────────────
|
|
122
|
+
// Nothing durable is written yet, so the first admission failure is a clean 4xx with no edge
|
|
123
|
+
// persisted. `selfPlanKey` excludes the epic's own active row so an idempotent re-submit does not
|
|
124
|
+
// 409 against itself.
|
|
125
|
+
const token = process.env.GITHUB_TOKEN ?? "";
|
|
126
|
+
const admitted: { parsed: ParsedIssue; baseBranch: string }[] = [];
|
|
127
|
+
// Intra-set shared-base guard: admitPlan's rule 4 only inspects DURABLE `plans` rows, and S2
|
|
128
|
+
// materializes none, so two members of THIS set reaching for the same custom integration branch
|
|
129
|
+
// would both slip past it and silently defeat ADR 0003 rule 4. Track each admitted member's
|
|
130
|
+
// custom (non-default) base per repo and reject a second, non-opted-in claim on it — mirroring the
|
|
131
|
+
// durable guard (the already-admitted member occupies the base regardless of its own flag; only a
|
|
132
|
+
// newcomer that sets `allowSharedBase: true` may stack on it). The default branch is exempt, just
|
|
133
|
+
// as it is in rule 4.
|
|
134
|
+
const claimedBases = new Map<string, Set<string>>();
|
|
135
|
+
for (const member of members) {
|
|
136
|
+
try {
|
|
137
|
+
const normalizedBase = await admitPlan(app.data, member.parsed.repo, member.baseBranch, token, {
|
|
138
|
+
allowSharedBase: member.allowSharedBase,
|
|
139
|
+
confirmDefaultBase: member.confirmDefaultBase,
|
|
140
|
+
selfPlanKey: member.parsed.planKey,
|
|
141
|
+
});
|
|
142
|
+
const defaultBranch = await fetchDefaultBranch(member.parsed.repo, token);
|
|
143
|
+
const isDefaultBase = defaultBranch !== null && normalizedBase === defaultBranch;
|
|
144
|
+
if (!isDefaultBase) {
|
|
145
|
+
const claimed = claimedBases.get(member.parsed.repo);
|
|
146
|
+
if (member.allowSharedBase !== true && claimed?.has(normalizedBase)) {
|
|
147
|
+
throw new SharedBaseError(member.parsed.repo, normalizedBase);
|
|
148
|
+
}
|
|
149
|
+
if (claimed) claimed.add(normalizedBase);
|
|
150
|
+
else claimedBases.set(member.parsed.repo, new Set([normalizedBase]));
|
|
151
|
+
}
|
|
152
|
+
admitted.push({ parsed: member.parsed, baseBranch: normalizedBase });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const mapped = admitPlanErrorResponse(err);
|
|
155
|
+
if (mapped) {
|
|
156
|
+
app.log.warn("start-epic-set rejected: epic admission gate", {
|
|
157
|
+
planKey: member.parsed.planKey,
|
|
158
|
+
status: mapped.status,
|
|
159
|
+
error: mapped.error,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
status: mapped.status,
|
|
163
|
+
body: { error: `epic ${member.parsed.planKey}: ${mapped.error}` },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Step 4: STAGE the admitted set + validated edges (idempotent). S2 is the admission DOOR only:
|
|
171
|
+
// per the #292 design decision it persists into ITS OWN FK-FREE staging tables and MATERIALIZES
|
|
172
|
+
// neither a `plans` row nor a `plan_deps` edge. Slice S3 (planner lowering) reads this staging and
|
|
173
|
+
// creates `plans` + `plan_deps` when it schedules roots — where the `plan_deps.plan_key REFERENCES
|
|
174
|
+
// plans(plan_key)` FK is satisfied by construction. Each admitted epic (INCLUDING roots) is staged
|
|
175
|
+
// so S3 can materialize its `plans` row; each validated edge is staged FK-free. Only reached once
|
|
176
|
+
// the WHOLE set admitted.
|
|
177
|
+
for (const a of admitted) {
|
|
178
|
+
await recordAdmittedEpic(app.data, {
|
|
179
|
+
plan_key: a.parsed.planKey,
|
|
180
|
+
repo: a.parsed.repo,
|
|
181
|
+
issue_number: a.parsed.number,
|
|
182
|
+
issue_url: a.parsed.url,
|
|
183
|
+
base_branch: a.baseBranch,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
for (const edge of edges) {
|
|
187
|
+
await recordAdmittedPlanDep(app.data, {
|
|
188
|
+
plan_key: edge.consumer,
|
|
189
|
+
depends_on_plan_key: edge.producer,
|
|
190
|
+
package: edge.package,
|
|
191
|
+
capability_ref: edge.capabilityRef,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Roots = admitted epics with no inbound edge — the ones S3 will start immediately.
|
|
196
|
+
const dependents = new Set(edges.map((e) => e.consumer));
|
|
197
|
+
const roots = admitted.map((a) => a.parsed.planKey).filter((k) => !dependents.has(k));
|
|
198
|
+
|
|
199
|
+
app.log.info("epic set admitted", {
|
|
200
|
+
epics: admitted.length,
|
|
201
|
+
edges: edges.length,
|
|
202
|
+
roots: roots.length,
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
status: 202,
|
|
206
|
+
body: {
|
|
207
|
+
epics: admitted.map((a) => ({ planKey: a.parsed.planKey, baseBranch: a.baseBranch })),
|
|
208
|
+
roots,
|
|
209
|
+
edges: edges.map((e) => ({
|
|
210
|
+
consumer: e.consumer,
|
|
211
|
+
producer: e.producer,
|
|
212
|
+
package: e.package,
|
|
213
|
+
capabilityRef: e.capabilityRef,
|
|
214
|
+
})),
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
});
|
|
@@ -10,16 +10,7 @@
|
|
|
10
10
|
// ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at the edge; this delegate just
|
|
11
11
|
// narrows the validated variant and keeps the issue-FORMAT parse guard (schema can't express it).
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
admitPlan,
|
|
16
|
-
DefaultBaseNotConfirmedError,
|
|
17
|
-
InvalidBaseBranchError,
|
|
18
|
-
MissingBaseBranchError,
|
|
19
|
-
parseIssue,
|
|
20
|
-
SharedBaseError,
|
|
21
|
-
startPlan,
|
|
22
|
-
} from "../app/plan.ts";
|
|
13
|
+
import { admitPlan, admitPlanErrorResponse, parseIssue, startPlan } from "../app/plan.ts";
|
|
23
14
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
24
15
|
|
|
25
16
|
export default defineOperation("startPlanFanout", async ({ body }, app) => {
|
|
@@ -51,52 +42,10 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
|
|
|
51
42
|
selfPlanKey: parsed.planKey,
|
|
52
43
|
});
|
|
53
44
|
} catch (err) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
body: { error: "baseBranch is required (name the integration branch, e.g. epic/agent-protocol)" },
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
if (err instanceof InvalidBaseBranchError) {
|
|
62
|
-
app.log.warn("start-plan rejected: invalid base branch", { baseBranch: err.value });
|
|
63
|
-
return {
|
|
64
|
-
status: 400,
|
|
65
|
-
body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)" },
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
if (err instanceof BaseBranchMustExistError) {
|
|
69
|
-
app.log.warn("start-plan rejected: base branch does not exist", { baseBranch: err.branch });
|
|
70
|
-
return {
|
|
71
|
-
status: 400,
|
|
72
|
-
body: {
|
|
73
|
-
error:
|
|
74
|
-
`baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
|
|
75
|
-
`auto-created — create it first, or use the epic/* convention`,
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
if (err instanceof DefaultBaseNotConfirmedError) {
|
|
80
|
-
app.log.warn("start-plan rejected: default base not confirmed", { baseBranch: err.branch });
|
|
81
|
-
return {
|
|
82
|
-
status: 400,
|
|
83
|
-
body: {
|
|
84
|
-
error:
|
|
85
|
-
`baseBranch "${err.branch}" is the repository default branch — every task would land ` +
|
|
86
|
-
`directly on it with no integration branch. Re-submit with confirmDefaultBase: true to proceed`,
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
if (err instanceof SharedBaseError) {
|
|
91
|
-
app.log.warn("start-plan rejected: shared base branch", { baseBranch: err.branch });
|
|
92
|
-
return {
|
|
93
|
-
status: 409,
|
|
94
|
-
body: {
|
|
95
|
-
error:
|
|
96
|
-
`baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
|
|
97
|
-
`allowSharedBase: true to stack on it, or name a distinct epic/* branch`,
|
|
98
|
-
},
|
|
99
|
-
};
|
|
45
|
+
const mapped = admitPlanErrorResponse(err);
|
|
46
|
+
if (mapped) {
|
|
47
|
+
app.log.warn("start-plan rejected: admission gate", { status: mapped.status, error: mapped.error });
|
|
48
|
+
return { status: mapped.status, body: { error: mapped.error } };
|
|
100
49
|
}
|
|
101
50
|
throw err;
|
|
102
51
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.92.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/epic.page.json
CHANGED
|
@@ -61,16 +61,16 @@
|
|
|
61
61
|
"source": "app",
|
|
62
62
|
"table": "plans",
|
|
63
63
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
64
|
-
"filter": [{ "field": "
|
|
64
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
65
65
|
},
|
|
66
66
|
"tabs": [
|
|
67
67
|
{
|
|
68
68
|
"label": "Active",
|
|
69
|
-
"filter": [{ "field": "
|
|
69
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
"label": "History",
|
|
73
|
-
"filter": [{ "field": "
|
|
73
|
+
"filter": [{ "field": "list_bucket", "in": ["history"] }]
|
|
74
74
|
},
|
|
75
75
|
{ "label": "All", "filter": [] }
|
|
76
76
|
],
|
|
@@ -80,11 +80,23 @@
|
|
|
80
80
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
81
81
|
{ "field": "delivery", "header": "Delivery" },
|
|
82
82
|
{ "field": "promotion_state", "header": "Promotion" },
|
|
83
|
+
{ "field": "delivery_label", "header": "Landing" },
|
|
83
84
|
{ "field": "base_branch", "header": "Base branch" },
|
|
84
85
|
{ "field": "wave_label", "header": "Wave" },
|
|
85
86
|
{ "field": "task_count", "header": "Tasks" },
|
|
86
87
|
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
|
87
88
|
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
89
|
+
],
|
|
90
|
+
"rowActions": [
|
|
91
|
+
{
|
|
92
|
+
"label": "Dismiss",
|
|
93
|
+
"confirm": "Dismiss this resolved epic? Its slices have all reached a terminal state \u2014 it acknowledges the epic and files it under History (if all slices landed, raise the integration\u2192main promotion PR first).",
|
|
94
|
+
"showWhenField": "ack_open",
|
|
95
|
+
"action": {
|
|
96
|
+
"path": "/app/api/actions/acknowledge-epic",
|
|
97
|
+
"body": { "plan_key": "{{row.plan_key}}" }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
88
100
|
]
|
|
89
101
|
}
|
|
90
102
|
}
|
package/pages/overview.page.json
CHANGED
|
@@ -88,13 +88,25 @@
|
|
|
88
88
|
"source": "app",
|
|
89
89
|
"table": "plans",
|
|
90
90
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
91
|
-
"filter": [{ "field": "
|
|
91
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
92
92
|
},
|
|
93
93
|
"columns": [
|
|
94
94
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
95
95
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
96
|
+
{ "field": "delivery_label", "header": "Landing" },
|
|
96
97
|
{ "field": "wave_label", "header": "Wave" },
|
|
97
98
|
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
99
|
+
],
|
|
100
|
+
"rowActions": [
|
|
101
|
+
{
|
|
102
|
+
"label": "Dismiss",
|
|
103
|
+
"confirm": "Dismiss this resolved epic? Its slices have all reached a terminal state \u2014 it acknowledges the epic and files it under History.",
|
|
104
|
+
"showWhenField": "ack_open",
|
|
105
|
+
"action": {
|
|
106
|
+
"path": "/app/api/actions/acknowledge-epic",
|
|
107
|
+
"body": { "plan_key": "{{row.plan_key}}" }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
98
110
|
]
|
|
99
111
|
}
|
|
100
112
|
},
|
|
@@ -270,31 +270,30 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
// Three collapsible active-work sections, one per dispatch surface, each with a
|
|
273
|
-
// live count in its header (showCount) and a persisted collapse toggle (collapsible).
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
"
|
|
281
|
-
"queued",
|
|
282
|
-
|
|
283
|
-
],
|
|
284
|
-
|
|
285
|
-
feature_runs: ["running", "escalated", "awaiting_operator"],
|
|
273
|
+
// live count in its header (showCount) and a persisted collapse toggle (collapsible). Each filters
|
|
274
|
+
// its Active list on a `{field, in:[...]}` predicate: the PR / feature surfaces on `status`, but the
|
|
275
|
+
// EPIC surface buckets on the DERIVED `list_bucket` (issue #298) — NOT raw `status` — so a `done`
|
|
276
|
+
// epic still converging, or landed-but-unpromoted, does not vanish from the in-flight Epics section
|
|
277
|
+
// the instant `status=done`. Guarding the field here is the regression guard for that defect class.
|
|
278
|
+
const expected: Record<string, { field: string; in: string[] }> = {
|
|
279
|
+
pull_requests: {
|
|
280
|
+
field: "status",
|
|
281
|
+
in: ["converging", "waiting_review", "escalated", "waiting_deps", "waiting_merge", "queued", "merging"],
|
|
282
|
+
},
|
|
283
|
+
plans: { field: "list_bucket", in: ["active"] },
|
|
284
|
+
feature_runs: { field: "status", in: ["running", "escalated", "awaiting_operator"] },
|
|
286
285
|
};
|
|
287
286
|
const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
|
|
288
|
-
for (const [table,
|
|
287
|
+
for (const [table, { field, in: values }] of Object.entries(expected)) {
|
|
289
288
|
const grid = grids.find((g: Json) => g.props?.data?.table === table);
|
|
290
289
|
assert(grid, `overview.page.json must have a section bound to "${table}"`);
|
|
291
290
|
assert(grid.props.collapsible === true, `overview "${table}" section must be collapsible`);
|
|
292
291
|
assert(grid.props.showCount === true, `overview "${table}" section must show a live count`);
|
|
293
|
-
const filter = grid.props?.data?.filter?.find((f: Json) => f.field ===
|
|
294
|
-
assert(filter, `overview "${table}" section must filter on
|
|
292
|
+
const filter = grid.props?.data?.filter?.find((f: Json) => f.field === field);
|
|
293
|
+
assert(filter, `overview "${table}" section must filter on ${field}`);
|
|
295
294
|
assert(
|
|
296
|
-
JSON.stringify([...filter.in].sort()) === JSON.stringify([...
|
|
297
|
-
`overview "${table}" section must filter to
|
|
295
|
+
JSON.stringify([...filter.in].sort()) === JSON.stringify([...values].sort()),
|
|
296
|
+
`overview "${table}" section must filter ${field} to ${JSON.stringify(values)}`,
|
|
298
297
|
);
|
|
299
298
|
}
|
|
300
299
|
});
|
|
@@ -29,6 +29,7 @@ function fakeApp() {
|
|
|
29
29
|
? planTaskDeps
|
|
30
30
|
: plans;
|
|
31
31
|
return {
|
|
32
|
+
get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
32
33
|
find: (q: Record<string, unknown>) =>
|
|
33
34
|
Promise.resolve(
|
|
34
35
|
store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// in that case (the edges were invalid).
|
|
18
18
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
19
19
|
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
20
|
-
import { planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
20
|
+
import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
21
21
|
import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
|
|
22
22
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
23
23
|
|
|
@@ -134,7 +134,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
134
134
|
const epicPhase = deriveEpicPhase(job.elementId);
|
|
135
135
|
if (epicPhase) patch.epic_phase = epicPhase;
|
|
136
136
|
if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
|
|
137
|
-
await app.data
|
|
137
|
+
await plans(app.data).update(planKey, patch);
|
|
138
138
|
|
|
139
139
|
// Kick off the wave loop at wave 0.
|
|
140
140
|
return { currentWave: 0, waveCount };
|
|
@@ -26,9 +26,23 @@ function fakeApp(rows: Row[]) {
|
|
|
26
26
|
data: {
|
|
27
27
|
table(name: string, key: string) {
|
|
28
28
|
if (name === "plans") {
|
|
29
|
+
// A full in-memory table double so the real `plans` gateway proxy (which reads back the row
|
|
30
|
+
// to reproject `list_bucket`/`ack_open`) works: get/all/insert/update, update upserting so a
|
|
31
|
+
// worker that updates an as-yet-unseeded plan still lands a row (mirrors production, where
|
|
32
|
+
// startPlan inserted it first).
|
|
29
33
|
return {
|
|
34
|
+
all: () => Promise.resolve(plans.slice()),
|
|
35
|
+
get: (k: any) => Promise.resolve(plans.find((p) => p[key] === k)),
|
|
36
|
+
find: (q: any) =>
|
|
37
|
+
Promise.resolve(plans.filter((p) => Object.entries(q).every(([f, v]) => p[f] === v))),
|
|
38
|
+
insert: (row: any) => {
|
|
39
|
+
plans.push({ ...row });
|
|
40
|
+
return Promise.resolve(row[key]);
|
|
41
|
+
},
|
|
30
42
|
update: (k: any, patch: any) => {
|
|
31
|
-
plans.
|
|
43
|
+
const existing = plans.find((p) => p[key] === k);
|
|
44
|
+
if (existing) Object.assign(existing, patch);
|
|
45
|
+
else plans.push({ [key]: k, ...patch });
|
|
32
46
|
return Promise.resolve(patch);
|
|
33
47
|
},
|
|
34
48
|
};
|
|
@@ -63,6 +77,8 @@ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () =
|
|
|
63
77
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
64
78
|
assertEquals(plan.status, "failed");
|
|
65
79
|
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
80
|
+
// The gateway projects the bucket: a failed epic settles to History (no tick-off needed).
|
|
81
|
+
assertEquals(plan.list_bucket, "history");
|
|
66
82
|
});
|
|
67
83
|
|
|
68
84
|
test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|
|
@@ -86,4 +102,6 @@ test("at least one opened PR finalizes cleanly (no throw)", async () => {
|
|
|
86
102
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
87
103
|
assertEquals(plan.status, "done");
|
|
88
104
|
assertEquals(plan.outcome, "1 PR(s) dispatched to convergence");
|
|
105
|
+
// A just-`done` epic (delivery not yet projected) stays in Active — it must not vanish (#298).
|
|
106
|
+
assertEquals(plan.list_bucket, "active");
|
|
89
107
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
15
|
import { BpmnError } from "@nanobpm/urban";
|
|
16
16
|
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
17
|
-
import { planTasks } from "../../app/plan.ts";
|
|
17
|
+
import { plans, planTasks } from "../../app/plan.ts";
|
|
18
18
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
19
19
|
|
|
20
20
|
// Input typed off the model data envelope (`RecordResultsIn` in plan-fanout.bpmn) — ADR 0040.
|
|
@@ -39,7 +39,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
39
39
|
const outcome = rows.length === 0
|
|
40
40
|
? "no work dispatched — the planner produced no tasks"
|
|
41
41
|
: "no work dispatched — every task was blocked or skipped";
|
|
42
|
-
await app.data
|
|
42
|
+
await plans(app.data).update(planKey, {
|
|
43
43
|
status: "failed",
|
|
44
44
|
outcome,
|
|
45
45
|
updated_at: ts,
|
|
@@ -56,7 +56,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
56
56
|
// outcome, and stamping "Dispatched" against a failed epic would misread. A null derivation
|
|
57
57
|
// (element id absent) must not clobber the last implementing phase.
|
|
58
58
|
const epicPhase = deriveEpicPhase(job.elementId);
|
|
59
|
-
await app.data
|
|
59
|
+
await plans(app.data).update(planKey, {
|
|
60
60
|
status: "done",
|
|
61
61
|
outcome: `${opened} PR(s) dispatched to convergence`,
|
|
62
62
|
...(epicPhase ? { epic_phase: epicPhase } : {}),
|