@nanobpm/nano-workforce 0.177.0 → 0.178.1

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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.178.1](https://github.com/nanobpm/nano-workforce/compare/v0.178.0...v0.178.1) (2026-09-03)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **mcp:** stage delivery graphs on a layout-free fast path so cold heavy tools don't time out ([#718](https://github.com/nanobpm/nano-workforce/issues/718)) ([c94ab00](https://github.com/nanobpm/nano-workforce/commit/c94ab00f091dbc5e529138e6818365c5794a1e25)), closes [#715](https://github.com/nanobpm/nano-workforce/issues/715) [nanobpm/nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488) [#716](https://github.com/nanobpm/nano-workforce/issues/716) [#716](https://github.com/nanobpm/nano-workforce/issues/716) [#716](https://github.com/nanobpm/nano-workforce/issues/716)
6
+
7
+ ## [0.178.0](https://github.com/nanobpm/nano-workforce/compare/v0.177.0...v0.178.0) (2026-09-03)
8
+
9
+ ### Features
10
+
11
+ * **mcp:** pin the workforce-visible MCP surface — session self-heal, tractable-surface budget, heavy-tool latency ([#717](https://github.com/nanobpm/nano-workforce/issues/717)) ([de55daa](https://github.com/nanobpm/nano-workforce/commit/de55daa1b5fa134c7fc0e08c96041113fecd1481)), closes [488/#501](https://github.com/488/nano-workforce/issues/501) [#715](https://github.com/nanobpm/nano-workforce/issues/715)
12
+
1
13
  ## [0.177.0](https://github.com/nanobpm/nano-workforce/compare/v0.176.1...v0.177.0) (2026-09-02)
2
14
 
3
15
  ### Features
@@ -260,23 +260,47 @@ function mustGet<K, V>(map: ReadonlyMap<K, V>, key: K): V {
260
260
  return value;
261
261
  }
262
262
 
263
+ /** The compiler result BEFORE diagram-interchange layout: everything the generated
264
+ * `CompileDeliveryGraphResult` carries EXCEPT the laid-out `bpmn`, plus the `semanticBpmn` (the
265
+ * pre-layout, DI-less BPMN). `compileDeliveryGraphSemantic` produces this cheaply (no
266
+ * `layoutBpmn`); `compileDeliveryGraph` layers the CPU-bound DI layout on top. Because the DI is
267
+ * DERIVED deterministically from `semanticBpmn`, `semanticBpmn` is the canonical content of a graph —
268
+ * the source `deliveryGraphDigest` content-addresses (issue #716). */
269
+ export interface CompiledDeliveryGraphSemantic {
270
+ ok: true;
271
+ /** A human-readable mermaid `flowchart` of the resolved graph. */
272
+ diagram: string;
273
+ /** The compiled one-shot BPMN process definition WITHOUT diagram interchange — the canonical,
274
+ * layout-independent content of the graph. Deterministic: same input graph → byte-identical XML. */
275
+ semanticBpmn: string;
276
+ resolved: CompileDeliveryGraphResult["resolved"];
277
+ humanNodes: CompileDeliveryGraphResult["humanNodes"];
278
+ sideEffects: CompileDeliveryGraphResult["sideEffects"];
279
+ }
280
+
281
+ /** A fully-compiled graph — the generated wire result (`diagram`, laid-out `bpmn`, …) PLUS the
282
+ * pre-layout `semanticBpmn` the content digest is taken over. */
283
+ export type CompiledDeliveryGraph = CompileDeliveryGraphResult & { semanticBpmn: string };
284
+
263
285
  /**
264
- * Validate + compile a delivery graph into a PURE preview (ADR 0005 slice S1). Returns
265
- * `{ ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }` for a well-formed graph, or
266
- * `{ ok:false, errors }` (each error path-qualified) for a malformed one. NEVER deploys, dispatches,
267
- * or mutates anything — safe to call repeatedly. Deterministic: identical input JSON yields
268
- * byte-identical output.
286
+ * Validate + compile a delivery graph into a PURE preview WITHOUT the CPU-bound diagram-interchange
287
+ * layout (issue #716). Returns `{ ok:true, diagram, semanticBpmn, resolved, humanNodes, sideEffects }`
288
+ * for a well-formed graph, or `{ ok:false, errors }` (each error path-qualified) for a malformed one.
289
+ * NEVER deploys, dispatches, or mutates anything — safe to call repeatedly. Deterministic: identical
290
+ * input JSON yields byte-identical output.
269
291
  *
270
- * ASYNC because the final step attaches DIAGRAM INTERCHANGE (`bpmndi:BPMNDiagram`) via the toolkit
271
- * autolayout (`layoutBpmn` `bpmn-auto-layout`), the SAME pass every AUTHORED process gets from
272
- * `npm run layout` (`scripts/layout-bpmn.ts`). This is the one BPMN in the system generated at
273
- * runtime, so without this it was the only one shipping DI-less — unrenderable in the process
274
- * explorer (#440). `layoutBpmn` is itself deterministic given identical semantic input, so
275
- * "same JSON byte-identical XML" still holds with the diagram included.
292
+ * This is the fast path the agent-facing compile/stage doors (`compileDeliveryGraph` /
293
+ * `sequenceIssues` `compileAndStageDeliveryGraph`) take: staging needs only the content digest (taken
294
+ * over `semanticBpmn`), the mermaid `diagram`, and the resolved model NOT the laid-out `bpmn`. Skipping
295
+ * `layoutBpmn` (`bpmn-auto-layout`, superlinear in node/edge count minutes on a 256-node/1024-edge
296
+ * graph) keeps a cold MCP tool call well under the client's per-call timeout instead of tripping a
297
+ * `-32001` that poisons the stateful session (#715). The laid-out `bpmn` is generated lazily, only at the
298
+ * OPERATOR's preview/dispatch time (`previewProposalBpmn` / `dispatchDeliveryGraph`), which is a cockpit
299
+ * action, not an MCP call, and so is not timeout-bound.
276
300
  */
277
- export async function compileDeliveryGraph(
301
+ export async function compileDeliveryGraphSemantic(
278
302
  graph: unknown,
279
- ): Promise<CompileDeliveryGraphResult | CompileDeliveryGraphErrors> {
303
+ ): Promise<CompiledDeliveryGraphSemantic | CompileDeliveryGraphErrors> {
280
304
  const validationErrors: DeliveryGraphError[] = validateDeliveryGraph(graph);
281
305
  if (validationErrors.length > 0) {
282
306
  // Forward every semantic failure verbatim as a wire `{ path, message }` (the stable `code` stays
@@ -509,13 +533,40 @@ export async function compileDeliveryGraph(
509
533
  }
510
534
 
511
535
  const semanticBpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
512
- const bpmn = await layoutDeliveryDiagram(semanticBpmn);
513
536
  const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
514
537
  const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
515
538
  const humanNodes = buildHumanNodes(nodes);
516
539
  const sideEffects = buildSideEffects(nodes);
517
540
 
518
- return { ok: true, diagram, bpmn, resolved, humanNodes, sideEffects };
541
+ return { ok: true, diagram, semanticBpmn, resolved, humanNodes, sideEffects };
542
+ }
543
+
544
+ /**
545
+ * Validate + compile a delivery graph into a PURE preview INCLUDING diagram interchange (ADR 0005
546
+ * slice S1). Returns the generated `CompileDeliveryGraphResult` shape (`diagram`, laid-out `bpmn`,
547
+ * `resolved`, `humanNodes`, `sideEffects`) PLUS the pre-layout `semanticBpmn`, or `{ ok:false, errors }`
548
+ * for a malformed graph. NEVER deploys, dispatches, or mutates anything. Deterministic: identical input
549
+ * JSON yields byte-identical output.
550
+ *
551
+ * ASYNC because the final step attaches DIAGRAM INTERCHANGE (`bpmndi:BPMNDiagram`) via the toolkit
552
+ * autolayout (`layoutBpmn` — `bpmn-auto-layout`), the SAME pass every AUTHORED process gets from
553
+ * `npm run layout` (`scripts/layout-bpmn.ts`). This is the one BPMN in the system generated at
554
+ * runtime, so without this it was the only one shipping DI-less — unrenderable in the process
555
+ * explorer (#440). `layoutBpmn` is itself deterministic given identical semantic input, so
556
+ * "same JSON → byte-identical XML" still holds with the diagram included.
557
+ *
558
+ * Callers that only need the content digest / preview (the agent-facing compile+STAGE doors) should
559
+ * use the cheaper {@link compileDeliveryGraphSemantic} instead — layout here is CPU-bound and
560
+ * superlinear (issue #716), so it belongs only on the operator's preview/dispatch/deploy paths that
561
+ * genuinely render or run the BPMN.
562
+ */
563
+ export async function compileDeliveryGraph(
564
+ graph: unknown,
565
+ ): Promise<CompiledDeliveryGraph | CompileDeliveryGraphErrors> {
566
+ const semantic = await compileDeliveryGraphSemantic(graph);
567
+ if (!semantic.ok) return semantic;
568
+ const bpmn = await layoutDeliveryDiagram(semantic.semanticBpmn);
569
+ return { ...semantic, bpmn };
519
570
  }
520
571
 
521
572
  /** Attach diagram interchange (`bpmndi:BPMNDiagram`) to the semantic-only compiled BPMN via the
@@ -63,7 +63,7 @@ export async function dispatchDeliveryGraphRun(
63
63
  return { ok: false, errors: compiled.errors };
64
64
  }
65
65
 
66
- const digest = deliveryGraphDigest(compiled.bpmn);
66
+ const digest = deliveryGraphDigest(compiled.semanticBpmn);
67
67
  const runKey = computeRunKey(options.runKey, digest);
68
68
  const sideEffecting = compiled.sideEffects.length > 0;
69
69
  const explicitTitle = typeof options.title === "string" && options.title.trim() !== "" ? options.title.trim() : "";
@@ -105,8 +105,12 @@ export function isProposalExpired(expiresAtIso: string | null | undefined, at: D
105
105
 
106
106
  /** Extract the operator-facing preview from a successful compile — the diagram, the side effects a
107
107
  * dispatch authorises, and the human stop-points. No BPMN, no digest handle beyond the content
108
- * address; a preview, not a dispatch affordance. */
109
- export function buildProposalPreview(compiled: CompileDeliveryGraphResult): DeliveryProposalPreview {
108
+ * address; a preview, not a dispatch affordance. Accepts either the full compile result or the
109
+ * layout-free {@link compileDeliveryGraphSemantic} result — the preview reads only `diagram`,
110
+ * `sideEffects`, and `humanNodes`, none of which need the laid-out `bpmn` (issue #716). */
111
+ export function buildProposalPreview(
112
+ compiled: Pick<CompileDeliveryGraphResult, "diagram" | "humanNodes" | "sideEffects">,
113
+ ): DeliveryProposalPreview {
110
114
  return {
111
115
  diagram: compiled.diagram,
112
116
  sideEffects: compiled.sideEffects,
@@ -0,0 +1,145 @@
1
+ // Regression coverage for the compile+stage HOT PATH (issue #716, split from #715 gap 4).
2
+ //
3
+ // THE FAILURE MODE
4
+ // ================
5
+ // `compileDeliveryGraph` and `sequenceIssues` (the agent-facing MCP tools) both route through
6
+ // `compileAndStageDeliveryGraph`, which USED to run the full compiler — including the CPU-bound
7
+ // `layoutBpmn` (`bpmn-auto-layout`) diagram-interchange pass. That layout is superlinear in
8
+ // node/edge count: on a large/dense graph (the issue cites up to 256 nodes / 1024 edges) it takes
9
+ // MINUTES, so a cold MCP `tools/call` blew past the client's per-call timeout and returned
10
+ // `-32001 Request timed out` — which (per #715) then poisoned the stateful MCP session.
11
+ //
12
+ // THE FIX
13
+ // =======
14
+ // Staging needs only the content DIGEST, the mermaid `diagram`, and the resolved model — NOT the
15
+ // laid-out `bpmn`. The digest is now taken over the deterministic SEMANTIC BPMN (the DI is derived
16
+ // from it), so `compileAndStageDeliveryGraph` uses the layout-free `compileDeliveryGraphSemantic`
17
+ // and returns in milliseconds. The expensive `layoutBpmn` is deferred to the OPERATOR's
18
+ // preview/dispatch (`previewProposalBpmn` / `dispatchDeliveryGraph`) — cockpit actions, not
19
+ // timeout-bound MCP calls.
20
+ //
21
+ // These tests pin BOTH halves of the fix so it cannot regress:
22
+ // 1. `compileDeliveryGraphSemantic` is layout-free (no `bpmn`, no `bpmndi:` in `semanticBpmn`) and
23
+ // content-addresses IDENTICALLY to the full compile — so staging and dispatch/preview agree.
24
+ // 2. `compileAndStageDeliveryGraph` on a LARGE/DENSE graph completes fast (a bound the old
25
+ // layout-on-the-hot-path could never meet) and stages a live proposal whose `digest` is the
26
+ // SEMANTIC digest (not the laid-out one) — the structural signature of the fast path.
27
+ import { mkdtempSync, rmSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { join, resolve } from "node:path";
30
+ import { after, before, describe, test } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
33
+ import { compileDeliveryGraph, compileDeliveryGraphSemantic } from "./deliveryGraphCompiler.ts";
34
+ import { getStagedProposal } from "./deliveryGraphProposals.ts";
35
+ import { compileAndStageDeliveryGraph } from "./deliveryGraphStage.ts";
36
+ import { deliveryGraphDigest } from "./deliveryRunner.ts";
37
+
38
+ const APP_ROOT = resolve(import.meta.dirname, "..");
39
+
40
+ /** A large, DENSE delivery graph — `n` agent nodes, each edged from its `fan` predecessors. At
41
+ * `n=256, fan=4` this is ~1020 edges: the exact class the issue names, and one `layoutBpmn` takes
42
+ * MINUTES on. The semantic compile of the same graph is ~40ms, so the fast path clears any sane
43
+ * timing bound by three orders of magnitude. */
44
+ function denseGraph(n: number, fan: number): { name: string; nodes: unknown[]; edges: unknown[] } {
45
+ const nodes: unknown[] = [];
46
+ const edges: unknown[] = [];
47
+ for (let i = 0; i < n; i++) {
48
+ nodes.push({ id: `n${i}`, kind: "agent", agent: { jobType: "senior:feature", prompt: `task ${i}` } });
49
+ }
50
+ for (let i = 1; i < n; i++) {
51
+ for (let f = 1; f <= fan && i - f >= 0; f++) edges.push({ from: `n${i - f}`, to: `n${i}` });
52
+ }
53
+ return { name: "dense bench", nodes, edges };
54
+ }
55
+
56
+ describe("compileDeliveryGraphSemantic — the layout-free compile", () => {
57
+ test("produces a DI-less semantic BPMN and NO laid-out bpmn", async () => {
58
+ const g = denseGraph(8, 2);
59
+ const semantic = await compileDeliveryGraphSemantic(g);
60
+ assert(semantic.ok, "a well-formed graph compiles semantically");
61
+ assert(!("bpmn" in semantic), "the layout-free result carries no laid-out `bpmn`");
62
+ assert(
63
+ !semantic.semanticBpmn.includes("bpmndi:"),
64
+ "the semantic BPMN carries no diagram interchange (that is the layout the fast path skips)",
65
+ );
66
+ assert(semantic.semanticBpmn.includes("<bpmn:process"), "it is still a real BPMN process definition");
67
+ });
68
+
69
+ test("content-addresses IDENTICALLY to the full compile (staging and dispatch agree)", async () => {
70
+ const g = denseGraph(6, 2);
71
+ const semantic = await compileDeliveryGraphSemantic(g);
72
+ const full = await compileDeliveryGraph(g);
73
+ assert(semantic.ok && full.ok, "both compiles succeed");
74
+ // The full compile exposes the SAME semanticBpmn (it layers DI on top), and the digest is taken
75
+ // over that — so the fast (stage) path and the full (preview/dispatch) path never drift on how a
76
+ // graph is addressed.
77
+ assert.equal(full.semanticBpmn, semantic.semanticBpmn, "full compile reuses the semantic BPMN verbatim");
78
+ assert.equal(
79
+ deliveryGraphDigest(semantic.semanticBpmn),
80
+ deliveryGraphDigest(full.semanticBpmn),
81
+ "the content digest matches across the fast and full paths",
82
+ );
83
+ // Sanity: the laid-out bytes genuinely differ from the semantic bytes (DI was attached), so a
84
+ // digest taken over the laid-out `bpmn` would be a DIFFERENT value — the thing the fix moves away
85
+ // from.
86
+ assert.notEqual(full.bpmn, full.semanticBpmn, "the laid-out bpmn differs from the semantic bpmn");
87
+ assert.notEqual(
88
+ deliveryGraphDigest(full.bpmn),
89
+ deliveryGraphDigest(full.semanticBpmn),
90
+ "the semantic digest is distinct from the (old) laid-out digest",
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("compileAndStageDeliveryGraph — the agent-facing hot path does not run layout", () => {
96
+ const dirs: string[] = [];
97
+ const apps: TestApp[] = [];
98
+ let app: TestApp;
99
+ before(async () => {
100
+ const d = mkdtempSync(join(tmpdir(), "nwf-stage-hotpath-"));
101
+ dirs.push(d);
102
+ app = await bootTestApp(APP_ROOT, {
103
+ env: { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "", NANO_APP_DB_URL: `file:${join(d, "app.db")}` },
104
+ });
105
+ apps.push(app);
106
+ });
107
+ after(async () => {
108
+ for (const a of apps) await a.stop?.();
109
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
110
+ });
111
+
112
+ test("stages a 256-node / ~1020-edge graph FAST and content-addresses it by its SEMANTIC digest", async () => {
113
+ const g = denseGraph(256, 4);
114
+ assert(g.edges.length > 1000, "the fixture really is dense (the layout-heavy class)");
115
+
116
+ const graphJson = JSON.stringify(g);
117
+ const started = performance.now();
118
+ const staged = await compileAndStageDeliveryGraph(app.db, g, graphJson, "http://example.test");
119
+ const elapsedMs = performance.now() - started;
120
+
121
+ assert(staged.ok, "the dense graph stages successfully");
122
+ assert.equal(staged.status, 200);
123
+ // The whole point of #716: the hot path must NOT run `layoutBpmn` (minutes on this graph). The
124
+ // layout-free semantic compile is ~40ms; a 20s bound is a 500x margin over the passing path and a
125
+ // 6x margin UNDER the failing (layout) path, so it separates them without flaking.
126
+ assert(
127
+ elapsedMs < 20_000,
128
+ `staging a dense graph must not pay the layout tax — took ${elapsedMs.toFixed(0)}ms (a layout would take minutes)`,
129
+ );
130
+
131
+ // The staged proposal is content-addressed by the SEMANTIC digest — the structural signature of
132
+ // the fast path. Before the fix it was the laid-out-bpmn digest (a different value), so this
133
+ // assertion is red on the old behaviour and green on the new one.
134
+ const semantic = await compileDeliveryGraphSemantic(g);
135
+ assert(semantic.ok);
136
+ const semanticDigest = deliveryGraphDigest(semantic.semanticBpmn);
137
+ assert.equal(staged.digest, semanticDigest, "staged proposal is addressed by its semantic digest");
138
+
139
+ // …and it is actually persisted as a live, dispatchable staged proposal (the operator can find it
140
+ // and drive the — deferred — layout at preview/dispatch time).
141
+ const live = await getStagedProposal(app.db, staged.digest);
142
+ assert(live, "the staged proposal is live in the store");
143
+ assert.equal(live.node_count, 256);
144
+ });
145
+ });
@@ -2,7 +2,7 @@
2
2
  // delivery graph (epic nano-workforce#605). Extracted from `operations/compileDeliveryGraph.ts` (S0)
3
3
  // so the intent-shaped generator doors (S4 — `sequenceIssues`) hand their CONSTRUCTED graph to the
4
4
  // EXACT same deterministic compile → validate → stage path the raw `compileDeliveryGraph` door uses:
5
- // one compiler (`compileDeliveryGraph`), one staging path (`stageProposal`), one idempotency/digest
5
+ // one compiler (`compileDeliveryGraphSemantic`), one staging path (`stageProposal`), one idempotency/digest
6
6
  // semantics (content-addressed by `deliveryGraphDigest`). Per AGENTS.md "Derivation over duplication:
7
7
  // no drift surfaces", a generator MUST NOT re-implement a second runner or a second staging path —
8
8
  // it only produces the `DeliveryGraph` and delegates here.
@@ -14,7 +14,7 @@
14
14
  // semantic validation. Nothing is staged on a rejected compile.
15
15
  import type { DataLayer } from "@nanobpm/urban";
16
16
  import type { CompileDeliveryGraphErrors, CompileDeliveryGraphStaged } from "../nano-generated/api-io.d.ts";
17
- import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
17
+ import { compileDeliveryGraphSemantic } from "./deliveryGraphCompiler.ts";
18
18
  import {
19
19
  buildProposalPreview,
20
20
  buildProposalRow,
@@ -64,12 +64,12 @@ export async function compileAndStageDeliveryGraph(
64
64
  graphJson: string,
65
65
  origin: string,
66
66
  ): Promise<StagedResult | StageErrors> {
67
- const result = await compileDeliveryGraph(graph);
67
+ const result = await compileDeliveryGraphSemantic(graph);
68
68
  if (!result.ok) {
69
69
  return { ok: false, status: 400, body: result };
70
70
  }
71
71
 
72
- const digest = deliveryGraphDigest(result.bpmn);
72
+ const digest = deliveryGraphDigest(result.semanticBpmn);
73
73
  const name =
74
74
  typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
75
75
  ? result.resolved.name.trim()
@@ -127,7 +127,7 @@ export async function parseAndCompileText(
127
127
  },
128
128
  };
129
129
  }
130
- const digest = deliveryGraphDigest(compiled.bpmn);
130
+ const digest = deliveryGraphDigest(compiled.semanticBpmn);
131
131
  const name =
132
132
  typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
133
133
  ? compiled.resolved.name.trim()
@@ -6,7 +6,8 @@
6
6
  // runs it); its whole job is deploy + seed + start.
7
7
  //
8
8
  // Definition lifecycle (the ADR open question, resolved here): the deployed process id is
9
- // CONTENT-ADDRESSED — `delivery-graph-<sha256(bpmn)[:12]>`. Identical graphs compile byte-identically
9
+ // CONTENT-ADDRESSED — `delivery-graph-<sha256(semanticBpmn)[:12]>` (the pre-layout semantic model, NOT
10
+ // the laid-out `bpmn` — issue #716). Identical graphs compile byte-identically
10
11
  // (S1 determinism) → identical id → an idempotent redeploy (the engine versions the same id, never a
11
12
  // duplicate definition per run); different graphs get different ids and never collide; and because the
12
13
  // id ENCODES its content, a stale one-shot definition is GC-identifiable by a later sweeper (out of
@@ -22,13 +23,21 @@ import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, read
22
23
  import { repoEnvelopeVars } from "./repoEnvelope.ts";
23
24
  import { isoDuration } from "./reviewWait.ts";
24
25
 
25
- /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
26
- * content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default idempotency
27
- * key + the staged-proposal primary key. The runner (deploy id), `app/deliveryGraphDispatch` (dedupe
28
- * key), and `app/deliveryGraphProposals` (proposal digest) all derive from THIS one function so they
29
- * can never drift on how a graph is addressed. */
30
- export function deliveryGraphDigest(bpmn: string): string {
31
- return createHash("sha256").update(bpmn).digest("hex").slice(0, 12);
26
+ /** The content digest of a compiled graph — `sha256(semanticBpmn)[:12]` — the single source of truth
27
+ * for the content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default
28
+ * idempotency key + the staged-proposal primary key. The runner (deploy id),
29
+ * `app/deliveryGraphDispatch` (dedupe key), and `app/deliveryGraphProposals` (proposal digest) all
30
+ * derive from THIS one function so they can never drift on how a graph is addressed.
31
+ *
32
+ * The digest is taken over the graph's SEMANTIC BPMN (the pre-layout `compileDeliveryGraphSemantic`
33
+ * output), NOT the laid-out `bpmn` (issue #716). The diagram interchange is DERIVED deterministically
34
+ * from the semantic model, so the semantic BPMN is the true canonical content of a graph — and, unlike
35
+ * the laid-out BPMN, it is available WITHOUT the CPU-bound `layoutBpmn` pass. This lets the agent-facing
36
+ * compile+stage doors content-address (and stage) a graph on the fast path while the expensive layout is
37
+ * deferred to the operator's preview/dispatch. Every caller MUST pass a `semanticBpmn` so the address
38
+ * stays consistent across staging, preview, dispatch, and deploy. */
39
+ export function deliveryGraphDigest(semanticBpmn: string): string {
40
+ return createHash("sha256").update(semanticBpmn).digest("hex").slice(0, 12);
32
41
  }
33
42
 
34
43
  /** The bounded-timeout / SLA envelope every node inherits (Decision: bounded → escalate). ISO-8601
@@ -120,7 +129,7 @@ export async function prepareDeliveryGraph(
120
129
  const compiled = await compileDeliveryGraph(graph);
121
130
  if (!compiled.ok) return { ok: false, errors: compiled.errors };
122
131
 
123
- const digest = deliveryGraphDigest(compiled.bpmn);
132
+ const digest = deliveryGraphDigest(compiled.semanticBpmn);
124
133
  const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
125
134
  const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
126
135
 
@@ -0,0 +1,63 @@
1
+ // Authoring guard for the curated MCP tool subset (`app/mcpToolSurface.ts`, issue #715).
2
+ //
3
+ // The curated `CURATED_MCP_TOOLS` allowlist is the tractable subset a client imports instead of
4
+ // `["*"]`. `e2e/mcp-tractability.e2e.ts` proves every entry projects onto the LIVE surface, but that
5
+ // needs a booted instance. This fast unit guard checks the same list against the SAME framework
6
+ // walker the runtime MCP projector uses (`parseSpec` + `collectOperations`) so an authoring typo —
7
+ // a curated app-tool name that is not an `openapi.yaml` operationId, or one accidentally `x-mcp`-
8
+ // excluded — fails CI in `npm test` without booting anything. Framework `urban_debug_*` tools are
9
+ // not `openapi.yaml` operations, so they are validated by their reserved prefix instead.
10
+ //
11
+ // Derivation over duplication (AGENTS.md): the exclusion/projection rule is NOT re-implemented here —
12
+ // it is read from the framework walker's `mcpExcluded` flag, the exact rule the runtime honours.
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { test } from "node:test";
17
+ import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
18
+ import { CURATED_MCP_TOOLS, FRAMEWORK_TOOL_PREFIX } from "../app/mcpToolSurface.ts";
19
+ import { assert } from "#test-assert";
20
+
21
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
22
+ const SPEC_PATH = join(REPO_ROOT, "openapi.yaml");
23
+
24
+ function projectedAppTools(): Set<string> {
25
+ return new Set(
26
+ collectOperations(parseSpec(readFileSync(SPEC_PATH, "utf8")))
27
+ .filter((op) => !op.mcpExcluded)
28
+ .map((op) => op.operationId),
29
+ );
30
+ }
31
+
32
+ test("every curated APP tool is a projected (non-x-mcp) openapi operation", () => {
33
+ const projected = projectedAppTools();
34
+ for (const name of CURATED_MCP_TOOLS) {
35
+ if (name.startsWith(FRAMEWORK_TOOL_PREFIX)) continue; // framework tool — validated by prefix below
36
+ assert(
37
+ projected.has(name),
38
+ `curated tool "${name}" is not a projected openapi operation — it is missing from openapi.yaml ` +
39
+ `or has been x-mcp-excluded. A client importing the curated allowlist would silently not get it.`,
40
+ );
41
+ }
42
+ });
43
+
44
+ test("every curated FRAMEWORK tool carries the reserved urban_debug_ prefix", () => {
45
+ const projected = projectedAppTools();
46
+ for (const name of CURATED_MCP_TOOLS) {
47
+ if (!name.startsWith(FRAMEWORK_TOOL_PREFIX)) continue;
48
+ // A framework name must NOT also be an app operationId (that would be a namespace collision the
49
+ // runtime reserves against) — it is owned entirely by the runtime's engine-debug family.
50
+ assert(
51
+ !projected.has(name),
52
+ `curated framework tool "${name}" unexpectedly collides with an openapi operationId.`,
53
+ );
54
+ }
55
+ });
56
+
57
+ test("the curated subset has no duplicate entries", () => {
58
+ const seen = new Set<string>();
59
+ for (const name of CURATED_MCP_TOOLS) {
60
+ assert(!seen.has(name), `CURATED_MCP_TOOLS lists "${name}" more than once.`);
61
+ seen.add(name);
62
+ }
63
+ });
@@ -0,0 +1,112 @@
1
+ // Canonical source of truth for the workforce MCP tool SURFACE budget and the curated driving
2
+ // subset (issue #715, epic #605 "tractable surface").
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ===============
6
+ // The runtime-served MCP surface (`/app/mcp`, ADR 0067) projects EVERY non-`x-mcp` `openapi.yaml`
7
+ // operation — plus the framework-owned `urban_debug_*` engine tools — into a tool. Measured against
8
+ // the deployed surface (issue #715) that is **56 tools / ~79 KB of `tools/list`**: large enough that
9
+ // an agent harness (Copilot CLI and others) DEFERS the whole set behind a tool-search gate, and a
10
+ // client config of `"tools": ["*"]` imports all 56 eagerly. That is the #605 "tractable surface"
11
+ // problem, quantified.
12
+ //
13
+ // The workforce-side lever is TWO-fold, and both live here as one source of truth:
14
+ //
15
+ // 1. A **budget** on the full projected surface (count + bytes), so the surface can only ever
16
+ // SHRINK below these ceilings — a new door that pushes it over fails CI (`e2e/mcp-tractability.e2e.ts`).
17
+ // The transport-level count reduction (gating rarely-used framework `urban_debug_*` tools
18
+ // behind a mode) lives in the urban runtime and is tracked upstream (nano-ide#488); this budget
19
+ // pins the workforce-visible number so it cannot regress while that lands.
20
+ // 2. A **curated subset** — the tools an agent actually drives/reads with day to day — that a
21
+ // client imports via its MCP-server `"tools"` allowlist INSTEAD of `["*"]`, so the eagerly-loaded
22
+ // set is materially smaller than the full surface and stays under the harness deferral threshold.
23
+ // This is the "documented curated subset" the issue's acceptance allows.
24
+ //
25
+ // DERIVATION OVER DUPLICATION (AGENTS.md)
26
+ // =======================================
27
+ // This list is the ONE authored source. `e2e/mcp-tractability.e2e.ts` asserts every name here
28
+ // actually projects onto the LIVE `/app/mcp` surface (so a curated entry can never go dead), and
29
+ // `scripts/sync-mcp-curated.ts` renders it verbatim into the runbook (`docs/mcp-runbook.md`) — a
30
+ // drift test under `npm test` (`scripts/sync-mcp-curated.test.ts`, which CI runs) and
31
+ // `npm run sync:mcp-curated:check` both fail on any drift. The served "Connect over MCP" page
32
+ // (`pages/mcp.page.json`) carries the concept as prose and points here + at the runbook, so there is
33
+ // no second enumerated copy to drift. Never hand-edit the curated `tools` block in the runbook; edit
34
+ // HERE and re-run `npm run sync:mcp-curated`.
35
+
36
+ /**
37
+ * The curated driving/reading subset an MCP client should import via its server entry's `"tools"`
38
+ * allowlist instead of `["*"]`. Grouped by intent in authoring order; the union is what a workforce
39
+ * operator/agent needs to drive work, read status, answer escalations, and triage a wedged instance —
40
+ * WITHOUT eagerly loading the whole 56-tool surface. Every name is asserted to project onto the live
41
+ * surface by `e2e/mcp-tractability.e2e.ts`.
42
+ */
43
+ export const CURATED_MCP_TOOLS: readonly string[] = [
44
+ // ── Drive / act ──────────────────────────────────────────────────────────
45
+ "startConvergenceLoop",
46
+ "startPlanFanout",
47
+ "startEpicSet",
48
+ "startFeature",
49
+ "compileDeliveryGraph",
50
+ "previewDeliveryGraph",
51
+ "sequenceIssues",
52
+ "agentCompleteEscalation",
53
+ "completeUserTask",
54
+ "cancelInstance",
55
+ "appendBlackboard",
56
+ "readBlackboard",
57
+ // ── Read / orient ────────────────────────────────────────────────────────
58
+ "getVersion",
59
+ "getAgentInstructions",
60
+ "getAgentGuide",
61
+ "listActivePrs",
62
+ "listStagedProposals",
63
+ "listEscalations",
64
+ "getLineage",
65
+ "getPrHistory",
66
+ // ── Engine-truth reads (wedge triage) ────────────────────────────────────
67
+ "urban_debug_search_process_instances",
68
+ "urban_debug_search_element_instance_wait_states",
69
+ "urban_debug_search_incidents",
70
+ "urban_debug_search_variables",
71
+ "urban_debug_search_jobs",
72
+ "urban_debug_instance_state",
73
+ "urban_debug_open_user_tasks",
74
+ ];
75
+
76
+ /** The framework-reserved namespace for engine-debug tools (mirrors the runtime's `DEBUG_PREFIX`).
77
+ * A curated entry with this prefix is a framework tool (not an `openapi.yaml` operation), so the
78
+ * spec-level unit guard validates it by prefix rather than against the projected operation set. */
79
+ export const FRAMEWORK_TOOL_PREFIX = "urban_debug_";
80
+
81
+ /**
82
+ * Hard CEILING on the projected `tools/list` tool count. The deployed surface measures 56 (issue
83
+ * #715); this budget forbids GROWTH — a new door that pushes the count over fails CI. It is a
84
+ * regression guard, not the reduction itself: the reduction an agent actually experiences comes from
85
+ * importing {@link CURATED_MCP_TOOLS} rather than `["*"]`, and the transport-level shrink of the
86
+ * framework tool family is tracked upstream (nano-ide#488).
87
+ */
88
+ export const MCP_TOOL_COUNT_BUDGET = 60;
89
+
90
+ /**
91
+ * Hard CEILING on the serialized byte size of the full `tools/list` payload (the schema bytes a
92
+ * client must parse). The deployed surface measures ~78,962 bytes (issue #715); this ceiling forbids
93
+ * meaningful growth so a fat new schema cannot silently re-inflate the surface past the harness
94
+ * deferral point.
95
+ */
96
+ export const MCP_SURFACE_BYTES_BUDGET = 84_000;
97
+
98
+ /**
99
+ * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
100
+ * is not "tractable". This ceiling pins the curated set at roughly half the full count so a creeping
101
+ * curation cannot quietly grow back toward `["*"]`.
102
+ */
103
+ export const CURATED_MCP_TOOLS_BUDGET = 30;
104
+
105
+ /**
106
+ * The per-call budget (ms) a heavy tool (synchronous BPMN layout — `compileDeliveryGraph` /
107
+ * `previewDeliveryGraph` / `sequenceIssues`) must complete within so a cold call does not exceed a
108
+ * typical MCP client's request timeout and `-32001` (issue #715 gap 4). Measured cold at ~0.5 s in
109
+ * the hermetic harness; the 4 s ceiling leaves generous headroom while still failing loudly if a
110
+ * heavy door regresses into a multi-second stall.
111
+ */
112
+ export const HEAVY_TOOL_BUDGET_MS = 4_000;