@minicor/mcp-server 4.8.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.
- package/README.md +82 -3
- package/dist/__live__/blueprints.live.test.d.ts +2 -0
- package/dist/__live__/blueprints.live.test.d.ts.map +1 -0
- package/dist/__live__/blueprints.live.test.js +610 -0
- package/dist/__live__/blueprints.live.test.js.map +1 -0
- package/dist/__tests__/blueprints-tools.test.d.ts +2 -0
- package/dist/__tests__/blueprints-tools.test.d.ts.map +1 -0
- package/dist/__tests__/blueprints-tools.test.js +1190 -0
- package/dist/__tests__/blueprints-tools.test.js.map +1 -0
- package/dist/__tests__/jobs-tools.test.js +76 -0
- package/dist/__tests__/jobs-tools.test.js.map +1 -1
- package/dist/__tests__/server-surface.test.js +19 -0
- package/dist/__tests__/server-surface.test.js.map +1 -1
- package/dist/bootstrap-perms.d.ts +12 -0
- package/dist/bootstrap-perms.d.ts.map +1 -0
- package/dist/bootstrap-perms.js +92 -0
- package/dist/bootstrap-perms.js.map +1 -0
- package/dist/bootstrap.js +2 -63
- package/dist/bootstrap.js.map +1 -1
- package/dist/middleware-service-client.d.ts +286 -0
- package/dist/middleware-service-client.d.ts.map +1 -1
- package/dist/middleware-service-client.js +143 -1
- package/dist/middleware-service-client.js.map +1 -1
- package/dist/server-surface.d.ts.map +1 -1
- package/dist/server-surface.js +26 -1
- package/dist/server-surface.js.map +1 -1
- package/dist/sync.js +5 -2
- package/dist/sync.js.map +1 -1
- package/dist/tools/blueprints.d.ts +20 -0
- package/dist/tools/blueprints.d.ts.map +1 -0
- package/dist/tools/blueprints.js +1193 -0
- package/dist/tools/blueprints.js.map +1 -0
- package/dist/tools/jobs.d.ts.map +1 -1
- package/dist/tools/jobs.js +57 -5
- package/dist/tools/jobs.js.map +1 -1
- package/package.json +2 -1
- 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
|