@minicor/mcp-server 4.7.0 → 4.9.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.
Files changed (145) hide show
  1. package/README.md +100 -10
  2. package/dist/__live__/blueprints.live.test.d.ts +2 -0
  3. package/dist/__live__/blueprints.live.test.d.ts.map +1 -0
  4. package/dist/__live__/blueprints.live.test.js +610 -0
  5. package/dist/__live__/blueprints.live.test.js.map +1 -0
  6. package/dist/__tests__/blueprints-tools.test.d.ts +2 -0
  7. package/dist/__tests__/blueprints-tools.test.d.ts.map +1 -0
  8. package/dist/__tests__/blueprints-tools.test.js +1190 -0
  9. package/dist/__tests__/blueprints-tools.test.js.map +1 -0
  10. package/dist/__tests__/config-stores.test.js +8 -7
  11. package/dist/__tests__/config-stores.test.js.map +1 -1
  12. package/dist/__tests__/dev-mode-draft.test.js +9 -10
  13. package/dist/__tests__/dev-mode-draft.test.js.map +1 -1
  14. package/dist/__tests__/discovery.test.js +6 -5
  15. package/dist/__tests__/discovery.test.js.map +1 -1
  16. package/dist/__tests__/helpers.test.js +20 -0
  17. package/dist/__tests__/helpers.test.js.map +1 -1
  18. package/dist/__tests__/http-server.test.js +118 -0
  19. package/dist/__tests__/http-server.test.js.map +1 -1
  20. package/dist/__tests__/jobs-tools.test.js +76 -0
  21. package/dist/__tests__/jobs-tools.test.js.map +1 -1
  22. package/dist/__tests__/mds-errors.test.d.ts +2 -0
  23. package/dist/__tests__/mds-errors.test.d.ts.map +1 -0
  24. package/dist/__tests__/mds-errors.test.js +91 -0
  25. package/dist/__tests__/mds-errors.test.js.map +1 -0
  26. package/dist/__tests__/server-surface.test.js +19 -0
  27. package/dist/__tests__/server-surface.test.js.map +1 -1
  28. package/dist/__tests__/state.test.js +104 -44
  29. package/dist/__tests__/state.test.js.map +1 -1
  30. package/dist/__tests__/vm-broker.test.d.ts +2 -0
  31. package/dist/__tests__/vm-broker.test.d.ts.map +1 -0
  32. package/dist/__tests__/vm-broker.test.js +812 -0
  33. package/dist/__tests__/vm-broker.test.js.map +1 -0
  34. package/dist/__tests__/vm-management.test.d.ts +2 -0
  35. package/dist/__tests__/vm-management.test.d.ts.map +1 -0
  36. package/dist/__tests__/vm-management.test.js +507 -0
  37. package/dist/__tests__/vm-management.test.js.map +1 -0
  38. package/dist/agent-service-client.d.ts +12 -0
  39. package/dist/agent-service-client.d.ts.map +1 -1
  40. package/dist/agent-service-client.js +74 -0
  41. package/dist/agent-service-client.js.map +1 -1
  42. package/dist/bootstrap-perms.d.ts +12 -0
  43. package/dist/bootstrap-perms.d.ts.map +1 -0
  44. package/dist/bootstrap-perms.js +92 -0
  45. package/dist/bootstrap-perms.js.map +1 -0
  46. package/dist/bootstrap.js +2 -63
  47. package/dist/bootstrap.js.map +1 -1
  48. package/dist/helpers.d.ts +14 -2
  49. package/dist/helpers.d.ts.map +1 -1
  50. package/dist/helpers.js +12 -3
  51. package/dist/helpers.js.map +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +4 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/lds-client.d.ts +59 -10
  56. package/dist/lds-client.d.ts.map +1 -1
  57. package/dist/lds-client.js +53 -9
  58. package/dist/lds-client.js.map +1 -1
  59. package/dist/lib.d.ts +6 -0
  60. package/dist/lib.d.ts.map +1 -1
  61. package/dist/lib.js +5 -1
  62. package/dist/lib.js.map +1 -1
  63. package/dist/mds-errors.d.ts +32 -0
  64. package/dist/mds-errors.d.ts.map +1 -0
  65. package/dist/mds-errors.js +61 -0
  66. package/dist/mds-errors.js.map +1 -0
  67. package/dist/middleware-service-client.d.ts +286 -0
  68. package/dist/middleware-service-client.d.ts.map +1 -1
  69. package/dist/middleware-service-client.js +143 -1
  70. package/dist/middleware-service-client.js.map +1 -1
  71. package/dist/server-surface.d.ts +2 -0
  72. package/dist/server-surface.d.ts.map +1 -1
  73. package/dist/server-surface.js +31 -3
  74. package/dist/server-surface.js.map +1 -1
  75. package/dist/state.d.ts +28 -14
  76. package/dist/state.d.ts.map +1 -1
  77. package/dist/state.js +58 -40
  78. package/dist/state.js.map +1 -1
  79. package/dist/sync.js +5 -2
  80. package/dist/sync.js.map +1 -1
  81. package/dist/tools/agents.d.ts.map +1 -1
  82. package/dist/tools/agents.js +2 -3
  83. package/dist/tools/agents.js.map +1 -1
  84. package/dist/tools/blueprints.d.ts +20 -0
  85. package/dist/tools/blueprints.d.ts.map +1 -0
  86. package/dist/tools/blueprints.js +1193 -0
  87. package/dist/tools/blueprints.js.map +1 -0
  88. package/dist/tools/config-stores.d.ts.map +1 -1
  89. package/dist/tools/config-stores.js +11 -12
  90. package/dist/tools/config-stores.js.map +1 -1
  91. package/dist/tools/context.d.ts.map +1 -1
  92. package/dist/tools/context.js +1 -2
  93. package/dist/tools/context.js.map +1 -1
  94. package/dist/tools/core.d.ts.map +1 -1
  95. package/dist/tools/core.js +2 -3
  96. package/dist/tools/core.js.map +1 -1
  97. package/dist/tools/dev-mode-draft.d.ts.map +1 -1
  98. package/dist/tools/dev-mode-draft.js +16 -17
  99. package/dist/tools/dev-mode-draft.js.map +1 -1
  100. package/dist/tools/discovery.d.ts.map +1 -1
  101. package/dist/tools/discovery.js +7 -8
  102. package/dist/tools/discovery.js.map +1 -1
  103. package/dist/tools/issues.d.ts.map +1 -1
  104. package/dist/tools/issues.js +7 -8
  105. package/dist/tools/issues.js.map +1 -1
  106. package/dist/tools/jobs.d.ts.map +1 -1
  107. package/dist/tools/jobs.js +58 -7
  108. package/dist/tools/jobs.js.map +1 -1
  109. package/dist/tools/session.d.ts.map +1 -1
  110. package/dist/tools/session.js +9 -10
  111. package/dist/tools/session.js.map +1 -1
  112. package/dist/tools/skills.d.ts.map +1 -1
  113. package/dist/tools/skills.js +2 -3
  114. package/dist/tools/skills.js.map +1 -1
  115. package/dist/tools/sync-tools.d.ts.map +1 -1
  116. package/dist/tools/sync-tools.js +2 -3
  117. package/dist/tools/sync-tools.js.map +1 -1
  118. package/dist/tools/tasks.d.ts +3 -0
  119. package/dist/tools/tasks.d.ts.map +1 -0
  120. package/dist/tools/tasks.js +51 -0
  121. package/dist/tools/tasks.js.map +1 -0
  122. package/dist/tools/tfa.d.ts.map +1 -1
  123. package/dist/tools/tfa.js +14 -5
  124. package/dist/tools/tfa.js.map +1 -1
  125. package/dist/tools/vm-rpa.d.ts +2 -1
  126. package/dist/tools/vm-rpa.d.ts.map +1 -1
  127. package/dist/tools/vm-rpa.js +82 -38
  128. package/dist/tools/vm-rpa.js.map +1 -1
  129. package/dist/tools/vm.d.ts +2 -1
  130. package/dist/tools/vm.d.ts.map +1 -1
  131. package/dist/tools/vm.js +511 -114
  132. package/dist/tools/vm.js.map +1 -1
  133. package/dist/tools/workflow-ops.d.ts.map +1 -1
  134. package/dist/tools/workflow-ops.js.map +1 -1
  135. package/dist/types.d.ts +6 -0
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/vm-service-client.d.ts +8 -2
  138. package/dist/vm-service-client.d.ts.map +1 -1
  139. package/dist/vm-service-client.js.map +1 -1
  140. package/dist/vm-target.d.ts +55 -0
  141. package/dist/vm-target.d.ts.map +1 -0
  142. package/dist/vm-target.js +134 -0
  143. package/dist/vm-target.js.map +1 -0
  144. package/package.json +2 -1
  145. package/skills/general/blueprint-vm-walkthrough.md +162 -0
