@nanobpm/nano-workforce 0.163.1 → 0.163.3
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/docs/mcp-runbook.md +17 -6
- package/e2e/mcp-surface.e2e.ts +42 -11
- package/package.json +3 -3
- package/pages/overview.page.json +2 -2
- package/scripts/pages-contract.test.ts +67 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.163.3](https://github.com/nanobpm/nano-workforce/compare/v0.163.2...v0.163.3) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **overview:** Active Features grid reads the derived read model, not the base-status allowlist ([#639](https://github.com/nanobpm/nano-workforce/issues/639)) ([ed7ddc1](https://github.com/nanobpm/nano-workforce/commit/ed7ddc10c6e99d097e079eb2657e51e572f477df)), closes [#637](https://github.com/nanobpm/nano-workforce/issues/637) [#638](https://github.com/nanobpm/nano-workforce/issues/638)
|
|
6
|
+
|
|
7
|
+
## [0.163.2](https://github.com/nanobpm/nano-workforce/compare/v0.163.1...v0.163.2) (2026-08-31)
|
|
8
|
+
|
|
9
|
+
### Build System
|
|
10
|
+
|
|
11
|
+
* **deps:** bump @nanobpm/urban-testkit to ^1.0.0 (native callActivity in e2e) ([#635](https://github.com/nanobpm/nano-workforce/issues/635)) ([2020817](https://github.com/nanobpm/nano-workforce/commit/2020817a2b5baeec347b2c808212720ab58328cb)), closes [#631](https://github.com/nanobpm/nano-workforce/issues/631) [#591](https://github.com/nanobpm/nano-workforce/issues/591) [nano-ide#503](https://github.com/nanobpm/nano-ide/issues/503) [#607](https://github.com/nanobpm/nano-workforce/issues/607) [nano-ide#503](https://github.com/nanobpm/nano-ide/issues/503) [#607](https://github.com/nanobpm/nano-workforce/issues/607) [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#607](https://github.com/nanobpm/nano-workforce/issues/607) [nano-ide#503](https://github.com/nanobpm/nano-ide/issues/503)
|
|
12
|
+
|
|
1
13
|
## [0.163.1](https://github.com/nanobpm/nano-workforce/compare/v0.163.0...v0.163.1) (2026-08-30)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -130,7 +130,10 @@ and does **not** resolve `$ref`s, so every projected (non-`x-mcp`) request-body
|
|
|
130
130
|
in `openapi.yaml` presents an inline `type: object` body with no `$ref`; the two graph doors
|
|
131
131
|
additionally carry a worked `example` — an agent discovers the body shape (and calls the tool
|
|
132
132
|
with a real object, not a
|
|
133
|
-
stringified one) from the surface alone
|
|
133
|
+
stringified one) from the surface alone — and even if an agent's client emits a
|
|
134
|
+
stringified object body, the door itself now **faithfully parses** it rather than
|
|
135
|
+
rejecting it (nano-ide#503, a server-side input-compatibility behavior that needs no
|
|
136
|
+
client upgrade — see the faithful-transport note below). The two graph doors split by convention:
|
|
134
137
|
`compileDeliveryGraph` takes the **structured `DeliveryGraph` object** (and *stages*);
|
|
135
138
|
`previewDeliveryGraph` takes the **text shape `{ "graphJson": "<serialized DeliveryGraph>" }`**
|
|
136
139
|
(and is *pure*). Every validation failure returns `issues`/`errors` as `[{ path, message }]`.
|
|
@@ -138,9 +141,14 @@ The inline bodies are **derived** from `components.schemas` by
|
|
|
138
141
|
`scripts/inline-mcp-bodies.ts` (single source of truth; run `npm run gen:mcp-bodies` after
|
|
139
142
|
editing a component), and `npm run check:mcp-bodies` + `test/mcp-tool-schemas.test.ts` (which
|
|
140
143
|
runs the real projector) fail CI if a `$ref` ever re-leaks. The upstream projector fix that
|
|
141
|
-
would make
|
|
144
|
+
would make the **schema** mitigation unnecessary is tracked in
|
|
142
145
|
[nano-ide#501](https://github.com/nanobpm/nano-ide/issues/501) (#502 self-contained schemas,
|
|
143
|
-
#503 faithful object-body transport
|
|
146
|
+
#504 real-spec conformance guard). **#503 faithful object-body transport has landed** —
|
|
147
|
+
`@nanobpm/urban` 0.87 ships ADR 0067's `normalizeBodyArg`, so the MCP door now **parses** a
|
|
148
|
+
stringified object body and forwards it faithfully instead of rejecting it with `expected object,
|
|
149
|
+
got string`. That retired the nwf-local stringified-body reject mitigation: the e2e guard in
|
|
150
|
+
`e2e/mcp-surface.e2e.ts` now asserts the door faithfully parses a stringified body (the
|
|
151
|
+
`assertObjectBodyAccepted` detector's teeth stay pinned synthetically).
|
|
144
152
|
|
|
145
153
|
## 5. Fallback
|
|
146
154
|
|
|
@@ -178,13 +186,16 @@ depends on:
|
|
|
178
186
|
|
|
179
187
|
- every projected tool schema is `$ref`-free with an explicit `type` (a leaked
|
|
180
188
|
`$ref` is unresolvable in the MCP context);
|
|
181
|
-
- an object argument arrives **as an object
|
|
189
|
+
- an object argument arrives **as an object** — and a stringified one is faithfully
|
|
190
|
+
**parsed** by the door (ADR 0067 / nano-ide#503, `@nanobpm/urban` ≥ 0.87), never
|
|
191
|
+
rejected as `expected object, got string`;
|
|
182
192
|
- validation failures answer uniformly with `issues[{path,message}]`;
|
|
183
193
|
- side-effecting calls stage nothing, so the suite is safe to re-run.
|
|
184
194
|
|
|
185
195
|
It runs in CI under `npm run e2e` (hermetic — no socket, no GitHub), so a
|
|
186
|
-
reintroduced `$ref`
|
|
187
|
-
reaching an agent
|
|
196
|
+
reintroduced `$ref` fails the build, and a stringified object body is asserted to be
|
|
197
|
+
faithfully parsed by the door (ADR 0067 / nano-ide#503) instead of reaching an agent
|
|
198
|
+
mis-serialized.
|
|
188
199
|
|
|
189
200
|
**Extending it (new per-tool case).** Import `bootMcpHarness` from
|
|
190
201
|
`e2e/support/mcp-harness.ts` in your own `e2e/<slice>.e2e.ts` and drive
|
package/e2e/mcp-surface.e2e.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// `e2e/support/mcp-harness.ts` module. It PINS the client-visible contract the S0 defect broke:
|
|
7
7
|
//
|
|
8
8
|
// • every projected tool schema is `$ref`-free with an explicit `type` (S0 / nano-ide#502);
|
|
9
|
-
// • an object argument arrives AS AN OBJECT
|
|
9
|
+
// • an object argument arrives AS AN OBJECT — and a stringified one is faithfully PARSED, not
|
|
10
|
+
// rejected, now that ADR 0067 / nano-ide#503 landed upstream in @nanobpm/urban 0.87;
|
|
10
11
|
// • validation failures answer uniformly with `issues[{path,message}]`;
|
|
11
12
|
// • the reads parse; the mutating framework tools are gated; side-effecting calls leave NO live
|
|
12
13
|
// staged proposal behind (safe to run repeatedly).
|
|
@@ -192,9 +193,14 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
|
|
|
192
193
|
);
|
|
193
194
|
});
|
|
194
195
|
|
|
195
|
-
// The falsifiable core (issue #607 acceptance): DELIBERATELY reintroducing
|
|
196
|
+
// The falsifiable core (issue #607 acceptance): DELIBERATELY reintroducing the SCHEMA half of the S0
|
|
196
197
|
// defect makes the harness fail. These pin the detector's teeth independently of whether S0 has
|
|
197
|
-
// landed — so the guard cannot silently rot into a no-op.
|
|
198
|
+
// landed — so the guard cannot silently rot into a no-op. The object-body-stringification half
|
|
199
|
+
// (nano-ide#503) is now fixed UPSTREAM — @nanobpm/urban 0.87 lands ADR 0067 "faithful object-body
|
|
200
|
+
// transport" (`normalizeBodyArg`), so the door PARSES a stringified object body and forwards it
|
|
201
|
+
// faithfully instead of rejecting it. That retired the nwf-local reject mitigation (docs/mcp-runbook.md
|
|
202
|
+
// §4): the live door can no longer produce the S0 signature to exercise end-to-end, so that half is
|
|
203
|
+
// now covered by a live faithful-parse assertion plus a synthetic detector-teeth check below.
|
|
198
204
|
describe("reintroducing the S0 defect fails the build", () => {
|
|
199
205
|
test("a $ref in a tool schema is caught by the self-containment assertion", () => {
|
|
200
206
|
const good = { type: "object", properties: { body: { type: "object", properties: { n: { type: "number" } } } } };
|
|
@@ -208,17 +214,42 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
|
|
|
208
214
|
assert.throws(() => assertSchemaSelfContained(typeless, "synthetic-typeless"), /type/, "a typeless schema must throw");
|
|
209
215
|
});
|
|
210
216
|
|
|
211
|
-
test("a stringified object body is
|
|
212
|
-
// Simulate the S0 client coercion: send the body as a JSON STRING instead of an object.
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
test("a stringified object body is faithfully parsed by the door (ADR 0067 / nano-ide#503)", async () => {
|
|
218
|
+
// Simulate the old S0 client coercion: send the body as a JSON STRING instead of an object.
|
|
219
|
+
// @nanobpm/urban 0.87's faithful object-body transport (ADR 0067 `normalizeBodyArg`) now PARSES
|
|
220
|
+
// it and forwards it faithfully — no longer the "expected object, got string" rejection the
|
|
221
|
+
// nwf-local S0 mitigation used to raise. Drive the PURE previewDeliveryGraph door so the
|
|
222
|
+
// parsed-and-compiled graph stages nothing.
|
|
223
|
+
const res = await h.callTool("previewDeliveryGraph", {
|
|
224
|
+
body: JSON.stringify({ graphJson: JSON.stringify(MINIMAL_VALID_GRAPH) }),
|
|
225
|
+
});
|
|
226
|
+
assert.ok(!res.isError, `the door must faithfully parse a stringified object body: ${res.text}`);
|
|
215
227
|
assert.ok(
|
|
216
|
-
res.text.includes(STRINGIFIED_BODY_MESSAGE),
|
|
217
|
-
`
|
|
228
|
+
!res.text.includes(STRINGIFIED_BODY_MESSAGE),
|
|
229
|
+
`faithful transport must not reject with "${STRINGIFIED_BODY_MESSAGE}": ${res.text}`,
|
|
218
230
|
);
|
|
219
|
-
|
|
231
|
+
assert.doesNotThrow(
|
|
232
|
+
() => assertObjectBodyAccepted(res, "previewDeliveryGraph"),
|
|
233
|
+
"a faithfully parsed object body must pass assertObjectBodyAccepted",
|
|
234
|
+
);
|
|
235
|
+
const json = res.json as { ok?: boolean; staged?: boolean } | undefined;
|
|
236
|
+
assert.equal(json?.ok, true, `previewDeliveryGraph must compile the parsed graph: ${res.text}`);
|
|
237
|
+
assert.equal(json?.staged, false, "previewDeliveryGraph is PURE — it must never stage");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("assertObjectBodyAccepted still flags an S0 stringified-body signature (detector teeth)", () => {
|
|
241
|
+
// The live door can no longer produce the S0 signature (fixed upstream, ADR 0067), so pin the
|
|
242
|
+
// detector's teeth SYNTHETICALLY — mirroring the $ref/typeless guards above — so the helper
|
|
243
|
+
// cannot rot into a no-op if the signature ever re-surfaces from another surface.
|
|
244
|
+
const s0Result = {
|
|
245
|
+
isError: true,
|
|
246
|
+
text: `validation failed: body: ${STRINGIFIED_BODY_MESSAGE}`,
|
|
247
|
+
json: undefined,
|
|
248
|
+
httpStatus: 422,
|
|
249
|
+
raw: undefined,
|
|
250
|
+
};
|
|
220
251
|
assert.throws(
|
|
221
|
-
() => assertObjectBodyAccepted(
|
|
252
|
+
() => assertObjectBodyAccepted(s0Result, "synthetic-stringified"),
|
|
222
253
|
/stringified/,
|
|
223
254
|
"assertObjectBodyAccepted must flag a stringified-body result",
|
|
224
255
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.163.
|
|
3
|
+
"version": "0.163.3",
|
|
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,12 +61,12 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@nanobpm/agentic": "^0.4.0",
|
|
64
|
-
"@nanobpm/urban": "^0.
|
|
64
|
+
"@nanobpm/urban": "^0.87.0",
|
|
65
65
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@biomejs/biome": "^2.4.11",
|
|
69
|
-
"@nanobpm/urban-testkit": "^0.
|
|
69
|
+
"@nanobpm/urban-testkit": "^1.0.0",
|
|
70
70
|
"@nanobpm/workflow": "^0.14.0",
|
|
71
71
|
"@semantic-release/changelog": "^7.0.0",
|
|
72
72
|
"@semantic-release/git": "^11.0.0",
|
package/pages/overview.page.json
CHANGED
|
@@ -177,9 +177,9 @@
|
|
|
177
177
|
"data": {
|
|
178
178
|
"kind": "datasource",
|
|
179
179
|
"source": "app",
|
|
180
|
-
"table": "
|
|
180
|
+
"table": "feature_read_model",
|
|
181
181
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
182
|
-
"filter": [{ "field": "
|
|
182
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
183
183
|
},
|
|
184
184
|
"columns": [
|
|
185
185
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
|
|
@@ -317,21 +317,24 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
317
317
|
);
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
//
|
|
320
|
+
// Four collapsible active-work sections, one per dispatch surface, each with a
|
|
321
321
|
// live count in its header (showCount) and a persisted collapse toggle (collapsible). Each filters
|
|
322
|
-
// its Active list on a `{field, in:[...]}` predicate
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
322
|
+
// its Active list on a `{field, in:[...]}` predicate. The FEATURE and EPIC surfaces bucket on the
|
|
323
|
+
// DERIVED `list_bucket` — NOT raw `status` — over their read-model VIEW (features: issue #637 —
|
|
324
|
+
// fixing a drift where the grid read the vestigial base `feature_runs.status` with an allowlist that
|
|
325
|
+
// hid every converging feature; epics: issue #298), so a `done`-but-unacknowledged item still
|
|
326
|
+
// converging does not vanish from its in-flight section the instant `status` reads terminal. Guarding
|
|
327
|
+
// the field here is the regression guard for that defect class. The feature surface binds the derived
|
|
328
|
+
// `feature_read_model` VIEW (the single source of truth for the `list_bucket` activeness predicate,
|
|
329
|
+
// shared byte-for-byte with the Feature tab); the epic surface binds `plan_read_model` (epic #412),
|
|
330
|
+
// not the raw `plans` table. PRs / delivery graphs still bucket on `status` (see #637 follow-up).
|
|
328
331
|
const expected: Record<string, { field: string; in: string[] }> = {
|
|
329
332
|
pull_requests: {
|
|
330
333
|
field: "status",
|
|
331
334
|
in: ["converging", "waiting_review", "escalated", "waiting_deps", "waiting_merge", "queued", "merging"],
|
|
332
335
|
},
|
|
333
336
|
plan_read_model: { field: "list_bucket", in: ["active"] },
|
|
334
|
-
|
|
337
|
+
feature_read_model: { field: "list_bucket", in: ["active"] },
|
|
335
338
|
// The 4th dispatch surface (issue #386) — active delivery graphs. Both in-flight statuses
|
|
336
339
|
// (`awaiting-approval` parked at the gate, `running` dispatched) show here. Binds the derived
|
|
337
340
|
// `delivery_graph_read_model` VIEW (S7 / #541 — the single source of truth for the pipeline
|
|
@@ -461,3 +464,59 @@ test("issue #521: the Delivery Graphs History tab surfaces dispatch time + the i
|
|
|
461
464
|
"the Instance cell's processExplorer link must key on `process_key`",
|
|
462
465
|
);
|
|
463
466
|
});
|
|
467
|
+
|
|
468
|
+
test("issue #637: the Overview 'Active Features' grid buckets on the derived read model, in parity with the Feature tab", async () => {
|
|
469
|
+
// The bug: Overview's "Active Features" grid read the VESTIGIAL base `feature_runs.status` column
|
|
470
|
+
// with a hand-maintained allowlist (`running`/`escalated`/`awaiting_operator` — not even a valid
|
|
471
|
+
// feature-status set: those are delivery-graph statuses cloned from the "Active Delivery Graphs"
|
|
472
|
+
// grid). A feature only ever surfaced there if it happened to be `escalated`; its whole convergence
|
|
473
|
+
// life (`converging`/`waiting_review`/`waiting_merge`/…) was invisible on Overview while the SAME
|
|
474
|
+
// feature showed on the dedicated Feature tab — a state-tear between two surfaces that must agree.
|
|
475
|
+
//
|
|
476
|
+
// The fix repoints the grid at the derived `feature_read_model` VIEW filtered on the canonical
|
|
477
|
+
// `list_bucket IN ('active')` activeness predicate — the single declare-once column (app/
|
|
478
|
+
// featureReadModel.ts, guarded byte-for-byte by check:derivation-parity) that the Feature tab's
|
|
479
|
+
// "Feature runs" grid already uses. This test pins that parity so the tear can't silently return.
|
|
480
|
+
const overview = JSON.parse(readFileSync(`${ROOT}pages/overview.page.json`, "utf8"));
|
|
481
|
+
const feature = JSON.parse(readFileSync(`${ROOT}pages/feature.page.json`, "utf8"));
|
|
482
|
+
|
|
483
|
+
const ovGrid = (overview.nodes ?? []).find(
|
|
484
|
+
(n: Json) => n.type === "dataGrid" && n.props?.title === "Active Features",
|
|
485
|
+
);
|
|
486
|
+
assert(ovGrid, "overview.page.json must have an 'Active Features' grid");
|
|
487
|
+
|
|
488
|
+
// It must bind the derived read-model VIEW, never the vestigial base `feature_runs` table.
|
|
489
|
+
assert(
|
|
490
|
+
ovGrid.props?.data?.table === "feature_read_model",
|
|
491
|
+
"the Overview 'Active Features' grid must bind the derived feature_read_model VIEW, not the base feature_runs table",
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// And it must filter on the canonical `list_bucket IN ('active')` activeness predicate — never a
|
|
495
|
+
// base-`status` allowlist.
|
|
496
|
+
const ovFilters: Json[] = ovGrid.props?.data?.filter ?? [];
|
|
497
|
+
assert(
|
|
498
|
+
!ovFilters.some((f: Json) => f.field === "status"),
|
|
499
|
+
"the Overview 'Active Features' grid must NOT re-encode activeness as a base-`status` allowlist",
|
|
500
|
+
);
|
|
501
|
+
const ovBucket = ovFilters.find((f: Json) => f.field === "list_bucket");
|
|
502
|
+
assert(
|
|
503
|
+
ovBucket && JSON.stringify(ovBucket.in) === JSON.stringify(["active"]),
|
|
504
|
+
"the Overview 'Active Features' grid must filter `list_bucket IN ['active']`",
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// Parity: the Feature tab's "Feature runs" grid answers "is this feature active?" identically —
|
|
508
|
+
// same VIEW, same activeness predicate — so the two surfaces show the same active-feature set.
|
|
509
|
+
const featGrid = (feature.nodes ?? []).find(
|
|
510
|
+
(n: Json) => n.type === "dataGrid" && n.props?.title === "Feature runs",
|
|
511
|
+
);
|
|
512
|
+
assert(featGrid, "feature.page.json must have a 'Feature runs' grid");
|
|
513
|
+
assert(
|
|
514
|
+
featGrid.props?.data?.table === ovGrid.props?.data?.table,
|
|
515
|
+
"the Overview and Feature-tab feature grids must bind the SAME table (feature_read_model)",
|
|
516
|
+
);
|
|
517
|
+
const featBucket = (featGrid.props?.data?.filter ?? []).find((f: Json) => f.field === "list_bucket");
|
|
518
|
+
assert(
|
|
519
|
+
featBucket && JSON.stringify(featBucket.in) === JSON.stringify(ovBucket.in),
|
|
520
|
+
"the Overview and Feature-tab feature grids must apply the IDENTICAL list_bucket activeness predicate",
|
|
521
|
+
);
|
|
522
|
+
});
|