@nanobpm/nano-workforce 0.91.0 → 0.93.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 +19 -0
- package/SPEC.md +10 -4
- package/app/admittedStaging.test.ts +98 -0
- package/app/contracts.ts +10 -1
- package/app/convergeGate.test.ts +80 -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/scopeGuard.test.ts +147 -0
- package/app/scopeGuard.ts +131 -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/resources/prompts/feature.md +30 -1
- package/scripts/pages-contract.test.ts +17 -18
- package/workers/converge-gate/worker.ts +46 -2
- 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.93.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
|
},
|
|
@@ -80,9 +80,38 @@ against the wrong base will not be merged into the epic.
|
|
|
80
80
|
3. Implement `task.prompt`. Keep the change scoped to this slice only.
|
|
81
81
|
4. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
|
|
82
82
|
branch, and open a pull request with `gh pr create` describing the slice and
|
|
83
|
-
linking the parent issue (`Depends-on:`/`Closes` as appropriate
|
|
83
|
+
linking the parent issue (`Depends-on:`/`Closes` as appropriate — but read the
|
|
84
|
+
scope-split rule below before you reach for `Closes`).
|
|
84
85
|
5. Clean up any scratch clone/worktree you created outside the commit.
|
|
85
86
|
|
|
87
|
+
## Closing keywords vs. scope splits — don't close a broader-scoped parent
|
|
88
|
+
|
|
89
|
+
The convergence loop runs a deterministic **scope-integrity gate** on your PR
|
|
90
|
+
before it can merge (`workers/converge-gate` → `app/scopeGuard.ts`). It exists
|
|
91
|
+
because a parity slice was once silently under-delivered: an agent shipped one
|
|
92
|
+
half, documented the deferred remainder honestly in a `## Scope` section, yet
|
|
93
|
+
still `Closes #N`'d the broader parent and filed **no** follow-up. The issue read
|
|
94
|
+
as done, `gh issue list` showed nothing outstanding, and a downstream consumer was
|
|
95
|
+
blocked on exactly the deferred half. Two rules keep that from recurring — the
|
|
96
|
+
gate **blocks and escalates to a human** if you break either:
|
|
97
|
+
|
|
98
|
+
1. **A `Closes/Fixes/Resolves #N` closing keyword means you delivered #N's FULL
|
|
99
|
+
stated scope.** If you split scope — shipping only part and deferring the rest
|
|
100
|
+
— do **not** close-keyword the parent. Use a non-closing ref instead
|
|
101
|
+
(`Refs #N` / `Part of #N`) and **leave #N open** (or convert #N into a
|
|
102
|
+
tracking/umbrella issue for the remainder). The gate flags any PR that both
|
|
103
|
+
closes #N and also contains deferral prose (a `## Scope` section, "deferred",
|
|
104
|
+
"out of scope").
|
|
105
|
+
2. **A deferred remainder must be a FILED, tracked issue — never just prose.** If
|
|
106
|
+
your PR defers part of its scope, **file a follow-up issue for each deferred
|
|
107
|
+
item** and link it in the PR body with an explicit tracking marker the gate can
|
|
108
|
+
see: `Deferred-to: #N`, `Tracked-in: #N`, or `Follow-up: #N`. A deferral that
|
|
109
|
+
lives only in commit/PR/ADR text is an invisible, unclaimable drift surface.
|
|
110
|
+
|
|
111
|
+
So: deliver the whole thing → `Closes #N`. Split it → `Refs #N`, file the
|
|
112
|
+
remainder, and link it with `Deferred-to: #<new-issue>`.
|
|
113
|
+
|
|
114
|
+
|
|
86
115
|
> **Do not request the Copilot review yourself.** When you open a *ready* PR the
|
|
87
116
|
> app enrolls it into the review-convergence loop and requests the initial
|
|
88
117
|
> Copilot review for you. In particular, **never escalate because Copilot is
|
|
@@ -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
|
});
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
// A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
|
|
12
12
|
// the human `wait-answer` escalation (recoverable), never a hard wedge.
|
|
13
13
|
//
|
|
14
|
+
// It ALSO enforces the scope-integrity guards (#313) over the PR description, blocking handoff when
|
|
15
|
+
// the PR under-delivers a broader-scoped parent:
|
|
16
|
+
// • a partial delivery that `Closes/Fixes/Resolves #N` while ALSO deferring scope (a `## Scope`
|
|
17
|
+
// section / "deferred" / "out of scope"), or
|
|
18
|
+
// • a deferral recorded only in PR prose with no filed follow-up issue linked for the remainder.
|
|
19
|
+
// This is the enforcement backstop for the Magikcraft/nano-bpm#631 → PR #863 (`Closes #631`, `##
|
|
20
|
+
// Scope` deferral, no follow-up → re-filed by hand as #872) failure class. See app/scopeGuard.ts.
|
|
21
|
+
//
|
|
14
22
|
// It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
|
|
15
23
|
// letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
|
|
16
24
|
// merge-gating check must escalate-on-uncertainty so #770 cannot recur.
|
|
@@ -18,11 +26,13 @@ import type { AppJobHandler } from "@nanobpm/urban";
|
|
|
18
26
|
import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
|
|
19
27
|
import {
|
|
20
28
|
fetchLatestCopilotReviewBody,
|
|
29
|
+
fetchPrMeta,
|
|
21
30
|
fetchReviewThreads,
|
|
22
31
|
parseAckedAdvisories,
|
|
23
32
|
parseSuppressedAdvisories,
|
|
24
33
|
type ReviewThread,
|
|
25
34
|
} from "../../app/github.ts";
|
|
35
|
+
import { evaluateScopeGuard } from "../../app/scopeGuard.ts";
|
|
26
36
|
import { parsePr } from "../../app/service.ts";
|
|
27
37
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
28
38
|
|
|
@@ -37,20 +47,31 @@ export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewTh
|
|
|
37
47
|
// Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
|
|
38
48
|
// `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
|
|
39
49
|
export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
50
|
+
// Reads the PR's own description body. `null` = no usable transport (unverifiable → fail closed);
|
|
51
|
+
// `""` = transport usable but the PR has an empty description (verified: nothing to scope-check).
|
|
52
|
+
export type PrBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
40
53
|
|
|
41
54
|
const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
|
|
42
55
|
fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
43
56
|
const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
|
|
44
57
|
fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
58
|
+
const defaultReadPrBody: PrBodyReader = async (repo, prNumber) => {
|
|
59
|
+
const meta = await fetchPrMeta(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
60
|
+
return meta ? meta.body : null;
|
|
61
|
+
};
|
|
45
62
|
|
|
46
63
|
const BLOCK_UNVERIFIABLE =
|
|
47
64
|
"Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
|
|
48
65
|
|
|
66
|
+
const BLOCK_UNVERIFIABLE_BODY =
|
|
67
|
+
"Convergence blocked: could not read the PR description from GitHub to verify scope integrity. A human must confirm this PR does not close a broader-scoped parent with an untracked deferred remainder before it converges (reply to resume the loop).";
|
|
68
|
+
|
|
49
69
|
/** Build the handler with injectable GitHub readers. The default export binds the real readers;
|
|
50
70
|
* tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
|
|
51
71
|
export function makeHandler(deps: {
|
|
52
72
|
readThreads: ThreadsReader;
|
|
53
73
|
readReviewBody: ReviewBodyReader;
|
|
74
|
+
readPrBody: PrBodyReader;
|
|
54
75
|
}): AppJobHandler<In, Out> {
|
|
55
76
|
return async (job) => {
|
|
56
77
|
const { prKey, repo, prNumber } = job.variables;
|
|
@@ -64,6 +85,7 @@ export function makeHandler(deps: {
|
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
let result: ConvergeGateResult;
|
|
88
|
+
let scopeReason: string;
|
|
67
89
|
try {
|
|
68
90
|
const threads = await deps.readThreads(ghRepo, ghNumber);
|
|
69
91
|
// A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
|
|
@@ -87,9 +109,30 @@ export function makeHandler(deps: {
|
|
|
87
109
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
88
110
|
}
|
|
89
111
|
|
|
112
|
+
// The scope-integrity guard (#313) reads/parses the PR description in its OWN try — a transport
|
|
113
|
+
// or parse failure here is a scope read failure, so it must surface BLOCK_UNVERIFIABLE_BODY, not
|
|
114
|
+
// the review-comment BLOCK_UNVERIFIABLE above. Sharing one catch would mislabel a description
|
|
115
|
+
// read failure as a review-thread verification failure and point the human escalation at the
|
|
116
|
+
// wrong place.
|
|
117
|
+
try {
|
|
118
|
+
// The PR description drives the scope-integrity guard (#313). A null read is unverifiable —
|
|
119
|
+
// fail closed with a scope-specific reason. (An empty STRING is a verified empty description:
|
|
120
|
+
// no closing keyword, no deferral, so the scope guard passes.)
|
|
121
|
+
const prBody = await deps.readPrBody(ghRepo, ghNumber);
|
|
122
|
+
if (prBody === null) {
|
|
123
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
|
|
124
|
+
}
|
|
125
|
+
scopeReason = evaluateScopeGuard({ prBody }).scopeBlockReason;
|
|
126
|
+
} catch {
|
|
127
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Both guards gate the same handoff to the merge loop: block if EITHER the review-comment gate
|
|
131
|
+
// or the scope-integrity gate blocks, joining their reasons so the human sees every cause.
|
|
132
|
+
const reason = [result.convergeBlockReason, scopeReason].filter((r) => r !== "").join(" ");
|
|
90
133
|
return {
|
|
91
|
-
convergeBlocked: result.convergeBlocked,
|
|
92
|
-
convergeBlockReason:
|
|
134
|
+
convergeBlocked: result.convergeBlocked || scopeReason !== "",
|
|
135
|
+
convergeBlockReason: reason,
|
|
93
136
|
};
|
|
94
137
|
};
|
|
95
138
|
}
|
|
@@ -97,5 +140,6 @@ export function makeHandler(deps: {
|
|
|
97
140
|
const handler = makeHandler({
|
|
98
141
|
readThreads: defaultReadThreads,
|
|
99
142
|
readReviewBody: defaultReadReviewBody,
|
|
143
|
+
readPrBody: defaultReadPrBody,
|
|
100
144
|
});
|
|
101
145
|
export default handler;
|
|
@@ -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
|
});
|