@@ -0,0 +1,610 @@
1
+ /**
2
+ * LIVE end-to-end suite for the blueprint_* MCP tool suite, exercising a REAL
3
+ * middleware-service — the layer above src/__tests__/blueprints-tools.test.ts
4
+ * (which mocks fetch). Every call here goes through the real MCP server
5
+ * (in-memory transport) into real HTTP against the service.
6
+ *
7
+ * GATING: the whole suite is skipped unless LIVE_MW_URL is set (so plain
8
+ * `npm test` / CI without a local stack never touches the network). The
9
+ * expected arrangement is the middleware-service smoke harness: the service's
10
+ * LAMINAR_API_URL pointed at scripts/smoke-sidecar.mjs, which accepts ANY
11
+ * bearer as user smoke@test in workspace $WORKSPACE_ID. scripts/live-tests.sh
12
+ * in this repo brings that up and tears it down (see docs/LIVE-TESTS.md).
13
+ *
14
+ * Env:
15
+ * LIVE_MW_URL e.g. http://localhost:4571 (required — gates the suite)
16
+ * LIVE_MW_WORKSPACE workspace id the sidecar grants (default 777)
17
+ * LIVE_MW_TOKEN bearer to present (default "live-mcp-tests"; the
18
+ * sidecar accepts anything)
19
+ *
20
+ * COST NOTE: exactly ONE test calls the real synthesis LLM (30–300s, costs
21
+ * money). Everything else uses cheap endpoints. States that need a real job
22
+ * build (building / needs_input / job_ahead) are unit-covered only.
23
+ *
24
+ * HYGIENE: every blueprint is created with a unique run-stamped name and
25
+ * deleted in afterEach, so parallel/repeat runs don't collide and the suite
26
+ * tolerates pre-existing data in the workspace.
27
+ */
28
+ import { randomUUID } from "node:crypto";
29
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
31
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
32
+ import { createMinicorServer } from "../lib.js";
33
+ import { MiddlewareServiceClient } from "../middleware-service-client.js";
34
+ const LIVE_URL = (process.env.LIVE_MW_URL ?? "").replace(/\/+$/, "");
35
+ const WORKSPACE = Number(process.env.LIVE_MW_WORKSPACE ?? 777);
36
+ const TOKEN = process.env.LIVE_MW_TOKEN ?? "live-mcp-tests";
37
+ const RUN_STAMP = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
38
+ const uniqueName = (suffix) => `live-mcp ${RUN_STAMP} ${suffix}`;
39
+ /**
40
+ * A minimal document patch that passes the service's shape validation.
41
+ * NOTE the section envelope: screens/steps/credentials/exampleCases merge via
42
+ * { upsert, remove } (BlueprintPatch), not bare arrays — bare arrays no-op.
43
+ */
44
+ const SPEC_PATCH = {
45
+ goal: "Review cost-center spend and flag anomalies",
46
+ screens: { upsert: [{ id: "scr-report", name: "Spend report" }] },
47
+ steps: {
48
+ upsert: [
49
+ {
50
+ id: "s1",
51
+ screenId: "scr-report",
52
+ intent: "Open the monthly spend report",
53
+ },
54
+ ],
55
+ },
56
+ };
57
+ const describeLive = LIVE_URL ? describe : describe.skip;
58
+ if (!LIVE_URL) {
59
+ // eslint-disable-next-line no-console
60
+ console.log("[blueprints.live] LIVE_MW_URL is not set — skipping the live suite. " +
61
+ "Run scripts/live-tests.sh (or export LIVE_MW_URL=http://localhost:4571 " +
62
+ "with the smoke sidecar arrangement up) to execute it.");
63
+ }
64
+ describeLive("blueprint tools (LIVE middleware-service)", () => {
65
+ let client;
66
+ let embedded;
67
+ let raw;
68
+ const savedMwUrl = process.env.MIDDLEWARE_SERVICE_URL;
69
+ /** Blueprints created by the current test — deleted in afterEach. */
70
+ const created = [];
71
+ beforeAll(async () => {
72
+ // Route the MCP's middleware client straight at the live service.
73
+ process.env.MIDDLEWARE_SERVICE_URL = LIVE_URL;
74
+ // Fail fast with a legible message when the stack isn't actually up.
75
+ const health = await fetch(`${LIVE_URL}/health`).catch((e) => e);
76
+ if (health instanceof Error || !health.ok) {
77
+ throw new Error(`middleware-service is not healthy at ${LIVE_URL} — bring the stack up ` +
78
+ `first (see docs/LIVE-TESTS.md / scripts/live-tests.sh). ` +
79
+ `Probe result: ${health instanceof Error ? health.message : health.status}`);
80
+ }
81
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
82
+ embedded = await createMinicorServer(serverTransport, {
83
+ localTools: false,
84
+ credentials: {
85
+ access_token: TOKEN,
86
+ refresh_token: "live-refresh-unused",
87
+ expires_at: Date.now() + 3_600_000,
88
+ },
89
+ baseUrl: "https://api.test.invalid",
90
+ codeMode: false,
91
+ });
92
+ client = new Client({ name: "live-test-client", version: "1.0.0" });
93
+ await client.connect(clientTransport);
94
+ raw = new MiddlewareServiceClient(LIVE_URL, TOKEN);
95
+ });
96
+ afterAll(async () => {
97
+ await client?.close();
98
+ await embedded?.close();
99
+ if (savedMwUrl === undefined)
100
+ delete process.env.MIDDLEWARE_SERVICE_URL;
101
+ else
102
+ process.env.MIDDLEWARE_SERVICE_URL = savedMwUrl;
103
+ });
104
+ afterEach(async () => {
105
+ const ids = created.splice(0);
106
+ for (const id of ids) {
107
+ await raw.deleteBlueprint(WORKSPACE, id).catch(() => undefined);
108
+ }
109
+ });
110
+ /**
111
+ * Call a tool and parse its payload. ok() responses parse as JSON; text()
112
+ * error responses come back as { __text }.
113
+ */
114
+ async function call(name, args) {
115
+ const result = await client.callTool({
116
+ name,
117
+ arguments: { workspaceId: WORKSPACE, ...args },
118
+ });
119
+ const body = result.content?.[0]?.text ?? "";
120
+ try {
121
+ return JSON.parse(body);
122
+ }
123
+ catch {
124
+ return { __text: body, __isError: Boolean(result.isError) };
125
+ }
126
+ }
127
+ /** Create a tracked blueprint and return the created row. */
128
+ async function createBp(suffix) {
129
+ const payload = await call("blueprint_create", { name: uniqueName(suffix) });
130
+ expect(payload.status).toBe("created");
131
+ expect(typeof payload.blueprint?.id).toBe("string");
132
+ created.push(payload.blueprint.id);
133
+ return payload.blueprint;
134
+ }
135
+ // ════════════════════════════════════════════════════════════
136
+ // 1. LIFECYCLE WALK
137
+ // ════════════════════════════════════════════════════════════
138
+ it("lifecycle: create → get → add entries → list → update(CAS) → versions → delete", async () => {
139
+ const bp = await createBp("lifecycle");
140
+ expect(bp.name).toBe(uniqueName("lifecycle"));
141
+ // blueprint_get on a fresh blueprint: the advertised empty-state facts.
142
+ const got = await call("blueprint_get", { blueprintId: bp.id });
143
+ expect(got.blueprint.id).toBe(bp.id);
144
+ expect(got.blueprint.state).toBe("never_synced");
145
+ expect(got.blueprint.headVersion).toBe(0);
146
+ expect(got.blueprint.librarySeq).toBe(0);
147
+ expect(got.blueprint.pendingProposal).toBeNull();
148
+ expect(got.blueprint.drift).toEqual({ newEntries: [], unsyncedVersions: [] });
149
+ expect(got.inbox).toEqual({ openQuestions: [], pendingProposal: null });
150
+ // Note entry: not enrichable — ready immediately, bumps librarySeq.
151
+ const note = await call("blueprint_add_entry", {
152
+ blueprintId: bp.id,
153
+ kind: "note",
154
+ title: "SOP outline",
155
+ text: "Open the spend report, filter to the cost center, export the rows.",
156
+ });
157
+ expect(note.status).toBe("created");
158
+ expect(note.entry.kind).toBe("note");
159
+ expect(note.entry.status).toBe("ready");
160
+ expect(note.entry.seq).toBe(1);
161
+ // URL entry: enrichable — starts pending (or races straight to ready),
162
+ // and the tool tells you to poll before synthesizing.
163
+ const link = await call("blueprint_add_entry", {
164
+ blueprintId: bp.id,
165
+ kind: "url",
166
+ title: "Reference page",
167
+ url: "https://example.com/",
168
+ });
169
+ expect(link.status).toBe("created");
170
+ expect(["pending", "processing", "ready"]).toContain(link.entry.status);
171
+ if (link.entry.status !== "ready") {
172
+ expect(link.note).toContain("blueprint_list_entries");
173
+ }
174
+ // blueprint_list_entries: enrichment status is visible per row + counts.
175
+ const listed = await call("blueprint_list_entries", { blueprintId: bp.id });
176
+ expect(listed.counts.total).toBe(2);
177
+ expect(listed.counts.failed).toBe(0);
178
+ const byId = new Map(listed.entries.map((e) => [e.id, e]));
179
+ expect(byId.has(note.entry.id)).toBe(true);
180
+ expect(byId.has(link.entry.id)).toBe(true);
181
+ for (const e of listed.entries) {
182
+ expect(["pending", "processing", "ready", "failed"]).toContain(e.status);
183
+ }
184
+ if (listed.counts.pending > 0) {
185
+ expect(listed.note).toContain("enriching");
186
+ }
187
+ // blueprint_update with baseVersion: CAS write snapshots version 1.
188
+ const updated = await call("blueprint_update", {
189
+ blueprintId: bp.id,
190
+ patch: SPEC_PATCH,
191
+ note: "live-test: seed spec",
192
+ baseVersion: 0,
193
+ });
194
+ expect(updated.status).toBe("updated");
195
+ expect(updated.headVersion).toBe(1);
196
+ expect(updated.blueprint.document.goal).toBe(SPEC_PATCH.goal);
197
+ expect(updated.blueprint.document.steps).toHaveLength(1);
198
+ // blueprint_list_versions: the snapshot is there, and fetchable in full.
199
+ const versions = await call("blueprint_list_versions", {
200
+ blueprintId: bp.id,
201
+ });
202
+ expect(Array.isArray(versions.versions)).toBe(true);
203
+ expect(versions.versions.length).toBeGreaterThanOrEqual(1);
204
+ const v1 = await call("blueprint_list_versions", {
205
+ blueprintId: bp.id,
206
+ version: 1,
207
+ });
208
+ expect(v1.version).toBeTruthy();
209
+ // blueprint_list: our row shows up with the summary shape.
210
+ const all = await call("blueprint_list", {});
211
+ const row = all.blueprints.find((b) => b.id === bp.id);
212
+ expect(row).toBeTruthy();
213
+ expect(row.name).toBe(uniqueName("lifecycle"));
214
+ expect(typeof row.state).toBe("string");
215
+ // Delete cleanup, then read-after-delete is a structured 404.
216
+ await raw.deleteBlueprint(WORKSPACE, bp.id);
217
+ created.length = 0; // already gone; skip afterEach delete
218
+ const after = await call("blueprint_get", { blueprintId: bp.id });
219
+ expect(after.__text).toContain("404");
220
+ expect(after.__text.toLowerCase()).toContain("not found");
221
+ expect(after.__text).not.toMatch(/\n\s+at /); // no stack trace
222
+ });
223
+ // ════════════════════════════════════════════════════════════
224
+ // 2. resolve_blueprint_state LIVE DECISION TABLE
225
+ // ════════════════════════════════════════════════════════════
226
+ it("decision table: no_context → no_spec → needs_answers → answered (404-convergence) → spec_stale", async () => {
227
+ const bp = await createBp("decision");
228
+ // Fresh: no entries, empty spec → no_context, and (no linked route)
229
+ // resolve degrades sanely: link/build null, no blockers.
230
+ let r = await call("resolve_blueprint_state", { blueprintId: bp.id });
231
+ expect(r.state).toBe("no_context");
232
+ expect(r.backendState).toBe("never_synced");
233
+ expect(r.link).toBeNull();
234
+ expect(r.build).toBeNull();
235
+ expect(r.blockers).toEqual([]);
236
+ expect(r.resources).toMatchObject({ total: 0, ready: 0 });
237
+ expect(r.nextActions.join("\n")).toContain("blueprint_add_entry");
238
+ // Entries added + enriched (notes are ready instantly) → no_spec.
239
+ await call("blueprint_add_entry", {
240
+ blueprintId: bp.id,
241
+ kind: "note",
242
+ title: "How the review works",
243
+ text: "Pull the cost-center report every Monday and flag spend over budget.",
244
+ });
245
+ r = await call("resolve_blueprint_state", { blueprintId: bp.id });
246
+ expect(r.state).toBe("no_spec");
247
+ expect(r.resources).toMatchObject({ total: 1, ready: 1 });
248
+ expect(r.nextActions.join("\n")).toContain("blueprint_synthesize");
249
+ // Write openQuestions (plus a real spec) into the doc → needs_answers,
250
+ // with one prefilled answer call per question and the fold-once rule.
251
+ const up = await call("blueprint_update", {
252
+ blueprintId: bp.id,
253
+ patch: {
254
+ ...SPEC_PATCH,
255
+ openQuestions: ["Which cost center?", "Who approves exceptions?"],
256
+ },
257
+ note: "live-test: spec + questions",
258
+ baseVersion: r.blueprint.headVersion,
259
+ });
260
+ expect(up.status).toBe("updated");
261
+ r = await call("resolve_blueprint_state", { blueprintId: bp.id });
262
+ expect(r.state).toBe("needs_answers");
263
+ expect(r.openQuestions).toEqual([
264
+ "Which cost center?",
265
+ "Who approves exceptions?",
266
+ ]);
267
+ const answerActions = r.nextActions.join("\n");
268
+ expect(answerActions).toContain('question="Which cost center?"');
269
+ expect(answerActions).toContain('question="Who approves exceptions?"');
270
+ expect(answerActions).toContain("blueprint_synthesize once");
271
+ // Answer one question atomically.
272
+ const a1 = await call("blueprint_answer_question", {
273
+ blueprintId: bp.id,
274
+ question: "Which cost center?",
275
+ answer: "CC-1042 (Field Ops)",
276
+ });
277
+ expect(a1.status).toBe("answered");
278
+ expect(a1.remainingOpenQuestions).toEqual(["Who approves exceptions?"]);
279
+ expect(a1.driftNote).toContain("spec_stale");
280
+ // Answer the SAME question again → 404-convergence, not failure.
281
+ const a2 = await call("blueprint_answer_question", {
282
+ blueprintId: bp.id,
283
+ question: "Which cost center?",
284
+ answer: "CC-9999 (should not land)",
285
+ });
286
+ expect(a2.status).toBe("already_answered");
287
+ expect(a2.note).toContain("CONVERGENCE");
288
+ // Answer the last question → the tool points at ONE synthesize.
289
+ const a3 = await call("blueprint_answer_question", {
290
+ blueprintId: bp.id,
291
+ question: "Who approves exceptions?",
292
+ answer: "The finance lead",
293
+ });
294
+ expect(a3.status).toBe("answered");
295
+ expect(a3.remainingOpenQuestions).toEqual([]);
296
+ expect(a3.nextActions.join("\n")).toContain("blueprint_synthesize");
297
+ // Library (context note + 2 answer notes) moved past the spec → spec_stale.
298
+ // answerNotesOnly must be false: the first note is plain context.
299
+ r = await call("resolve_blueprint_state", { blueprintId: bp.id });
300
+ expect(r.state).toBe("spec_stale");
301
+ expect(r.drift.newEntries.length).toBeGreaterThanOrEqual(3);
302
+ expect(r.drift.answerNotesOnly).toBe(false);
303
+ expect(r.nextActions.join("\n")).toContain("blueprint_synthesize");
304
+ // Still no linked route — the build side keeps degrading sanely.
305
+ expect(r.link).toBeNull();
306
+ expect(r.build).toBeNull();
307
+ // The answer notes are real library rows with the provenance link.
308
+ const entries = await call("blueprint_list_entries", { blueprintId: bp.id });
309
+ const answers = entries.entries.filter((e) => e.kind === "note" && e.content?.answersQuestion);
310
+ expect(answers.map((e) => e.content.answersQuestion).sort()).toEqual([
311
+ "Which cost center?",
312
+ "Who approves exceptions?",
313
+ ]);
314
+ });
315
+ // ════════════════════════════════════════════════════════════
316
+ // 3. CAS UNDER FIRE
317
+ // ════════════════════════════════════════════════════════════
318
+ it("CAS under fire: two concurrent same-baseVersion updates — exactly one wins, the loser gets version_conflict", async () => {
319
+ const bp = await createBp("cas");
320
+ const seed = await call("blueprint_update", {
321
+ blueprintId: bp.id,
322
+ patch: { goal: "seed goal" },
323
+ note: "live-test: seed",
324
+ baseVersion: 0,
325
+ });
326
+ expect(seed.status).toBe("updated");
327
+ expect(seed.headVersion).toBe(1);
328
+ // Deterministic CAS: a write authored against version 0 AFTER the head
329
+ // committed version 1 — the loser must get the structured conflict with
330
+ // re-read guidance, never a raw error.
331
+ const stale = await call("blueprint_update", {
332
+ blueprintId: bp.id,
333
+ patch: { goal: "stale writer goal" },
334
+ note: "live-test: stale writer",
335
+ baseVersion: 0,
336
+ });
337
+ expect(stale.status).toBe("blocked");
338
+ expect(stale.blocked.reason).toBe("version_conflict");
339
+ expect(stale.blocked.retryable).toBe(true);
340
+ expect(stale.nextActions.join("\n")).toContain("blueprint_get");
341
+ expect(stale.nextActions.join("\n")).toContain("baseVersion");
342
+ // Concurrent same-base writers: EXACTLY one wins. The service runs the
343
+ // baseVersion check + write + version snapshot inside one row-locked
344
+ // transaction (middleware-service 5b3a8b3 — the CAS TOCTOU an earlier
345
+ // version of this test detected), so a double-win here is a regression
346
+ // and a hard failure.
347
+ const [x, y] = await Promise.all([
348
+ call("blueprint_update", {
349
+ blueprintId: bp.id,
350
+ patch: { goal: "writer A goal" },
351
+ note: "live-test: writer A",
352
+ baseVersion: 1,
353
+ }),
354
+ call("blueprint_update", {
355
+ blueprintId: bp.id,
356
+ patch: { goal: "writer B goal" },
357
+ note: "live-test: writer B",
358
+ baseVersion: 1,
359
+ }),
360
+ ]);
361
+ const winners = [x, y].filter((p) => p.status === "updated");
362
+ const losers = [x, y].filter((p) => p.status === "blocked");
363
+ expect(winners, `expected exactly one winner (CAS regression? see middleware 5b3a8b3): ${JSON.stringify([x.status ?? x.__text, y.status ?? y.__text])}`).toHaveLength(1);
364
+ expect(losers).toHaveLength(1);
365
+ expect(losers[0].blocked.reason).toBe("version_conflict");
366
+ expect(losers[0].blocked.retryable).toBe(true);
367
+ expect(losers[0].nextActions.join("\n")).toContain("blueprint_get");
368
+ // The surviving document is the winner's, at head 2.
369
+ const got = await call("blueprint_get", { blueprintId: bp.id });
370
+ expect(got.blueprint.headVersion).toBe(2);
371
+ expect(got.blueprint.document.goal).toBe(winners[0].blueprint.document.goal);
372
+ });
373
+ it("proposal guards without a proposal: reject → already_gone, apply → no_pending_proposal", async () => {
374
+ const bp = await createBp("proposal-guards");
375
+ // Reject with nothing pending: 404 is success-shaped.
376
+ const rejected = await call("blueprint_reject_proposal", {
377
+ blueprintId: bp.id,
378
+ });
379
+ expect(rejected.status).toBe("already_gone");
380
+ expect(rejected.note).toContain("Success-shaped");
381
+ // Apply with nothing pending: convergence, no error surface.
382
+ const applied = await call("blueprint_apply_proposal", {
383
+ blueprintId: bp.id,
384
+ baseVersion: 0,
385
+ proposalBasedOnSeq: 0,
386
+ });
387
+ expect(applied.status).toBe("no_pending_proposal");
388
+ expect(applied.note).toContain("convergence");
389
+ // get_proposal agrees and routes you to synthesize.
390
+ const gp = await call("blueprint_get_proposal", { blueprintId: bp.id });
391
+ expect(gp.status).toBe("no_pending_proposal");
392
+ expect(gp.nextActions.join("\n")).toContain("blueprint_synthesize");
393
+ });
394
+ // ════════════════════════════════════════════════════════════
395
+ // 4. THE ONE SYNTHESIS TEST (real LLM call — 30–300s, costs money)
396
+ // ════════════════════════════════════════════════════════════
397
+ it("synthesize → citations → get_proposal → swapped-guard → apply lands a spec", async (ctx) => {
398
+ if (process.env.LIVE_SKIP_SYNTHESIS) {
399
+ // eslint-disable-next-line no-console
400
+ console.log("[blueprints.live] SKIP synthesis test: LIVE_SKIP_SYNTHESIS is set " +
401
+ "(cost-saving knob — unset it for full coverage).");
402
+ ctx.skip();
403
+ return;
404
+ }
405
+ const bp = await createBp("synthesis");
406
+ const e1 = await call("blueprint_add_entry", {
407
+ blueprintId: bp.id,
408
+ kind: "note",
409
+ title: "SOP: submit a reimbursement",
410
+ text: "Open the expenses portal. Click 'New reimbursement'. Enter the amount and cost center CC-1042. Attach the receipt. Submit.",
411
+ });
412
+ const e2 = await call("blueprint_add_entry", {
413
+ blueprintId: bp.id,
414
+ kind: "note",
415
+ title: "Approval rules",
416
+ text: "Amounts under $100 auto-approve. Anything else routes to the finance lead for manual approval.",
417
+ });
418
+ expect(e1.entry.status).toBe("ready");
419
+ expect(e2.entry.status).toBe("ready");
420
+ const synthesizeOnce = () => call("blueprint_synthesize", {
421
+ blueprintId: bp.id,
422
+ instructions: "Keep the spec minimal: one screen, at most four steps, one example case.",
423
+ });
424
+ let syn = await synthesizeOnce();
425
+ // One retry for transient LLM-gateway failures (5xx/429 ride through the
426
+ // tool as an unstructured error text).
427
+ if (syn.__text && /failed \((5\d\d|429)\)/.test(syn.__text)) {
428
+ // eslint-disable-next-line no-console
429
+ console.warn(`[blueprints.live] transient synthesize failure, retrying once: ${syn.__text.slice(0, 300)}`);
430
+ await new Promise((r) => setTimeout(r, 10_000));
431
+ syn = await synthesizeOnce();
432
+ }
433
+ if (syn.status === "blocked" && syn.blocked?.reason === "llm_unconfigured") {
434
+ // eslint-disable-next-line no-console
435
+ console.log("[blueprints.live] SKIP synthesis test: the service has no synthesis " +
436
+ "LLM configured (set MINICOR_LLMS_API_KEY on middleware-service).");
437
+ ctx.skip();
438
+ return;
439
+ }
440
+ expect(syn.status, `unexpected synthesize payload: ${JSON.stringify(syn).slice(0, 1500)}`).toBe("proposal_ready");
441
+ const proposal = syn.proposal;
442
+ expect(proposal).toBeTruthy();
443
+ expect(typeof proposal.summary).toBe("string");
444
+ expect(proposal.summary.length).toBeGreaterThan(0);
445
+ // Citations must point at REAL entry ids and carry provenance tags.
446
+ const entryIds = new Set([e1.entry.id, e2.entry.id]);
447
+ expect(Array.isArray(proposal.citations)).toBe(true);
448
+ expect(proposal.citations.length).toBeGreaterThan(0);
449
+ for (const c of proposal.citations) {
450
+ expect(entryIds.has(c.entryId)).toBe(true);
451
+ expect(["extracted", "inferred", "ambiguous"]).toContain(c.provenance);
452
+ }
453
+ // get_proposal returns the exact apply coordinates.
454
+ const gp = await call("blueprint_get_proposal", { blueprintId: bp.id });
455
+ expect(gp.applyWith).toEqual({
456
+ baseVersion: proposal.baseVersion,
457
+ proposalBasedOnSeq: proposal.basedOnSeq,
458
+ });
459
+ // Stale coordinates → the swapped guard refuses (simulated stale reviewer).
460
+ const swapped = await call("blueprint_apply_proposal", {
461
+ blueprintId: bp.id,
462
+ baseVersion: gp.applyWith.baseVersion + 1,
463
+ proposalBasedOnSeq: gp.applyWith.proposalBasedOnSeq + 1,
464
+ });
465
+ expect(swapped.status).toBe("blocked");
466
+ expect(swapped.blocked.reason).toBe("proposal_swapped");
467
+ expect(swapped.nextActions.join("\n")).toContain("blueprint_get_proposal");
468
+ // Correct coordinates → applied; the doc now has real substance.
469
+ const applied = await call("blueprint_apply_proposal", {
470
+ blueprintId: bp.id,
471
+ baseVersion: gp.applyWith.baseVersion,
472
+ proposalBasedOnSeq: gp.applyWith.proposalBasedOnSeq,
473
+ });
474
+ expect(applied.status).toBe("applied");
475
+ expect(applied.headVersion).toBeGreaterThanOrEqual(1);
476
+ const got = await call("blueprint_get", { blueprintId: bp.id });
477
+ expect(got.blueprint.pendingProposal).toBeNull();
478
+ const doc = got.blueprint.document;
479
+ expect((doc.goal ?? "").length).toBeGreaterThan(0);
480
+ expect((doc.steps ?? []).length).toBeGreaterThan(0);
481
+ // Post-apply resolve: the spec is ahead of the (nonexistent) job —
482
+ // job_stale routes to blueprint_build, or needs_answers if the LLM left
483
+ // open questions on the spec.
484
+ const r = await call("resolve_blueprint_state", { blueprintId: bp.id });
485
+ expect(["job_stale", "needs_answers"]).toContain(r.state);
486
+ if (r.state === "job_stale") {
487
+ expect(r.nextActions.join("\n")).toContain("blueprint_build");
488
+ }
489
+ }, 360_000);
490
+ // ════════════════════════════════════════════════════════════
491
+ // 5. UPLOAD HANDSHAKE
492
+ // ════════════════════════════════════════════════════════════
493
+ it("upload handshake: upload_url → PUT blob → add_entry(blobRef) → downloadUrl fetches", async (ctx) => {
494
+ const bp = await createBp("upload");
495
+ const up = await call("blueprint_entry_upload_url", {
496
+ blueprintId: bp.id,
497
+ fileName: "live-upload.txt",
498
+ });
499
+ if (up.status === "blocked" && up.blocked?.reason === "blob_storage_unconfigured") {
500
+ // eslint-disable-next-line no-console
501
+ console.log("[blueprints.live] SKIP upload test: blob storage is not configured " +
502
+ "on the service (set JOB_ARTIFACTS_BUCKET and point " +
503
+ "GOOGLE_APPLICATION_CREDENTIALS at a service-account key).");
504
+ ctx.skip();
505
+ return;
506
+ }
507
+ expect(up.uploadUrl).toMatch(/^https?:\/\//);
508
+ expect(up.blobRef).toMatch(/^gs:\/\//);
509
+ expect(typeof up.expiresOn).toBe("string");
510
+ expect(up.nextActions.join("\n")).toContain("PUT the file bytes");
511
+ const body = `live upload payload ${RUN_STAMP}`;
512
+ // Plain PUT: GCS signed URLs need no extension headers.
513
+ const put = await fetch(up.uploadUrl, {
514
+ method: "PUT",
515
+ headers: { "Content-Type": "text/plain" },
516
+ body,
517
+ });
518
+ expect(put.status).toBeGreaterThanOrEqual(200);
519
+ expect(put.status).toBeLessThan(300);
520
+ const added = await call("blueprint_add_entry", {
521
+ blueprintId: bp.id,
522
+ kind: "doc",
523
+ title: "Uploaded doc",
524
+ blobRef: up.blobRef,
525
+ fileName: "live-upload.txt",
526
+ contentType: "text/plain",
527
+ sizeBytes: Buffer.byteLength(body, "utf8"),
528
+ });
529
+ expect(added.status).toBe("created");
530
+ expect(added.entry.blobRef).toBe(up.blobRef);
531
+ const one = await call("blueprint_list_entries", {
532
+ blueprintId: bp.id,
533
+ entryId: added.entry.id,
534
+ downloadUrl: true,
535
+ });
536
+ expect(one.entry.id).toBe(added.entry.id);
537
+ expect(one.downloadUrl).toMatch(/^https?:\/\//);
538
+ const dl = await fetch(one.downloadUrl);
539
+ expect(dl.status).toBe(200);
540
+ expect(await dl.text()).toBe(body);
541
+ });
542
+ // ════════════════════════════════════════════════════════════
543
+ // 6. ERROR SURFACES
544
+ // ════════════════════════════════════════════════════════════
545
+ it("nonexistent blueprint id → structured not-found, not a stack trace", async () => {
546
+ const ghost = randomUUID();
547
+ const got = await call("blueprint_get", { blueprintId: ghost });
548
+ expect(got.__text).toBeTruthy();
549
+ expect(got.__text).toContain("404");
550
+ expect(got.__text.toLowerCase()).toContain("not found");
551
+ expect(got.__text).not.toMatch(/\n\s+at /);
552
+ const resolved = await call("resolve_blueprint_state", {
553
+ blueprintId: ghost,
554
+ });
555
+ expect(resolved.__text).toContain("404");
556
+ });
557
+ it("malformed entry kind is rejected at the tool boundary (never hits the wire)", async () => {
558
+ const bp = await createBp("bad-kind");
559
+ let message = "";
560
+ try {
561
+ const result = await client.callTool({
562
+ name: "blueprint_add_entry",
563
+ arguments: {
564
+ workspaceId: WORKSPACE,
565
+ blueprintId: bp.id,
566
+ kind: "sop", // not a valid BlueprintEntryKind
567
+ title: "bogus",
568
+ },
569
+ });
570
+ message = result.content?.[0]?.text ?? "";
571
+ expect(result.isError).toBe(true);
572
+ }
573
+ catch (e) {
574
+ message = e.message; // SDK may throw InvalidParams instead
575
+ }
576
+ expect(message.toLowerCase()).toMatch(/kind|invalid|enum/);
577
+ // Nothing was created.
578
+ const listed = await call("blueprint_list_entries", { blueprintId: bp.id });
579
+ expect(listed.counts.total).toBe(0);
580
+ });
581
+ it("answering a question that was never open converges instead of failing", async () => {
582
+ const bp = await createBp("ghost-question");
583
+ const ans = await call("blueprint_answer_question", {
584
+ blueprintId: bp.id,
585
+ question: "Was this question ever asked?",
586
+ answer: "No.",
587
+ });
588
+ expect(ans.status).toBe("already_answered");
589
+ expect(ans.nextActions.join("\n")).toContain("blueprint_get");
590
+ });
591
+ it("blueprint_build on an empty spec surfaces the validator's problem list legibly", async () => {
592
+ const bp = await createBp("empty-build");
593
+ const built = await call("blueprint_build", { blueprintId: bp.id });
594
+ expect(built.status).toBe("blocked");
595
+ expect(built.blocked.reason).toBe("spec_not_syncable");
596
+ expect(built.blocked.retryable).toBe(true);
597
+ // The 400 problem list rides through the blocked message verbatim.
598
+ expect(built.blocked.message).toContain("goal is empty");
599
+ expect(built.blocked.message).toContain("no steps");
600
+ expect(built.nextActions.join("\n")).toContain("blueprint_update");
601
+ });
602
+ it("blueprint_synthesize on an empty library blocks with library_empty", async () => {
603
+ const bp = await createBp("empty-synth");
604
+ const syn = await call("blueprint_synthesize", { blueprintId: bp.id });
605
+ expect(syn.status).toBe("blocked");
606
+ expect(syn.blocked.reason).toBe("library_empty");
607
+ expect(syn.nextActions.join("\n")).toContain("blueprint_add_entry");
608
+ });
609
+ });
610
+ //# sourceMappingURL=blueprints.live.test.js.map