@nanobpm/nano-workforce 0.90.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.
@@ -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 { BaseBranchMustExistError } from "../app/github.ts";
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
- if (err instanceof MissingBaseBranchError) {
55
- app.log.warn("start-plan rejected: missing base branch");
56
- return {
57
- status: 400,
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.90.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",
@@ -61,16 +61,16 @@
61
61
  "source": "app",
62
62
  "table": "plans",
63
63
  "orderBy": { "field": "updated_at", "dir": "desc" },
64
- "filter": [{ "field": "status", "in": ["planning", "dispatched"] }]
64
+ "filter": [{ "field": "list_bucket", "in": ["active"] }]
65
65
  },
66
66
  "tabs": [
67
67
  {
68
68
  "label": "Active",
69
- "filter": [{ "field": "status", "in": ["planning", "dispatched"] }]
69
+ "filter": [{ "field": "list_bucket", "in": ["active"] }]
70
70
  },
71
71
  {
72
72
  "label": "History",
73
- "filter": [{ "field": "status", "in": ["done", "failed", "abandoned"] }]
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
  }
@@ -88,13 +88,25 @@
88
88
  "source": "app",
89
89
  "table": "plans",
90
90
  "orderBy": { "field": "updated_at", "dir": "desc" },
91
- "filter": [{ "field": "status", "in": ["planning", "dispatched"] }]
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
  },
@@ -84,8 +84,12 @@
84
84
  },
85
85
  "columns": [
86
86
  {
87
- "field": "subject_key",
88
- "header": "Feature"
87
+ "field": "subject_title",
88
+ "template": "{{subject_title}}",
89
+ "header": "Item",
90
+ "subtitleField": "subject_key",
91
+ "truncate": true,
92
+ "linkField": "subject_url"
89
93
  },
90
94
  {
91
95
  "field": "subject_url",
@@ -141,8 +145,12 @@
141
145
  },
142
146
  "columns": [
143
147
  {
144
- "field": "subject_key",
145
- "header": "Epic"
148
+ "field": "subject_title",
149
+ "template": "{{subject_title}}",
150
+ "header": "Item",
151
+ "subtitleField": "subject_key",
152
+ "truncate": true,
153
+ "linkField": "subject_url"
146
154
  },
147
155
  {
148
156
  "field": "question",
@@ -202,8 +210,12 @@
202
210
  },
203
211
  "columns": [
204
212
  {
205
- "field": "subject_key",
206
- "header": "Epic"
213
+ "field": "subject_title",
214
+ "template": "{{subject_title}}",
215
+ "header": "Item",
216
+ "subtitleField": "subject_key",
217
+ "truncate": true,
218
+ "linkField": "subject_url"
207
219
  },
208
220
  {
209
221
  "field": "question",
@@ -264,8 +276,12 @@
264
276
  },
265
277
  "columns": [
266
278
  {
267
- "field": "subject_key",
268
- "header": "PR"
279
+ "field": "subject_title",
280
+ "template": "{{subject_title}}",
281
+ "header": "Item",
282
+ "subtitleField": "subject_key",
283
+ "truncate": true,
284
+ "linkField": "subject_url"
269
285
  },
270
286
  {
271
287
  "field": "kind_label",
@@ -343,8 +359,12 @@
343
359
  },
344
360
  "columns": [
345
361
  {
346
- "field": "subject_key",
347
- "header": "Feature"
362
+ "field": "subject_title",
363
+ "template": "{{subject_title}}",
364
+ "header": "Item",
365
+ "subtitleField": "subject_key",
366
+ "truncate": true,
367
+ "linkField": "subject_url"
348
368
  },
349
369
  {
350
370
  "field": "question",
@@ -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
- const expected: Record<string, string[]> = {
275
- pull_requests: [
276
- "converging",
277
- "waiting_review",
278
- "escalated",
279
- "waiting_deps",
280
- "waiting_merge",
281
- "queued",
282
- "merging",
283
- ],
284
- plans: ["planning", "dispatched"],
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, statuses] of Object.entries(expected)) {
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 === "status");
294
- assert(filter, `overview "${table}" section must filter on status`);
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([...statuses].sort()),
297
- `overview "${table}" section must filter to the active statuses ${JSON.stringify(statuses)}`,
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.table("plans", "plan_key").update(planKey, patch);
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.push({ [key]: k, ...patch });
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.table("plans", "plan_key").update(planKey, {
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.table("plans", "plan_key").update(planKey, {
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 } : {}),