@minicor/mcp-server 4.8.0 → 4.10.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 +97 -5
- 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__/job-webhook-events.test.d.ts +2 -0
- package/dist/__tests__/job-webhook-events.test.d.ts.map +1 -0
- package/dist/__tests__/job-webhook-events.test.js +36 -0
- package/dist/__tests__/job-webhook-events.test.js.map +1 -0
- package/dist/__tests__/jobs-tools.test.js +211 -0
- package/dist/__tests__/jobs-tools.test.js.map +1 -1
- package/dist/__tests__/middleware-service-client.test.js +50 -0
- package/dist/__tests__/middleware-service-client.test.js.map +1 -1
- package/dist/__tests__/server-surface.test.js +22 -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 +98 -0
- package/dist/bootstrap-perms.js.map +1 -0
- package/dist/bootstrap.js +2 -63
- package/dist/bootstrap.js.map +1 -1
- package/dist/job-webhook-events.d.ts +30 -0
- package/dist/job-webhook-events.d.ts.map +1 -0
- package/dist/job-webhook-events.js +153 -0
- package/dist/job-webhook-events.js.map +1 -0
- package/dist/middleware-service-client.d.ts +332 -0
- package/dist/middleware-service-client.d.ts.map +1 -1
- package/dist/middleware-service-client.js +163 -1
- package/dist/middleware-service-client.js.map +1 -1
- package/dist/server-surface.d.ts.map +1 -1
- package/dist/server-surface.js +28 -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 +219 -11
- package/dist/tools/jobs.js.map +1 -1
- package/package.json +2 -1
- package/skills/general/blueprint-vm-walkthrough.md +162 -0
- package/skills/general/job-build-loop.md +4 -0
- package/skills/general/job-webhooks.md +182 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools for Blueprints — the front door of the Minicor build loop.
|
|
3
|
+
*
|
|
4
|
+
* A Blueprint is the living spec + context library that OWNS a job. The loop:
|
|
5
|
+
* feed the library (entries: SOPs, Looms, notes, walkthrough sessions) ->
|
|
6
|
+
* synthesize (LLM drafts a spec proposal) -> review/answer -> apply ->
|
|
7
|
+
* build (sync the spec into a Job) -> the jobs toolset takes over with the
|
|
8
|
+
* {middlewareId, routeId, buildId} triple blueprint_build returns.
|
|
9
|
+
*
|
|
10
|
+
* OWNERSHIP RULE: when a route is blueprint-linked, never register_job /
|
|
11
|
+
* update_job / add_test_case directly on it — that trips reverse-drift
|
|
12
|
+
* detection (state job_ahead). Drive changes through the spec instead.
|
|
13
|
+
*
|
|
14
|
+
* Auth/transport is handled by MiddlewareServiceClient (same plumbing as the
|
|
15
|
+
* jobs tools): the region's public middleware-service with the MCP's user
|
|
16
|
+
* bearer token. See middleware-service-client.ts for the base-URL precedence.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { ok, text } from "../helpers.js";
|
|
20
|
+
import { MiddlewareServiceClient, MiddlewareServiceError, } from "../middleware-service-client.js";
|
|
21
|
+
const regionParam = z
|
|
22
|
+
.enum(["us", "ca"])
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Region (us or ca). Auto-detected from workspace if omitted.");
|
|
25
|
+
const entryKindParam = z
|
|
26
|
+
.enum([
|
|
27
|
+
"doc",
|
|
28
|
+
"video",
|
|
29
|
+
"url",
|
|
30
|
+
"note",
|
|
31
|
+
"transcript",
|
|
32
|
+
"data-source",
|
|
33
|
+
"walkthrough",
|
|
34
|
+
"screen-map",
|
|
35
|
+
"anchor",
|
|
36
|
+
"screenshot",
|
|
37
|
+
])
|
|
38
|
+
.describe("Entry kind. Context kinds feed synthesis: doc | video | url | note | transcript | data-source | walkthrough. Resource kinds are runtime-referenceable as entry://<id>#<fragment>: screen-map | anchor | screenshot.");
|
|
39
|
+
/** HTTP status of a middleware-service failure, or undefined for other errors. */
|
|
40
|
+
function statusOf(e) {
|
|
41
|
+
return e instanceof MiddlewareServiceError ? e.status : undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Structured error code of a middleware-service failure, when the service sent one. */
|
|
44
|
+
function codeOf(e) {
|
|
45
|
+
return e instanceof MiddlewareServiceError ? e.code : undefined;
|
|
46
|
+
}
|
|
47
|
+
/** A build run is considered stalled after this long in `queued`. */
|
|
48
|
+
const BUILDER_STALL_MS = 2 * 60 * 1000;
|
|
49
|
+
/** True when the spec document has no substance yet (nothing to build from). */
|
|
50
|
+
function specIsEmpty(bp) {
|
|
51
|
+
const doc = bp.document ?? {};
|
|
52
|
+
const goal = typeof doc.goal === "string" ? doc.goal.trim() : "";
|
|
53
|
+
const steps = Array.isArray(doc.steps) ? doc.steps : [];
|
|
54
|
+
return goal === "" && steps.length === 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Classify a blocked synthesize response into a structured blocked.reason.
|
|
58
|
+
* Newer services carry a stable snake_case `code` on every synthesis-path
|
|
59
|
+
* 409/502 — that is the primary discriminator. The message-string matching
|
|
60
|
+
* below it is a FALLBACK for older deployments that predate the codes;
|
|
61
|
+
* remove it once the middleware structured-code change is deployed
|
|
62
|
+
* everywhere (middleware-service faiz/synthesis-shape-retry).
|
|
63
|
+
*/
|
|
64
|
+
function classifySynthesizeBlock(status, message, code) {
|
|
65
|
+
switch (code) {
|
|
66
|
+
case "proposal_pending":
|
|
67
|
+
return { reason: "proposal_pending", retryable: true };
|
|
68
|
+
case "entries_enriching":
|
|
69
|
+
return { reason: "entries_enriching", retryable: true };
|
|
70
|
+
case "synthesis_not_configured":
|
|
71
|
+
return { reason: "llm_unconfigured", retryable: false };
|
|
72
|
+
// 502s: the LLM leg failed — synthesize again (the service already
|
|
73
|
+
// retries internally; a repeat failure is worth one more attempt
|
|
74
|
+
// before escalating).
|
|
75
|
+
case "llm_gateway_failed":
|
|
76
|
+
case "synthesis_unparseable":
|
|
77
|
+
case "synthesis_missing_patch":
|
|
78
|
+
case "patch_shape_invalid":
|
|
79
|
+
return { reason: code, retryable: true };
|
|
80
|
+
}
|
|
81
|
+
// Legacy string-matching fallback (older services send no `code`).
|
|
82
|
+
if (status === 409) {
|
|
83
|
+
if (/already pending/i.test(message))
|
|
84
|
+
return { reason: "proposal_pending", retryable: true };
|
|
85
|
+
if (/still being enriched/i.test(message))
|
|
86
|
+
return { reason: "entries_enriching", retryable: true };
|
|
87
|
+
if (/not configured/i.test(message))
|
|
88
|
+
return { reason: "llm_unconfigured", retryable: false };
|
|
89
|
+
return { reason: "conflict", retryable: true };
|
|
90
|
+
}
|
|
91
|
+
if (status === 400 && /empt/i.test(message))
|
|
92
|
+
return { reason: "library_empty", retryable: true };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
export function register(deps) {
|
|
96
|
+
const { server } = deps;
|
|
97
|
+
function getClient(region) {
|
|
98
|
+
const token = deps.getAuthToken?.(region) || "";
|
|
99
|
+
return new MiddlewareServiceClient(deps.getApiBase(region), token);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* GET /:id returns the computed-state block (headVersion/state/drift), but
|
|
103
|
+
* the WRITE endpoints (PATCH /:id, POST /:id/proposal/apply, DELETE
|
|
104
|
+
* /:id/proposal) return the raw blueprint row without it. headVersion is
|
|
105
|
+
* the next edit's CAS base, so it must never be reported as undefined —
|
|
106
|
+
* re-read when the computed fields are missing. If the re-read ALSO fails,
|
|
107
|
+
* the write still succeeded: return the raw row plus a stateReadError so
|
|
108
|
+
* the tool can mark the response partial instead of claiming a complete
|
|
109
|
+
* computed-state result.
|
|
110
|
+
*/
|
|
111
|
+
async function withComputedState(client, workspaceId, bp) {
|
|
112
|
+
if (bp.headVersion !== undefined && bp.state !== undefined)
|
|
113
|
+
return { bp };
|
|
114
|
+
try {
|
|
115
|
+
return { bp: await client.getBlueprint(workspaceId, bp.id) };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return { bp, stateReadError: String(e?.message ?? e) };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Partial-response marker for a write whose post-write re-read failed. */
|
|
122
|
+
function stateReadWarning(blueprintId, stateReadError) {
|
|
123
|
+
return stateReadError
|
|
124
|
+
? {
|
|
125
|
+
warning: `The write succeeded but the post-write state re-read failed (${stateReadError}) — headVersion/state are unknown. blueprint_get blueprintId=${blueprintId} before the next CAS edit.`,
|
|
126
|
+
}
|
|
127
|
+
: {};
|
|
128
|
+
}
|
|
129
|
+
function resolveRegion(workspaceId, explicit) {
|
|
130
|
+
if (explicit)
|
|
131
|
+
return explicit;
|
|
132
|
+
return deps.state.getWorkspaceRegion(workspaceId) ?? undefined;
|
|
133
|
+
}
|
|
134
|
+
// ── blueprint_list ─────────────────────────────────────────
|
|
135
|
+
server.tool("blueprint_list", "List a workspace's blueprints: id, name, computed state (never_synced | building | build_failed | job_ahead | spec_stale | job_stale | in_sync), drift flags, and the linked job coordinates (middlewareId/routeId/buildId). Start of a blueprints pass: for any blueprint not in_sync, call resolve_blueprint_state on it and follow the returned nextActions.", {
|
|
136
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
137
|
+
region: regionParam,
|
|
138
|
+
}, async ({ workspaceId, region }) => {
|
|
139
|
+
const r = resolveRegion(workspaceId, region);
|
|
140
|
+
try {
|
|
141
|
+
const blueprints = await getClient(r).listBlueprints(workspaceId);
|
|
142
|
+
return ok({ blueprints });
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
return text(`Error listing blueprints: ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// ── blueprint_create ───────────────────────────────────────
|
|
149
|
+
server.tool("blueprint_create", "Create a blueprint — the living spec + context library that will own a job. Two entry paths: (a) NEW automation: create empty, then feed the library with blueprint_add_entry and run blueprint_synthesize; (b) BACKFILL an existing job: pass routeId + middlewareId to link it, then blueprint_import_cases adopts the job's test cases into the spec. After creating, resolve_blueprint_state tells you the next verb.", {
|
|
150
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
151
|
+
name: z.string().describe("Blueprint name"),
|
|
152
|
+
document: z
|
|
153
|
+
.record(z.string(), z.unknown())
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("Initial BlueprintDocument (defaults to the empty skeleton). Usually omit — the library + synthesize fills it."),
|
|
156
|
+
routeId: z
|
|
157
|
+
.string()
|
|
158
|
+
.optional()
|
|
159
|
+
.describe("Link an EXISTING job at creation (backfill). blueprint_build then rebuilds that job instead of creating a new one. Requires middlewareId."),
|
|
160
|
+
middlewareId: z
|
|
161
|
+
.string()
|
|
162
|
+
.optional()
|
|
163
|
+
.describe("Router owning routeId (required together with routeId)."),
|
|
164
|
+
region: regionParam,
|
|
165
|
+
}, async ({ workspaceId, name, document, routeId, middlewareId, region }) => {
|
|
166
|
+
const r = resolveRegion(workspaceId, region);
|
|
167
|
+
try {
|
|
168
|
+
const blueprint = await getClient(r).createBlueprint(workspaceId, {
|
|
169
|
+
name,
|
|
170
|
+
document: document,
|
|
171
|
+
routeId,
|
|
172
|
+
middlewareId,
|
|
173
|
+
});
|
|
174
|
+
return ok({
|
|
175
|
+
status: "created",
|
|
176
|
+
blueprint,
|
|
177
|
+
nextActions: [
|
|
178
|
+
routeId
|
|
179
|
+
? `Linked to existing job ${routeId} — blueprint_import_cases blueprintId=${blueprint.id} to adopt its test cases, then feed the library and synthesize.`
|
|
180
|
+
: `Feed the library: blueprint_add_entry blueprintId=${blueprint.id} (SOPs, Looms, notes, walkthroughs), then blueprint_synthesize.`,
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
return text(`Error creating blueprint: ${e.message}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
// ── blueprint_get ──────────────────────────────────────────
|
|
189
|
+
server.tool("blueprint_get", "Get a blueprint: the full spec document, computed state, both drift summaries, and the inbox (document.openQuestions + pendingProposal). This is the raw read — headVersion here is the baseVersion for blueprint_update's CAS, and pendingProposal's baseVersion/basedOnSeq are the coordinates blueprint_apply_proposal requires. For 'what should I do next', prefer resolve_blueprint_state, which turns this state into concrete tool calls.", {
|
|
190
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
191
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
192
|
+
region: regionParam,
|
|
193
|
+
}, async ({ workspaceId, blueprintId, region }) => {
|
|
194
|
+
const r = resolveRegion(workspaceId, region);
|
|
195
|
+
try {
|
|
196
|
+
const blueprint = await getClient(r).getBlueprint(workspaceId, blueprintId);
|
|
197
|
+
return ok({
|
|
198
|
+
blueprint,
|
|
199
|
+
inbox: {
|
|
200
|
+
openQuestions: blueprint.document?.openQuestions ?? [],
|
|
201
|
+
pendingProposal: blueprint.pendingProposal
|
|
202
|
+
? {
|
|
203
|
+
summary: blueprint.pendingProposal.summary,
|
|
204
|
+
baseVersion: blueprint.pendingProposal.baseVersion,
|
|
205
|
+
basedOnSeq: blueprint.pendingProposal.basedOnSeq,
|
|
206
|
+
createdAt: blueprint.pendingProposal.createdAt,
|
|
207
|
+
}
|
|
208
|
+
: null,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
return text(`Error fetching blueprint: ${e.message}`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// ── blueprint_update ───────────────────────────────────────
|
|
217
|
+
server.tool("blueprint_update", "Edit the spec document directly: `document` replaces wholesale, `patch` is the section-level merge (goal/schemas/openQuestions replaced, environment shallow-merged, screens/steps upserted by id, credentials by key, exampleCases by name). ALWAYS pass baseVersion (headVersion from blueprint_get / resolve_blueprint_state) — it is compare-and-swap: a 409 means the blueprint moved past your read, so RE-READ with blueprint_get and re-author the edit against the new head; never retry blindly. Prefer the synthesize->apply path for library-derived changes; direct edits are for surgical fixes. A successful document change snapshots a version and makes the job stale (blueprint_build to re-sync).", {
|
|
218
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
219
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
220
|
+
name: z.string().optional().describe("Rename the blueprint"),
|
|
221
|
+
document: z
|
|
222
|
+
.record(z.string(), z.unknown())
|
|
223
|
+
.optional()
|
|
224
|
+
.describe("Full document replacement (BlueprintDocument)"),
|
|
225
|
+
patch: z
|
|
226
|
+
.record(z.string(), z.unknown())
|
|
227
|
+
.optional()
|
|
228
|
+
.describe("Section-level merge (BlueprintPatch shape)"),
|
|
229
|
+
note: z
|
|
230
|
+
.string()
|
|
231
|
+
.optional()
|
|
232
|
+
.describe('Version note, e.g. "agent: added screen 3"'),
|
|
233
|
+
baseVersion: z
|
|
234
|
+
.number()
|
|
235
|
+
.optional()
|
|
236
|
+
.describe("Optimistic concurrency: the headVersion this edit was authored against. REQUIRED for document/patch edits (409 when the blueprint has moved past it). Omittable only for writes that cannot conflict (rename)."),
|
|
237
|
+
region: regionParam,
|
|
238
|
+
}, async ({ workspaceId, blueprintId, name, document, patch, note, baseVersion, region }) => {
|
|
239
|
+
// CAS is the protocol, not a suggestion: document edits without a
|
|
240
|
+
// baseVersion would silently last-write-win over concurrent editors.
|
|
241
|
+
// Only conflict-free writes (rename) may omit it.
|
|
242
|
+
if ((document !== undefined || patch !== undefined) && baseVersion === undefined) {
|
|
243
|
+
return ok({
|
|
244
|
+
status: "blocked",
|
|
245
|
+
blocked: {
|
|
246
|
+
reason: "base_version_required",
|
|
247
|
+
retryable: true,
|
|
248
|
+
message: "Document edits require baseVersion (compare-and-swap). Read headVersion first, author the edit against it, then retry.",
|
|
249
|
+
},
|
|
250
|
+
nextActions: [
|
|
251
|
+
`blueprint_get blueprintId=${blueprintId} for the current document and headVersion, then retry blueprint_update with baseVersion=<that headVersion>.`,
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const r = resolveRegion(workspaceId, region);
|
|
256
|
+
try {
|
|
257
|
+
const client = getClient(r);
|
|
258
|
+
const { bp: blueprint, stateReadError } = await withComputedState(client, workspaceId, await client.updateBlueprint(workspaceId, blueprintId, {
|
|
259
|
+
name,
|
|
260
|
+
document: document,
|
|
261
|
+
patch: patch,
|
|
262
|
+
note,
|
|
263
|
+
baseVersion,
|
|
264
|
+
}));
|
|
265
|
+
return ok({
|
|
266
|
+
status: "updated",
|
|
267
|
+
headVersion: blueprint.headVersion ?? null,
|
|
268
|
+
blueprint,
|
|
269
|
+
...stateReadWarning(blueprintId, stateReadError),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
if (statusOf(e) === 409) {
|
|
274
|
+
return ok({
|
|
275
|
+
status: "blocked",
|
|
276
|
+
blocked: {
|
|
277
|
+
reason: "version_conflict",
|
|
278
|
+
retryable: true,
|
|
279
|
+
message: e.message,
|
|
280
|
+
},
|
|
281
|
+
nextActions: [
|
|
282
|
+
`The blueprint moved past baseVersion ${baseVersion} — blueprint_get blueprintId=${blueprintId} to re-read the document and headVersion, re-author your edit against it, then retry blueprint_update with the new baseVersion.`,
|
|
283
|
+
],
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return text(`Error updating blueprint: ${e.message}`);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
// ── blueprint_add_entry ────────────────────────────────────
|
|
290
|
+
server.tool("blueprint_add_entry", "Add (or, with entryId, update) a library entry — the way you feed a blueprint context. Context kinds (doc/video/url/note/transcript/data-source/walkthrough) feed synthesis; resource kinds (screen-map/anchor/screenshot) are runtime-referenceable as entry://<id>#<fragment>. Enrichable kinds (doc/video/url with a url or blobRef) start status=pending and are enriched to text by a worker — poll blueprint_list_entries before synthesizing. For file uploads: blueprint_entry_upload_url first, PUT the bytes, then pass the returned blobRef here. VM WALKTHROUGH MODE (get_skill 'blueprint-vm-walkthrough'): create the walkthrough entry IMMEDIATELY when the session starts and PATCH it (entryId + content) after EVERY step — crash resilience. Every write bumps librarySeq, so the spec becomes spec_stale until the next blueprint_synthesize.", {
|
|
291
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
292
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
293
|
+
entryId: z
|
|
294
|
+
.string()
|
|
295
|
+
.optional()
|
|
296
|
+
.describe("PATCH mode: update this existing entry (title/text/url/content only) instead of creating one. A new url re-queues enrichment."),
|
|
297
|
+
kind: entryKindParam.optional().describe("Entry kind — required when creating, ignored in PATCH mode (kind is immutable)."),
|
|
298
|
+
title: z.string().optional().describe("Human-readable title (required when creating)"),
|
|
299
|
+
text: z.string().optional().describe("Freeform text (notes, pasted SOPs, answers)"),
|
|
300
|
+
url: z.string().optional().describe("Link / Loom URL (enriched to a transcript)"),
|
|
301
|
+
blobRef: z
|
|
302
|
+
.string()
|
|
303
|
+
.optional()
|
|
304
|
+
.describe("gs:// ref from blueprint_entry_upload_url (create mode only)"),
|
|
305
|
+
content: z
|
|
306
|
+
.record(z.string(), z.unknown())
|
|
307
|
+
.optional()
|
|
308
|
+
.describe("Structured payload: ScreenMapContent for screen-map, AnchorContent for anchor, WalkthroughContent (transcript + filmstrip + artifactRefs) for walkthrough, arbitrary refs for data-source"),
|
|
309
|
+
fileName: z.string().optional().describe("Original file name (uploads)"),
|
|
310
|
+
contentType: z.string().optional().describe("MIME type (uploads)"),
|
|
311
|
+
sizeBytes: z.number().optional().describe("Upload size in bytes"),
|
|
312
|
+
region: regionParam,
|
|
313
|
+
}, async ({ workspaceId, blueprintId, entryId, kind, title, text: entryText, url, blobRef, content, fileName, contentType, sizeBytes, region, }) => {
|
|
314
|
+
const r = resolveRegion(workspaceId, region);
|
|
315
|
+
try {
|
|
316
|
+
const client = getClient(r);
|
|
317
|
+
if (entryId) {
|
|
318
|
+
const entry = await client.updateBlueprintEntry(workspaceId, blueprintId, entryId, {
|
|
319
|
+
...(title !== undefined ? { title } : {}),
|
|
320
|
+
...(entryText !== undefined ? { text: entryText } : {}),
|
|
321
|
+
...(url !== undefined ? { url } : {}),
|
|
322
|
+
...(content !== undefined ? { content } : {}),
|
|
323
|
+
});
|
|
324
|
+
return ok({ status: "updated", entry });
|
|
325
|
+
}
|
|
326
|
+
if (!kind || !title) {
|
|
327
|
+
return text("Creating an entry requires both `kind` and `title` (pass entryId to update an existing one).");
|
|
328
|
+
}
|
|
329
|
+
const entry = await client.createBlueprintEntry(workspaceId, blueprintId, {
|
|
330
|
+
kind,
|
|
331
|
+
title,
|
|
332
|
+
text: entryText,
|
|
333
|
+
url,
|
|
334
|
+
blobRef,
|
|
335
|
+
content,
|
|
336
|
+
fileName,
|
|
337
|
+
contentType,
|
|
338
|
+
sizeBytes,
|
|
339
|
+
});
|
|
340
|
+
const enriching = entry.status === "pending" || entry.status === "processing";
|
|
341
|
+
return ok({
|
|
342
|
+
status: "created",
|
|
343
|
+
entry,
|
|
344
|
+
...(enriching
|
|
345
|
+
? {
|
|
346
|
+
note: "Entry is being enriched (status pending) — poll blueprint_list_entries until ready before blueprint_synthesize.",
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
return text(`Error writing blueprint entry: ${e.message}`);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
// ── blueprint_list_entries ─────────────────────────────────
|
|
356
|
+
server.tool("blueprint_list_entries", "List a blueprint's library entries (full rows: text, url, structured content, enrichment status/transcript/error), or one entry via entryId. status pending/processing means the enrichment worker is still on it — synthesize 409s until every entry is ready; status failed carries the reason in `error` (fix the source or delete the entry). With entryId: revisions=true adds the entry's immutable history (newest first), downloadUrl=true adds a signed read URL for the uploaded blob.", {
|
|
357
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
358
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
359
|
+
entryId: z.string().optional().describe("Fetch just this entry"),
|
|
360
|
+
revisions: z
|
|
361
|
+
.boolean()
|
|
362
|
+
.optional()
|
|
363
|
+
.describe("With entryId: include the entry's revision history (newest first)"),
|
|
364
|
+
snapshots: z
|
|
365
|
+
.boolean()
|
|
366
|
+
.optional()
|
|
367
|
+
.describe("With revisions=true: include each revision's full snapshot jsonb (default true; false = metadata only)"),
|
|
368
|
+
downloadUrl: z
|
|
369
|
+
.boolean()
|
|
370
|
+
.optional()
|
|
371
|
+
.describe("With entryId: include a signed read URL for the entry's uploaded blob"),
|
|
372
|
+
region: regionParam,
|
|
373
|
+
}, async ({ workspaceId, blueprintId, entryId, revisions, snapshots, downloadUrl, region }) => {
|
|
374
|
+
const r = resolveRegion(workspaceId, region);
|
|
375
|
+
try {
|
|
376
|
+
const client = getClient(r);
|
|
377
|
+
if (!entryId) {
|
|
378
|
+
const entries = await client.listBlueprintEntries(workspaceId, blueprintId);
|
|
379
|
+
const pending = entries.filter((e) => e.status === "pending" || e.status === "processing").length;
|
|
380
|
+
const failed = entries.filter((e) => e.status === "failed").length;
|
|
381
|
+
return ok({
|
|
382
|
+
entries,
|
|
383
|
+
counts: { total: entries.length, pending, failed },
|
|
384
|
+
...(pending
|
|
385
|
+
? { note: `${pending} entr${pending === 1 ? "y is" : "ies are"} still enriching — wait before blueprint_synthesize.` }
|
|
386
|
+
: {}),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const entry = await client.getBlueprintEntry(workspaceId, blueprintId, entryId);
|
|
390
|
+
const result = { entry };
|
|
391
|
+
if (revisions) {
|
|
392
|
+
result.revisions = await client.listBlueprintEntryRevisions(workspaceId, blueprintId, entryId, { snapshots });
|
|
393
|
+
}
|
|
394
|
+
if (downloadUrl) {
|
|
395
|
+
try {
|
|
396
|
+
const dl = await client.createBlueprintEntryDownloadUrl(workspaceId, blueprintId, entryId);
|
|
397
|
+
result.downloadUrl = dl.downloadUrl;
|
|
398
|
+
}
|
|
399
|
+
catch (e) {
|
|
400
|
+
result.downloadUrlError = e.message;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return ok(result);
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
return text(`Error listing blueprint entries: ${e.message}`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
// ── blueprint_entry_upload_url ─────────────────────────────
|
|
410
|
+
server.tool("blueprint_entry_upload_url", "Get a short-lived signed upload URL for a file that will become a library entry. Handshake: (1) this tool -> { uploadUrl, blobRef, expiresOn }; (2) PUT the bytes to uploadUrl (plain PUT with the file's Content-Type; no extra headers needed — works from anywhere, including FROM INSIDE A VM via vm_execute_script with Python requests.put, the walkthrough screenshot path); (3) blueprint_add_entry with the returned blobRef. 409 means blob storage isn't configured on the service.", {
|
|
411
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
412
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
413
|
+
fileName: z.string().describe("Original file name, e.g. 'step-3-invoice-screen.png'"),
|
|
414
|
+
region: regionParam,
|
|
415
|
+
}, async ({ workspaceId, blueprintId, fileName, region }) => {
|
|
416
|
+
const r = resolveRegion(workspaceId, region);
|
|
417
|
+
try {
|
|
418
|
+
const result = await getClient(r).createBlueprintEntryUploadUrl(workspaceId, blueprintId, fileName);
|
|
419
|
+
return ok({
|
|
420
|
+
...result,
|
|
421
|
+
nextActions: [
|
|
422
|
+
"PUT the file bytes to uploadUrl (plain PUT with the file's Content-Type) before expiresOn.",
|
|
423
|
+
`Then blueprint_add_entry blueprintId=${blueprintId} with blobRef='${result.blobRef}' (kind screenshot/doc/video as appropriate).`,
|
|
424
|
+
],
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
if (statusOf(e) === 409) {
|
|
429
|
+
return ok({
|
|
430
|
+
status: "blocked",
|
|
431
|
+
blocked: {
|
|
432
|
+
reason: "blob_storage_unconfigured",
|
|
433
|
+
retryable: false,
|
|
434
|
+
message: e.message,
|
|
435
|
+
},
|
|
436
|
+
nextActions: [
|
|
437
|
+
"Blob storage is not configured on this middleware-service deployment — use url/text entries instead, or escalate to configure JOB_ARTIFACTS_BUCKET and GCS signing credentials.",
|
|
438
|
+
],
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return text(`Error creating upload URL: ${e.message}`);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// ── blueprint_synthesize ───────────────────────────────────
|
|
445
|
+
server.tool("blueprint_synthesize", "Synthesize a spec proposal from the library (LLM). Produces ONE pending proposal for review — it is NEVER applied silently; blueprint_apply_proposal is the gate. Call after feeding entries (state no_spec) or after the library moved past the spec (state spec_stale) — and answer ALL open questions FIRST so one synthesis folds every answer in (don't loop answer->synthesize per question). Synchronous and can take a while on large libraries; on timeout, poll blueprint_get — the proposal may have landed anyway. Blocked responses are structured: blocked.reason = proposal_pending (review or pass replace=true) | entries_enriching (poll blueprint_list_entries) | llm_unconfigured (escalate) | library_empty (blueprint_add_entry first).", {
|
|
446
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
447
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
448
|
+
instructions: z
|
|
449
|
+
.string()
|
|
450
|
+
.optional()
|
|
451
|
+
.describe("Steering for this run, e.g. 'focus on the new refund SOP'"),
|
|
452
|
+
replace: z
|
|
453
|
+
.boolean()
|
|
454
|
+
.optional()
|
|
455
|
+
.describe("Replace a pending proposal instead of blocking on it"),
|
|
456
|
+
region: regionParam,
|
|
457
|
+
}, async ({ workspaceId, blueprintId, instructions, replace, region }) => {
|
|
458
|
+
const r = resolveRegion(workspaceId, region);
|
|
459
|
+
try {
|
|
460
|
+
const blueprint = await getClient(r).synthesizeBlueprint(workspaceId, blueprintId, { replace, instructions });
|
|
461
|
+
const proposal = blueprint.pendingProposal;
|
|
462
|
+
return ok({
|
|
463
|
+
status: "proposal_ready",
|
|
464
|
+
proposal,
|
|
465
|
+
nextActions: proposal
|
|
466
|
+
? [
|
|
467
|
+
`Review the proposal (summary: ${JSON.stringify(proposal.summary)}), then blueprint_apply_proposal blueprintId=${blueprintId} baseVersion=${proposal.baseVersion} proposalBasedOnSeq=${proposal.basedOnSeq} — or blueprint_reject_proposal to discard.`,
|
|
468
|
+
]
|
|
469
|
+
: [
|
|
470
|
+
`Synthesis returned no pending proposal — blueprint_get blueprintId=${blueprintId} to inspect.`,
|
|
471
|
+
],
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
const block = classifySynthesizeBlock(statusOf(e), e.message, codeOf(e));
|
|
476
|
+
if (block) {
|
|
477
|
+
const nextActions = [];
|
|
478
|
+
if (block.reason === "proposal_pending") {
|
|
479
|
+
nextActions.push(`A proposal is already pending — review it (blueprint_get_proposal blueprintId=${blueprintId}) and apply/reject, or re-run with replace=true to overwrite it.`);
|
|
480
|
+
}
|
|
481
|
+
else if (block.reason === "entries_enriching") {
|
|
482
|
+
nextActions.push(`Entries are still enriching — poll blueprint_list_entries blueprintId=${blueprintId} until counts.pending is 0, then retry.`);
|
|
483
|
+
}
|
|
484
|
+
else if (block.reason === "llm_unconfigured") {
|
|
485
|
+
nextActions.push("The service has no synthesis LLM configured (MINICOR_LLMS_API_KEY) — escalate to an operator; this is not retryable from here.");
|
|
486
|
+
}
|
|
487
|
+
else if (block.reason === "library_empty") {
|
|
488
|
+
nextActions.push(`The library is empty — blueprint_add_entry blueprintId=${blueprintId} with SOPs/notes/links first.`);
|
|
489
|
+
}
|
|
490
|
+
else if ([
|
|
491
|
+
"llm_gateway_failed",
|
|
492
|
+
"synthesis_unparseable",
|
|
493
|
+
"synthesis_missing_patch",
|
|
494
|
+
"patch_shape_invalid",
|
|
495
|
+
].includes(block.reason)) {
|
|
496
|
+
nextActions.push(`The synthesis LLM leg failed (${block.reason}) — retry blueprint_synthesize blueprintId=${blueprintId} once; escalate to an operator if it fails again.`);
|
|
497
|
+
}
|
|
498
|
+
return ok({
|
|
499
|
+
status: "blocked",
|
|
500
|
+
blocked: { ...block, message: e.message },
|
|
501
|
+
nextActions,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return text(`Error synthesizing blueprint: ${e.message}`);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
// ── blueprint_get_proposal ─────────────────────────────────
|
|
508
|
+
server.tool("blueprint_get_proposal", "Read the pending synthesis proposal for review: the section-level patch, regenerated SOP, one-paragraph summary, and per-claim citations (entryId + provenance extracted|inferred|ambiguous — scrutinize 'inferred' and 'ambiguous' claims before applying). Returns the exact apply coordinates: blueprint_apply_proposal requires this proposal's baseVersion AND basedOnSeq, so a concurrent resynthesize can never swap a different proposal under your review.", {
|
|
509
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
510
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
511
|
+
region: regionParam,
|
|
512
|
+
}, async ({ workspaceId, blueprintId, region }) => {
|
|
513
|
+
const r = resolveRegion(workspaceId, region);
|
|
514
|
+
try {
|
|
515
|
+
const blueprint = await getClient(r).getBlueprint(workspaceId, blueprintId);
|
|
516
|
+
const proposal = blueprint.pendingProposal;
|
|
517
|
+
if (!proposal) {
|
|
518
|
+
return ok({
|
|
519
|
+
status: "no_pending_proposal",
|
|
520
|
+
nextActions: [
|
|
521
|
+
`No proposal is pending — blueprint_synthesize blueprintId=${blueprintId} to produce one (state ${blueprint.state}).`,
|
|
522
|
+
],
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return ok({
|
|
526
|
+
proposal,
|
|
527
|
+
applyWith: {
|
|
528
|
+
baseVersion: proposal.baseVersion,
|
|
529
|
+
proposalBasedOnSeq: proposal.basedOnSeq,
|
|
530
|
+
},
|
|
531
|
+
nextActions: [
|
|
532
|
+
`Apply: blueprint_apply_proposal blueprintId=${blueprintId} baseVersion=${proposal.baseVersion} proposalBasedOnSeq=${proposal.basedOnSeq}. Reject: blueprint_reject_proposal blueprintId=${blueprintId}.`,
|
|
533
|
+
],
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
return text(`Error fetching proposal: ${e.message}`);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
// ── blueprint_apply_proposal ───────────────────────────────
|
|
541
|
+
server.tool("blueprint_apply_proposal", "Apply the pending proposal you reviewed: patch + SOP land as one validated, version-snapshotted document update, and specBasedOnSeq is stamped. Requires the reviewed proposal's coordinates (baseVersion + proposalBasedOnSeq from blueprint_get_proposal): the tool sends proposalBasedOnSeq with the apply so the service atomically REFUSES (409 proposal_mismatch) if the pending proposal is no longer the one you reviewed (a concurrent resynthesize swapped it); it also re-reads and checks before POSTing as defense in depth. After a successful apply the spec is ahead of the job (job_stale) — blueprint_build is the next verb, after answering any openQuestions the new spec carries.", {
|
|
542
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
543
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
544
|
+
baseVersion: z
|
|
545
|
+
.number()
|
|
546
|
+
.describe("The reviewed proposal's baseVersion (from blueprint_get_proposal.applyWith)"),
|
|
547
|
+
proposalBasedOnSeq: z
|
|
548
|
+
.number()
|
|
549
|
+
.describe("The reviewed proposal's basedOnSeq (from blueprint_get_proposal.applyWith)"),
|
|
550
|
+
region: regionParam,
|
|
551
|
+
}, async ({ workspaceId, blueprintId, baseVersion, proposalBasedOnSeq, region }) => {
|
|
552
|
+
const r = resolveRegion(workspaceId, region);
|
|
553
|
+
try {
|
|
554
|
+
const client = getClient(r);
|
|
555
|
+
// Pre-flight re-read: newer services validate basedOnSeq atomically
|
|
556
|
+
// in the apply request (409 proposal_mismatch), but OLDER deployments
|
|
557
|
+
// ignore the body — this check is their only swap protection, and it
|
|
558
|
+
// avoids a mutating POST when the mismatch is already visible.
|
|
559
|
+
const current = await client.getBlueprint(workspaceId, blueprintId);
|
|
560
|
+
const pending = current.pendingProposal;
|
|
561
|
+
if (!pending) {
|
|
562
|
+
return ok({
|
|
563
|
+
status: "no_pending_proposal",
|
|
564
|
+
note: "No proposal is pending — another writer applied or rejected it. Treat as convergence.",
|
|
565
|
+
nextActions: [
|
|
566
|
+
`blueprint_get blueprintId=${blueprintId} (or resolve_blueprint_state) to see the current state.`,
|
|
567
|
+
],
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (pending.baseVersion !== baseVersion ||
|
|
571
|
+
pending.basedOnSeq !== proposalBasedOnSeq) {
|
|
572
|
+
return ok({
|
|
573
|
+
status: "blocked",
|
|
574
|
+
blocked: {
|
|
575
|
+
reason: "proposal_swapped",
|
|
576
|
+
retryable: true,
|
|
577
|
+
message: `The pending proposal (baseVersion ${pending.baseVersion}, basedOnSeq ${pending.basedOnSeq}) is not the one you reviewed (baseVersion ${baseVersion}, basedOnSeq ${proposalBasedOnSeq}) — a concurrent resynthesize replaced it.`,
|
|
578
|
+
},
|
|
579
|
+
nextActions: [
|
|
580
|
+
`Re-review: blueprint_get_proposal blueprintId=${blueprintId}, then apply with the NEW coordinates.`,
|
|
581
|
+
],
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
const { bp: blueprint, stateReadError } = await withComputedState(client, workspaceId,
|
|
585
|
+
// The reviewed proposal's identity rides along: a service-side
|
|
586
|
+
// basedOnSeq mismatch 409s (proposal_mismatch) instead of applying
|
|
587
|
+
// a proposal that was swapped in after the pre-flight check above.
|
|
588
|
+
await client.applyBlueprintProposal(workspaceId, blueprintId, {
|
|
589
|
+
basedOnSeq: proposalBasedOnSeq,
|
|
590
|
+
}));
|
|
591
|
+
const openQuestions = blueprint.document?.openQuestions ?? [];
|
|
592
|
+
return ok({
|
|
593
|
+
status: "applied",
|
|
594
|
+
headVersion: blueprint.headVersion ?? null,
|
|
595
|
+
state: blueprint.state ?? null,
|
|
596
|
+
openQuestions,
|
|
597
|
+
...stateReadWarning(blueprintId, stateReadError),
|
|
598
|
+
nextActions: openQuestions.length
|
|
599
|
+
? [
|
|
600
|
+
`The applied spec carries ${openQuestions.length} open question(s) — answer each with blueprint_answer_question, then blueprint_synthesize ONCE to fold the answers, then blueprint_build.`,
|
|
601
|
+
]
|
|
602
|
+
: [
|
|
603
|
+
`Spec applied (v${blueprint.headVersion}) — blueprint_build blueprintId=${blueprintId} to sync it into the job.`,
|
|
604
|
+
],
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
if (statusOf(e) === 409) {
|
|
609
|
+
// Code-first, message fallback for older deployments — remove the
|
|
610
|
+
// string match once middleware-service faiz/synthesis-shape-retry
|
|
611
|
+
// is deployed everywhere.
|
|
612
|
+
const swapped = codeOf(e) === "proposal_mismatch" ||
|
|
613
|
+
(codeOf(e) === undefined && /not the one you reviewed/i.test(e.message));
|
|
614
|
+
if (swapped) {
|
|
615
|
+
return ok({
|
|
616
|
+
status: "blocked",
|
|
617
|
+
blocked: {
|
|
618
|
+
reason: "proposal_swapped",
|
|
619
|
+
retryable: true,
|
|
620
|
+
message: e.message,
|
|
621
|
+
},
|
|
622
|
+
nextActions: [
|
|
623
|
+
`The pending proposal was replaced after your review (concurrent resynthesize) — re-review it: blueprint_get_proposal blueprintId=${blueprintId}, then apply DELIBERATELY with the NEW baseVersion + proposalBasedOnSeq.`,
|
|
624
|
+
],
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return ok({
|
|
628
|
+
status: "blocked",
|
|
629
|
+
blocked: { reason: "spec_moved", retryable: true, message: e.message },
|
|
630
|
+
nextActions: [
|
|
631
|
+
`The spec changed since this proposal was synthesized — blueprint_reject_proposal blueprintId=${blueprintId}, then blueprint_synthesize again.`,
|
|
632
|
+
],
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (statusOf(e) === 404) {
|
|
636
|
+
// "No pending proposal…" is convergence; "Blueprint <id> not
|
|
637
|
+
// found" is a caller error — don't shape it as success. Match the
|
|
638
|
+
// body message, not the request path (which contains "proposal").
|
|
639
|
+
if (!/no pending proposal/i.test(e.message)) {
|
|
640
|
+
return text(`Blueprint not found: ${e.message}`);
|
|
641
|
+
}
|
|
642
|
+
return ok({
|
|
643
|
+
status: "no_pending_proposal",
|
|
644
|
+
note: "Apply raced another writer — the proposal is gone. Treat as convergence.",
|
|
645
|
+
nextActions: [`blueprint_get blueprintId=${blueprintId} for the current state.`],
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return text(`Error applying proposal: ${e.message}`);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
// ── blueprint_reject_proposal ──────────────────────────────
|
|
652
|
+
server.tool("blueprint_reject_proposal", "Reject (discard) the pending proposal — use when the synthesis got it wrong or the spec moved underneath it. A 404 ('no pending proposal') is success-shaped: someone else already applied or rejected it, so the outcome you wanted (no pending proposal blocking synthesize) holds either way. Follow with blueprint_synthesize (optionally with steering `instructions`) to produce a better one.", {
|
|
653
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
654
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
655
|
+
region: regionParam,
|
|
656
|
+
}, async ({ workspaceId, blueprintId, region }) => {
|
|
657
|
+
const r = resolveRegion(workspaceId, region);
|
|
658
|
+
try {
|
|
659
|
+
const client = getClient(r);
|
|
660
|
+
const { bp: blueprint, stateReadError } = await withComputedState(client, workspaceId, await client.rejectBlueprintProposal(workspaceId, blueprintId));
|
|
661
|
+
return ok({
|
|
662
|
+
status: "rejected",
|
|
663
|
+
state: blueprint.state ?? null,
|
|
664
|
+
...stateReadWarning(blueprintId, stateReadError),
|
|
665
|
+
nextActions: [
|
|
666
|
+
`blueprint_synthesize blueprintId=${blueprintId} (pass instructions to steer the retry).`,
|
|
667
|
+
],
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (e) {
|
|
671
|
+
if (statusOf(e) === 404) {
|
|
672
|
+
// Same discrimination as apply: a missing blueprint is not
|
|
673
|
+
// success-shaped convergence.
|
|
674
|
+
if (!/no pending proposal/i.test(e.message)) {
|
|
675
|
+
return text(`Blueprint not found: ${e.message}`);
|
|
676
|
+
}
|
|
677
|
+
return ok({
|
|
678
|
+
status: "already_gone",
|
|
679
|
+
note: "No proposal was pending — another writer already applied or rejected it. Success-shaped: nothing is blocking synthesize.",
|
|
680
|
+
nextActions: [`blueprint_get blueprintId=${blueprintId} for the current state.`],
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
return text(`Error rejecting proposal: ${e.message}`);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
// ── blueprint_answer_question ──────────────────────────────
|
|
687
|
+
server.tool("blueprint_answer_question", "Answer ONE open synthesis question atomically: the service removes it from document.openQuestions, stores the answer as a note entry, and snapshots a version in a single transaction. `question` must match the openQuestions text EXACTLY (from blueprint_get / resolve_blueprint_state). A 404 means another writer already answered it — that is CONVERGENCE, not failure; move to the next question. Builder questions (build run needs_input) are NOT answered here — use answer_build_question. DRIFT NOTE: each answer bumps librarySeq, so the spec goes spec_stale — answer ALL open questions first, then run blueprint_synthesize ONCE; never loop answer->synthesize per question.", {
|
|
688
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
689
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
690
|
+
question: z
|
|
691
|
+
.string()
|
|
692
|
+
.describe("The open question being answered — EXACT text from document.openQuestions"),
|
|
693
|
+
answer: z.string().describe("The answer (stored as a note library entry)"),
|
|
694
|
+
region: regionParam,
|
|
695
|
+
}, async ({ workspaceId, blueprintId, question, answer, region }) => {
|
|
696
|
+
const r = resolveRegion(workspaceId, region);
|
|
697
|
+
const client = getClient(r);
|
|
698
|
+
try {
|
|
699
|
+
const blueprint = await client.answerBlueprintQuestion(workspaceId, blueprintId, question, answer);
|
|
700
|
+
const remaining = blueprint.document?.openQuestions ?? [];
|
|
701
|
+
return ok({
|
|
702
|
+
status: "answered",
|
|
703
|
+
remainingOpenQuestions: remaining,
|
|
704
|
+
driftNote: "The answer was stored as a note entry, so the spec is now spec_stale. Answer the remaining questions first, then blueprint_synthesize ONCE to fold every answer in.",
|
|
705
|
+
nextActions: remaining.length
|
|
706
|
+
? remaining.map((q) => `blueprint_answer_question blueprintId=${blueprintId} question=${JSON.stringify(q)}`)
|
|
707
|
+
: [
|
|
708
|
+
`All questions answered — blueprint_synthesize blueprintId=${blueprintId} to fold the answers into the spec.`,
|
|
709
|
+
],
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
if (statusOf(e) === 404) {
|
|
714
|
+
// Deploy skew: an older service without the atomic endpoint 404s the
|
|
715
|
+
// ROUTE itself ("Cannot POST ..."). Fall back to the patch-based
|
|
716
|
+
// two-step (note entry + openQuestions patch with baseVersion CAS).
|
|
717
|
+
if (/cannot post/i.test(e.message)) {
|
|
718
|
+
return answerViaPatchFallback(client, workspaceId, blueprintId, question, answer);
|
|
719
|
+
}
|
|
720
|
+
// The service 404s BOTH a missing blueprint ("Blueprint <id> not
|
|
721
|
+
// found") and a not-open question ("Open question not found…") —
|
|
722
|
+
// only the latter is convergence. Discriminate on the body message
|
|
723
|
+
// (not the request path, which always contains "questions") until
|
|
724
|
+
// a structured code ships for these.
|
|
725
|
+
if (!/question (is )?not (open|found)|open question not found/i.test(e.message)) {
|
|
726
|
+
return text(`Blueprint not found: ${e.message}`);
|
|
727
|
+
}
|
|
728
|
+
return ok({
|
|
729
|
+
status: "already_answered",
|
|
730
|
+
note: "The question is not open — another writer answered it (or it never existed with this exact text). Treat as CONVERGENCE, not failure.",
|
|
731
|
+
nextActions: [
|
|
732
|
+
`blueprint_get blueprintId=${blueprintId} for the current openQuestions (match text exactly).`,
|
|
733
|
+
],
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return text(`Error answering question: ${e.message}`);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
/**
|
|
740
|
+
* Deploy-skew fallback for blueprint_answer_question: older services lack
|
|
741
|
+
* POST /:id/questions/answer. Emulate it non-atomically — note entry first
|
|
742
|
+
* (evidence preserved even if the patch loses a race), then a baseVersion-
|
|
743
|
+
* guarded openQuestions patch.
|
|
744
|
+
*/
|
|
745
|
+
async function answerViaPatchFallback(client, workspaceId, blueprintId, question, answer) {
|
|
746
|
+
try {
|
|
747
|
+
const blueprint = await client.getBlueprint(workspaceId, blueprintId);
|
|
748
|
+
const openQuestions = blueprint.document?.openQuestions ?? [];
|
|
749
|
+
if (!openQuestions.includes(question)) {
|
|
750
|
+
return ok({
|
|
751
|
+
status: "already_answered",
|
|
752
|
+
note: "The question is not in openQuestions — treat as convergence.",
|
|
753
|
+
nextActions: [`blueprint_get blueprintId=${blueprintId} for the current openQuestions.`],
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
const entry = await client.createBlueprintEntry(workspaceId, blueprintId, {
|
|
757
|
+
kind: "note",
|
|
758
|
+
title: `Answer: ${question.slice(0, 120)}`,
|
|
759
|
+
text: answer,
|
|
760
|
+
content: { answersQuestion: question },
|
|
761
|
+
});
|
|
762
|
+
try {
|
|
763
|
+
await client.updateBlueprint(workspaceId, blueprintId, {
|
|
764
|
+
patch: { openQuestions: openQuestions.filter((q) => q !== question) },
|
|
765
|
+
note: `agent: answered "${question.slice(0, 80)}"`,
|
|
766
|
+
baseVersion: blueprint.headVersion,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
catch (patchErr) {
|
|
770
|
+
return ok({
|
|
771
|
+
status: "partial",
|
|
772
|
+
note: `Answer stored as note entry ${entry.id}, but removing the question from openQuestions failed (${patchErr.message}). Legacy-service fallback path (non-atomic).`,
|
|
773
|
+
nextActions: [
|
|
774
|
+
`blueprint_get blueprintId=${blueprintId}, then blueprint_update with patch.openQuestions minus the answered question and the fresh baseVersion.`,
|
|
775
|
+
],
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
return ok({
|
|
779
|
+
status: "answered",
|
|
780
|
+
note: "Answered via the legacy patch fallback (this service lacks the atomic questions/answer endpoint).",
|
|
781
|
+
driftNote: "The answer note bumped librarySeq — answer remaining questions, then blueprint_synthesize ONCE.",
|
|
782
|
+
remainingOpenQuestions: openQuestions.filter((q) => q !== question),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
catch (e) {
|
|
786
|
+
return text(`Error answering question (fallback path): ${e.message}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// ── blueprint_build ────────────────────────────────────────
|
|
790
|
+
server.tool("blueprint_build", "Sync the spec into its Job (the primary build verb): validates the spec, ensures the router + route exist, upserts judge test cases from exampleCases (content-hash idempotent), attaches spec + library + resolved resource refs as the builder artifact, and queues/folds into a build run. RETURNS THE HANDOFF ARTIFACT: { middlewareId, routeId, buildId, testCaseIds } — the coordinates the whole jobs toolset (get_build_status, run_tests, resolve_job_state, publish_job) takes over with. Precondition enforced here: if the linked build run is needs_input, this returns blocked instead of syncing (the service would fold cases WITHOUT re-queueing the builder) — answer_build_question first. A 400 means the spec isn't syncable and carries the problem list.", {
|
|
791
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
792
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
793
|
+
instructions: z
|
|
794
|
+
.string()
|
|
795
|
+
.optional()
|
|
796
|
+
.describe('Operator instructions to the job builder for this build, e.g. "reuse workflow 2136 for the export step" — the builder must follow them.'),
|
|
797
|
+
region: regionParam,
|
|
798
|
+
}, async ({ workspaceId, blueprintId, instructions, region }) => {
|
|
799
|
+
const r = resolveRegion(workspaceId, region);
|
|
800
|
+
try {
|
|
801
|
+
const client = getClient(r);
|
|
802
|
+
const current = await client.getBlueprint(workspaceId, blueprintId);
|
|
803
|
+
if (current.buildStatus === "needs_input" && current.middlewareId && current.routeId) {
|
|
804
|
+
return ok({
|
|
805
|
+
status: "blocked",
|
|
806
|
+
blocked: {
|
|
807
|
+
reason: "build_needs_input",
|
|
808
|
+
retryable: true,
|
|
809
|
+
message: `Build ${current.buildId} is waiting on answers — syncing now would fold cases without re-queueing the builder.`,
|
|
810
|
+
},
|
|
811
|
+
nextActions: [
|
|
812
|
+
`Answer the builder's questions first: get_build_status workspaceId=${workspaceId} middlewareId=${current.middlewareId} routeId=${current.routeId} buildId=${current.buildId}, then answer_build_question per open question. The build re-queues automatically when none remain.`,
|
|
813
|
+
],
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
const result = await client.syncBlueprint(workspaceId, blueprintId, instructions);
|
|
817
|
+
return ok({
|
|
818
|
+
status: "build_queued",
|
|
819
|
+
...result,
|
|
820
|
+
nextActions: [
|
|
821
|
+
`Poll the build: blueprint_build_status blueprintId=${blueprintId} (or get_build_status workspaceId=${workspaceId} middlewareId=${result.middlewareId} routeId=${result.routeId} buildId=${result.buildId}).`,
|
|
822
|
+
`When the build is green: run_tests workspaceId=${workspaceId} middlewareId=${result.middlewareId} routeId=${result.routeId} variant=draft, then publish_job. resolve_job_state gives the job-side loop.`,
|
|
823
|
+
],
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
catch (e) {
|
|
827
|
+
if (statusOf(e) === 400) {
|
|
828
|
+
return ok({
|
|
829
|
+
status: "blocked",
|
|
830
|
+
blocked: { reason: "spec_not_syncable", retryable: true, message: e.message },
|
|
831
|
+
nextActions: [
|
|
832
|
+
`Fix the listed problems in the spec (blueprint_update with baseVersion, or re-run the synthesize->apply loop), then retry blueprint_build blueprintId=${blueprintId}.`,
|
|
833
|
+
],
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
return text(`Error building blueprint: ${e.message}`);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
// ── blueprint_build_status ─────────────────────────────────
|
|
840
|
+
server.tool("blueprint_build_status", "Blueprint-side view of the linked build: composes the blueprint's state with the stamped build run (status queued|running|green|failed|needs_input, summary, open questions). needs_input questions come back with answer_build_question coordinates prefilled — try to answer them from the library FIRST (blueprint_list_entries; cite what you find) before escalating to a human. For the full job-side picture (test cases, executions), use resolve_job_state with the same coordinates.", {
|
|
841
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
842
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
843
|
+
region: regionParam,
|
|
844
|
+
}, async ({ workspaceId, blueprintId, region }) => {
|
|
845
|
+
const r = resolveRegion(workspaceId, region);
|
|
846
|
+
try {
|
|
847
|
+
const client = getClient(r);
|
|
848
|
+
const blueprint = await client.getBlueprint(workspaceId, blueprintId);
|
|
849
|
+
if (!blueprint.buildId || !blueprint.middlewareId || !blueprint.routeId) {
|
|
850
|
+
return ok({
|
|
851
|
+
state: blueprint.state,
|
|
852
|
+
build: null,
|
|
853
|
+
nextActions: [
|
|
854
|
+
`No build has been stamped yet — blueprint_build blueprintId=${blueprintId} runs the first sync (resolve_blueprint_state to check the spec is ready).`,
|
|
855
|
+
],
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
let build;
|
|
859
|
+
let buildFetchError;
|
|
860
|
+
try {
|
|
861
|
+
build = await client.getBuildRun(workspaceId, blueprint.middlewareId, blueprint.routeId, blueprint.buildId);
|
|
862
|
+
}
|
|
863
|
+
catch (e) {
|
|
864
|
+
buildFetchError = String(e?.message ?? e);
|
|
865
|
+
}
|
|
866
|
+
const openQuestions = (build?.questions ?? []).filter((q) => !q.answer);
|
|
867
|
+
const nextActions = [];
|
|
868
|
+
if (buildFetchError) {
|
|
869
|
+
nextActions.push(`The stamped build ${blueprint.buildId} could not be fetched (${buildFetchError}) — this is a READ failure, not 'no build'. Retry blueprint_build_status blueprintId=${blueprintId}, or get_build_status workspaceId=${workspaceId} middlewareId=${blueprint.middlewareId} routeId=${blueprint.routeId} buildId=${blueprint.buildId}; escalate if it persists.`);
|
|
870
|
+
}
|
|
871
|
+
if (build?.status === "needs_input") {
|
|
872
|
+
for (const q of openQuestions) {
|
|
873
|
+
nextActions.push(`answer_build_question workspaceId=${workspaceId} middlewareId=${blueprint.middlewareId} routeId=${blueprint.routeId} buildId=${build.id} questionId=${q.id} — "${q.text}" (search the library first: blueprint_list_entries blueprintId=${blueprintId}).`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
else if (build?.status === "failed") {
|
|
877
|
+
nextActions.push(`Build failed — read build.summary, fix (spec via blueprint_update / workflows via the jobs loop), then blueprint_build blueprintId=${blueprintId} to re-sync.`);
|
|
878
|
+
}
|
|
879
|
+
else if (build?.status === "green") {
|
|
880
|
+
nextActions.push(`Build is green — run_tests workspaceId=${workspaceId} middlewareId=${blueprint.middlewareId} routeId=${blueprint.routeId} variant=draft, then publish_job.`);
|
|
881
|
+
}
|
|
882
|
+
else if (build) {
|
|
883
|
+
nextActions.push(`Build is ${build.status} — poll blueprint_build_status again, or get_build_status buildId=${build.id} for the raw run.`);
|
|
884
|
+
}
|
|
885
|
+
return ok({
|
|
886
|
+
state: blueprint.state,
|
|
887
|
+
link: {
|
|
888
|
+
middlewareId: blueprint.middlewareId,
|
|
889
|
+
routeId: blueprint.routeId,
|
|
890
|
+
buildId: blueprint.buildId,
|
|
891
|
+
},
|
|
892
|
+
build: build ?? null,
|
|
893
|
+
...(buildFetchError ? { buildFetchError } : {}),
|
|
894
|
+
openQuestions,
|
|
895
|
+
nextActions,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
catch (e) {
|
|
899
|
+
return text(`Error fetching build status: ${e.message}`);
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
// ── blueprint_import_cases ─────────────────────────────────
|
|
903
|
+
server.tool("blueprint_import_cases", "Adopt the linked job's test cases into the spec's exampleCases (upsert by name) — the job_ahead FOLD-BACK arrow. Use when the job was edited directly (update_job / teach_job / Dev-Mode clones tripped reverse drift) and the direct edits should become spec truth, or right after blueprint_create linked an existing job. After importing, the spec is ahead of the job (job_stale): review it, then blueprint_build re-syncs and re-stamps the drift baseline. The alternative job_ahead exit is blueprint_build alone — rebuild from the spec and OVERWRITE the direct edits.", {
|
|
904
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
905
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
906
|
+
region: regionParam,
|
|
907
|
+
}, async ({ workspaceId, blueprintId, region }) => {
|
|
908
|
+
const r = resolveRegion(workspaceId, region);
|
|
909
|
+
try {
|
|
910
|
+
const blueprint = await getClient(r).importBlueprintCases(workspaceId, blueprintId);
|
|
911
|
+
return ok({
|
|
912
|
+
status: "imported",
|
|
913
|
+
exampleCases: blueprint.document?.exampleCases ?? [],
|
|
914
|
+
nextActions: [
|
|
915
|
+
`Review the imported exampleCases (blueprint_get blueprintId=${blueprintId}), then blueprint_build to re-sync and clear job_ahead.`,
|
|
916
|
+
],
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
if (statusOf(e) === 400) {
|
|
921
|
+
return ok({
|
|
922
|
+
status: "blocked",
|
|
923
|
+
blocked: { reason: "nothing_to_import", retryable: false, message: e.message },
|
|
924
|
+
nextActions: [
|
|
925
|
+
"The blueprint isn't linked to a job, or the job has no test cases — nothing to fold back.",
|
|
926
|
+
],
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return text(`Error importing cases: ${e.message}`);
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
// ── blueprint_list_versions ────────────────────────────────
|
|
933
|
+
server.tool("blueprint_list_versions", "List a blueprint's spec version snapshots (newest first) — every document change (apply, direct update, answered question) snapshots one, with its note. Pass version to fetch one full snapshot (e.g. to diff what a proposal changed, or to recover a document to re-author against after a CAS 409). Entry-level history lives on blueprint_list_entries revisions=true instead.", {
|
|
934
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
935
|
+
blueprintId: z.string().describe("Blueprint ID"),
|
|
936
|
+
version: z.number().optional().describe("Fetch one full version snapshot"),
|
|
937
|
+
region: regionParam,
|
|
938
|
+
}, async ({ workspaceId, blueprintId, version, region }) => {
|
|
939
|
+
const r = resolveRegion(workspaceId, region);
|
|
940
|
+
try {
|
|
941
|
+
const client = getClient(r);
|
|
942
|
+
if (version != null) {
|
|
943
|
+
const snapshot = await client.getBlueprintVersion(workspaceId, blueprintId, version);
|
|
944
|
+
return ok({ version: snapshot });
|
|
945
|
+
}
|
|
946
|
+
const versions = await client.listBlueprintVersions(workspaceId, blueprintId);
|
|
947
|
+
return ok({ versions });
|
|
948
|
+
}
|
|
949
|
+
catch (e) {
|
|
950
|
+
return text(`Error listing blueprint versions: ${e.message}`);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
// ── resolve_blueprint_state ────────────────────────────────
|
|
954
|
+
server.tool("resolve_blueprint_state", "The 'where am I in the blueprint loop?' call — START HERE when working a blueprint (the sibling of resolve_job_state, one layer up). One call fetches the blueprint (state + drift + proposal + open questions), its library entries (enrichment status), and the linked build run, then returns an UNFOLDED state (no_context | enriching | no_spec | proposal_pending | needs_answers | building | needs_input | build_failed | job_ahead | spec_stale | job_stale | in_sync — every state maps to one verb), blockers[] (enrichment_failed / builder_stalled / question_needs_human — the escalation surface), and ordered nextActions with the concrete tool calls prefilled. The loop it drives: add entries -> synthesize -> answer questions -> apply proposal -> blueprint_build -> the jobs loop (resolve_job_state) takes over.", {
|
|
955
|
+
workspaceId: z.number().describe("Workspace ID"),
|
|
956
|
+
blueprintId: z.string().describe("Blueprint ID to resolve"),
|
|
957
|
+
region: regionParam,
|
|
958
|
+
}, async ({ workspaceId, blueprintId, region }) => {
|
|
959
|
+
const r = resolveRegion(workspaceId, region);
|
|
960
|
+
try {
|
|
961
|
+
const client = getClient(r);
|
|
962
|
+
const [bp, entriesRead] = await Promise.all([
|
|
963
|
+
client.getBlueprint(workspaceId, blueprintId),
|
|
964
|
+
client.listBlueprintEntries(workspaceId, blueprintId).then((entries) => ({ entries }), (e) => ({ entriesError: String(e?.message ?? e) })),
|
|
965
|
+
]);
|
|
966
|
+
// The decision table needs the library to be TRUSTWORTHY: a failed
|
|
967
|
+
// entries read must not masquerade as an empty library (no_context /
|
|
968
|
+
// no_spec would direct the agent to feed or synthesize against a
|
|
969
|
+
// library it never saw). Degrade explicitly instead.
|
|
970
|
+
if ("entriesError" in entriesRead) {
|
|
971
|
+
return ok({
|
|
972
|
+
status: "degraded",
|
|
973
|
+
backendState: bp.state,
|
|
974
|
+
error: `Could not read the blueprint's library: ${entriesRead.entriesError}`,
|
|
975
|
+
nextActions: [
|
|
976
|
+
`The library read failed, so the unfolded state cannot be computed — retry resolve_blueprint_state blueprintId=${blueprintId} (blueprint_get gives the spec-side view meanwhile); escalate if it persists.`,
|
|
977
|
+
],
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
const entries = entriesRead.entries;
|
|
981
|
+
let build;
|
|
982
|
+
if (bp.buildId && bp.middlewareId && bp.routeId) {
|
|
983
|
+
try {
|
|
984
|
+
build = await client.getBuildRun(workspaceId, bp.middlewareId, bp.routeId, bp.buildId);
|
|
985
|
+
}
|
|
986
|
+
catch (buildErr) {
|
|
987
|
+
// Same trust rule as the entries read above: a failed build
|
|
988
|
+
// read must not masquerade as "no build" — needs_input
|
|
989
|
+
// questions and stall detection would silently vanish and the
|
|
990
|
+
// resolver could report in_sync with "nothing to do".
|
|
991
|
+
return ok({
|
|
992
|
+
status: "degraded",
|
|
993
|
+
backendState: bp.state,
|
|
994
|
+
error: `Could not read the linked build run ${bp.buildId}: ${String(buildErr?.message ?? buildErr)}`,
|
|
995
|
+
nextActions: [
|
|
996
|
+
`The linked build read failed, so the unfolded state cannot be computed — retry resolve_blueprint_state blueprintId=${blueprintId} (blueprint_get gives the spec-side view meanwhile); escalate if it persists.`,
|
|
997
|
+
],
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const pendingEntries = entries.filter((e) => e.status === "pending" || e.status === "processing");
|
|
1002
|
+
const failedEntries = entries.filter((e) => e.status === "failed");
|
|
1003
|
+
const openQuestions = bp.document?.openQuestions ?? [];
|
|
1004
|
+
const emptySpec = specIsEmpty(bp);
|
|
1005
|
+
const buildQuestions = (build?.questions ?? []).filter((q) => !q.answer);
|
|
1006
|
+
// Is the spec_stale drift ONLY answer-note entries? Then ONE
|
|
1007
|
+
// synthesize folds them — the dedupe flag that stops the
|
|
1008
|
+
// answer->synthesize->spec_stale loop.
|
|
1009
|
+
const entriesById = new Map(entries.map((e) => [e.id, e]));
|
|
1010
|
+
const answerNotesOnly = bp.drift.newEntries.length > 0 &&
|
|
1011
|
+
bp.drift.newEntries.every((d) => {
|
|
1012
|
+
const entry = entriesById.get(d.id);
|
|
1013
|
+
return (entry?.kind === "note" &&
|
|
1014
|
+
Boolean(entry.content?.answersQuestion));
|
|
1015
|
+
});
|
|
1016
|
+
// ── The decision table: fold backend + library facts into ONE state ──
|
|
1017
|
+
// Backend-loud states keep their precedence; never_synced/spec_stale
|
|
1018
|
+
// unfold into the pre-build sub-states.
|
|
1019
|
+
let state;
|
|
1020
|
+
if (bp.buildStatus === "needs_input")
|
|
1021
|
+
state = "needs_input";
|
|
1022
|
+
else if (bp.state === "building")
|
|
1023
|
+
state = "building";
|
|
1024
|
+
else if (bp.state === "build_failed")
|
|
1025
|
+
state = "build_failed";
|
|
1026
|
+
else if (bp.state === "job_ahead")
|
|
1027
|
+
state = "job_ahead";
|
|
1028
|
+
else if (bp.hasPendingProposal)
|
|
1029
|
+
state = "proposal_pending";
|
|
1030
|
+
else if (pendingEntries.length > 0)
|
|
1031
|
+
state = "enriching";
|
|
1032
|
+
else if (openQuestions.length > 0)
|
|
1033
|
+
state = "needs_answers";
|
|
1034
|
+
else if (entries.length === 0 && emptySpec)
|
|
1035
|
+
state = "no_context";
|
|
1036
|
+
else if (emptySpec)
|
|
1037
|
+
state = "no_spec";
|
|
1038
|
+
// The service folds UNLINKED blueprints to never_synced regardless of
|
|
1039
|
+
// drift, so spec_stale must come from the specStale boolean (library
|
|
1040
|
+
// moved past the spec) — synthesize-first beats build-now.
|
|
1041
|
+
else if (bp.specStale || bp.state === "spec_stale")
|
|
1042
|
+
state = "spec_stale";
|
|
1043
|
+
else if (bp.state === "job_stale" || bp.state === "never_synced")
|
|
1044
|
+
state = "job_stale";
|
|
1045
|
+
else
|
|
1046
|
+
state = "in_sync";
|
|
1047
|
+
// ── Blockers: the escalation surface ──
|
|
1048
|
+
const blockers = [];
|
|
1049
|
+
if (failedEntries.length > 0) {
|
|
1050
|
+
blockers.push({
|
|
1051
|
+
kind: "enrichment_failed",
|
|
1052
|
+
hint: "Enrichment failed for these entries — synthesis will proceed without their content. Fix the source (blueprint_add_entry entryId=<id> with a corrected url re-queues enrichment) or accept the gap.",
|
|
1053
|
+
entries: failedEntries.map((e) => ({
|
|
1054
|
+
id: e.id,
|
|
1055
|
+
title: e.title,
|
|
1056
|
+
error: e.error,
|
|
1057
|
+
})),
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
if (build?.status === "queued") {
|
|
1061
|
+
const queuedSince = Date.parse((build.startedAt ?? build.createdAt ?? ""));
|
|
1062
|
+
const queuedForMs = Number.isFinite(queuedSince)
|
|
1063
|
+
? Date.now() - queuedSince
|
|
1064
|
+
: undefined;
|
|
1065
|
+
if (queuedForMs !== undefined && queuedForMs > BUILDER_STALL_MS) {
|
|
1066
|
+
blockers.push({
|
|
1067
|
+
kind: "builder_stalled",
|
|
1068
|
+
queuedForMs,
|
|
1069
|
+
hint: `Build ${build.id} has been queued for ${Math.round(queuedForMs / 1000)}s — the builder may be down. Escalate, or cancel_build and drive the draft manually via the jobs loop.`,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (buildQuestions.length > 0) {
|
|
1074
|
+
blockers.push({
|
|
1075
|
+
kind: "question_needs_human",
|
|
1076
|
+
questions: buildQuestions.map((q) => ({ id: q.id, text: q.text })),
|
|
1077
|
+
hint: "Builder questions are YOURS to try first: read the library (blueprint_list_entries), cite what you find in answer_build_question. Escalate to a human only when the library genuinely lacks the answer — then checkpoint the blocker.",
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
// ── nextActions: the concrete next tool calls per state ──
|
|
1081
|
+
const nextActions = [];
|
|
1082
|
+
const linkCoords = bp.middlewareId && bp.routeId
|
|
1083
|
+
? `workspaceId=${workspaceId} middlewareId=${bp.middlewareId} routeId=${bp.routeId}`
|
|
1084
|
+
: null;
|
|
1085
|
+
switch (state) {
|
|
1086
|
+
case "no_context":
|
|
1087
|
+
nextActions.push(`Empty blueprint — feed the library first: blueprint_add_entry blueprintId=${blueprintId} (paste SOPs as text, add Loom/doc urls, or run the VM walkthrough: get_skill name='blueprint-vm-walkthrough'). Then blueprint_synthesize.`);
|
|
1088
|
+
break;
|
|
1089
|
+
case "enriching":
|
|
1090
|
+
nextActions.push(`${pendingEntries.length} entr${pendingEntries.length === 1 ? "y is" : "ies are"} still enriching (${pendingEntries
|
|
1091
|
+
.map((e) => e.title)
|
|
1092
|
+
.join(", ")}) — poll blueprint_list_entries blueprintId=${blueprintId} until counts.pending is 0, then blueprint_synthesize.`);
|
|
1093
|
+
break;
|
|
1094
|
+
case "no_spec":
|
|
1095
|
+
nextActions.push(`Library is ready but the spec is empty — blueprint_synthesize blueprintId=${blueprintId} to draft the first proposal.`);
|
|
1096
|
+
break;
|
|
1097
|
+
case "proposal_pending":
|
|
1098
|
+
nextActions.push(bp.pendingProposal
|
|
1099
|
+
? `A proposal is pending review — blueprint_get_proposal blueprintId=${blueprintId}, then blueprint_apply_proposal blueprintId=${blueprintId} baseVersion=${bp.pendingProposal.baseVersion} proposalBasedOnSeq=${bp.pendingProposal.basedOnSeq} (or blueprint_reject_proposal).`
|
|
1100
|
+
: `A proposal is pending review — blueprint_get_proposal blueprintId=${blueprintId}.`);
|
|
1101
|
+
break;
|
|
1102
|
+
case "needs_answers":
|
|
1103
|
+
for (const q of openQuestions) {
|
|
1104
|
+
nextActions.push(`blueprint_answer_question blueprintId=${blueprintId} question=${JSON.stringify(q)}`);
|
|
1105
|
+
}
|
|
1106
|
+
nextActions.push("Answer ALL open questions, THEN blueprint_synthesize once to fold the answers in — don't loop answer->synthesize per question.");
|
|
1107
|
+
break;
|
|
1108
|
+
case "building":
|
|
1109
|
+
nextActions.push(`Build ${bp.buildId} is ${bp.buildStatus} — poll blueprint_build_status blueprintId=${blueprintId}${linkCoords ? ` (job-side: get_build_status ${linkCoords} buildId=${bp.buildId})` : ""}. Don't edit the draft underneath it.`);
|
|
1110
|
+
break;
|
|
1111
|
+
case "needs_input":
|
|
1112
|
+
for (const q of buildQuestions) {
|
|
1113
|
+
nextActions.push(`answer_build_question ${linkCoords} buildId=${bp.buildId} questionId=${q.id} — "${q.text}" (search the library first: blueprint_list_entries blueprintId=${blueprintId}).`);
|
|
1114
|
+
}
|
|
1115
|
+
if (buildQuestions.length === 0) {
|
|
1116
|
+
nextActions.push(`Build ${bp.buildId} is needs_input — blueprint_build_status blueprintId=${blueprintId} for its questions.`);
|
|
1117
|
+
}
|
|
1118
|
+
break;
|
|
1119
|
+
case "build_failed":
|
|
1120
|
+
nextActions.push(`Build ${bp.buildId} FAILED — blueprint_build_status blueprintId=${blueprintId} for the summary. Fix at the right level (spec: blueprint_update / workflows: the jobs loop${linkCoords ? ` via resolve_job_state ${linkCoords}` : ""}), then blueprint_build to re-sync.`);
|
|
1121
|
+
break;
|
|
1122
|
+
case "job_ahead":
|
|
1123
|
+
nextActions.push(`The job was edited DIRECTLY since the last sync (reverse drift). Either fold the edits back: blueprint_import_cases blueprintId=${blueprintId} (adopts the job's test cases into exampleCases), or rebuild from the spec and overwrite them: blueprint_build blueprintId=${blueprintId}.`);
|
|
1124
|
+
break;
|
|
1125
|
+
case "spec_stale":
|
|
1126
|
+
nextActions.push(answerNotesOnly
|
|
1127
|
+
? `Library drift is answer notes ONLY (${bp.drift.newEntries.length}) — one blueprint_synthesize blueprintId=${blueprintId} folds them; no further answering needed.`
|
|
1128
|
+
: `The library moved past the spec (${bp.drift.newEntries.length} new entr${bp.drift.newEntries.length === 1 ? "y" : "ies"}) — blueprint_synthesize blueprintId=${blueprintId} to fold them into a new proposal.`);
|
|
1129
|
+
break;
|
|
1130
|
+
case "job_stale":
|
|
1131
|
+
nextActions.push(bp.routeId
|
|
1132
|
+
? `The spec is ahead of the job (${bp.drift.unsyncedVersions.length} unsynced version(s)) — blueprint_build blueprintId=${blueprintId} to re-sync.`
|
|
1133
|
+
: `The spec is ready and no job exists yet — blueprint_build blueprintId=${blueprintId} creates the router + route, upserts judge cases, and queues the builder.`);
|
|
1134
|
+
break;
|
|
1135
|
+
case "in_sync":
|
|
1136
|
+
nextActions.push(`In sync — nothing to do. To change behavior: blueprint_add_entry -> blueprint_synthesize -> apply -> blueprint_build.${linkCoords ? ` Job-side status: resolve_job_state ${linkCoords}.` : ""}`);
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
return ok({
|
|
1140
|
+
blueprint: {
|
|
1141
|
+
id: bp.id,
|
|
1142
|
+
name: bp.name,
|
|
1143
|
+
headVersion: bp.headVersion,
|
|
1144
|
+
librarySeq: bp.librarySeq,
|
|
1145
|
+
specBasedOnSeq: bp.specBasedOnSeq,
|
|
1146
|
+
lastSyncedVersion: bp.lastSyncedVersion,
|
|
1147
|
+
},
|
|
1148
|
+
state,
|
|
1149
|
+
backendState: bp.state,
|
|
1150
|
+
link: bp.middlewareId && bp.routeId
|
|
1151
|
+
? { middlewareId: bp.middlewareId, routeId: bp.routeId, buildId: bp.buildId ?? null }
|
|
1152
|
+
: null,
|
|
1153
|
+
resources: {
|
|
1154
|
+
total: entries.length,
|
|
1155
|
+
ready: entries.length - pendingEntries.length - failedEntries.length,
|
|
1156
|
+
pending: pendingEntries.map((e) => ({ id: e.id, kind: e.kind, title: e.title })),
|
|
1157
|
+
failed: failedEntries.map((e) => ({
|
|
1158
|
+
id: e.id,
|
|
1159
|
+
kind: e.kind,
|
|
1160
|
+
title: e.title,
|
|
1161
|
+
error: e.error,
|
|
1162
|
+
})),
|
|
1163
|
+
},
|
|
1164
|
+
openQuestions,
|
|
1165
|
+
proposal: bp.pendingProposal
|
|
1166
|
+
? {
|
|
1167
|
+
summary: bp.pendingProposal.summary,
|
|
1168
|
+
baseVersion: bp.pendingProposal.baseVersion,
|
|
1169
|
+
basedOnSeq: bp.pendingProposal.basedOnSeq,
|
|
1170
|
+
createdAt: bp.pendingProposal.createdAt,
|
|
1171
|
+
citations: bp.pendingProposal.citations?.length ?? 0,
|
|
1172
|
+
}
|
|
1173
|
+
: null,
|
|
1174
|
+
build: build
|
|
1175
|
+
? {
|
|
1176
|
+
id: build.id,
|
|
1177
|
+
status: build.status,
|
|
1178
|
+
openQuestions: buildQuestions,
|
|
1179
|
+
startedAt: build.startedAt,
|
|
1180
|
+
completedAt: build.completedAt,
|
|
1181
|
+
}
|
|
1182
|
+
: null,
|
|
1183
|
+
drift: { ...bp.drift, answerNotesOnly },
|
|
1184
|
+
blockers,
|
|
1185
|
+
nextActions,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
catch (e) {
|
|
1189
|
+
return text(`Error resolving blueprint state: ${e.message}`);
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
//# sourceMappingURL=blueprints.js.map
|