@barivia/barmesh-mcp 0.7.0 → 0.8.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/dist/audit.js +3 -1
- package/dist/deferred_feedback.js +88 -0
- package/dist/index.js +1 -1
- package/dist/inventory_format.js +69 -0
- package/dist/job_monitor.js +38 -2
- package/dist/logger.js +3 -0
- package/dist/richardson_results.js +35 -0
- package/dist/shared.js +347 -45
- package/dist/tools/barmesh_results_explorer.js +44 -33
- package/dist/tools/cfd.js +20 -8
- package/dist/tools/datasets.js +61 -27
- package/dist/tools/feedback.js +71 -6
- package/dist/tools/guide.js +41 -7
- package/dist/tools/jobs.js +117 -11
- package/dist/tools/results.js +11 -5
- package/dist/tools/training_monitor.js +25 -20
- package/dist/training_monitor_curve.js +1 -1
- package/dist/ui-delivery.js +155 -0
- package/dist/views/src/views/barmesh-results-explorer/index.html +18 -16
- package/dist/views/src/views/barmesh-training-monitor/index.html +40 -28
- package/dist/viz_links.js +55 -0
- package/package.json +1 -1
package/dist/shared.js
CHANGED
|
@@ -13,17 +13,29 @@ import { pipeline } from "node:stream/promises";
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { logInfo } from "./logger.js";
|
|
16
|
+
import { logInfo, logWarn } from "./logger.js";
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Config
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
|
-
export const API_URL = process.env.BARIVIA_API_URL ??
|
|
21
|
-
export const API_KEY = process.env.BARIVIA_API_KEY ??
|
|
20
|
+
export const API_URL = process.env.BARIVIA_API_URL ?? "https://api.barivia.se";
|
|
21
|
+
export const API_KEY = process.env.BARIVIA_API_KEY ?? "";
|
|
22
22
|
export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
|
|
23
|
-
|
|
23
|
+
/** Retries after the first attempt; default 2 → 3 total tries. Override with BARIVIA_MAX_RETRIES. */
|
|
24
|
+
export const MAX_RETRIES = parseInt(process.env.BARIVIA_MAX_RETRIES ?? "2", 10);
|
|
25
|
+
/** Delay between burst retries (ms). Default 2000 → ~2s between each of the 3 attempts. */
|
|
26
|
+
export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "2000", 10);
|
|
24
27
|
export const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
28
|
+
/** Cool-down hint after the burst is exhausted — agents should wait before another round. */
|
|
29
|
+
export const UNAVAILABLE_RETRY_AFTER_SEC = 30;
|
|
30
|
+
/** Fixed delay between burst retries (not exponential). */
|
|
31
|
+
export function retryDelayMs(_attempt) {
|
|
32
|
+
return RETRY_BASE_MS;
|
|
33
|
+
}
|
|
34
|
+
function newRequestId() {
|
|
35
|
+
return randomUUID();
|
|
36
|
+
}
|
|
25
37
|
/** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
|
|
26
|
-
export const CLIENT_VERSION = "0.
|
|
38
|
+
export const CLIENT_VERSION = "0.8.0";
|
|
27
39
|
export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
|
|
28
40
|
/** Large per-cell CSV uploads may exceed the default fetch timeout. */
|
|
29
41
|
export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
|
|
@@ -33,6 +45,21 @@ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
|
|
|
33
45
|
export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
|
|
34
46
|
/** Poll window for the async stage_dataset job. */
|
|
35
47
|
export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
|
|
48
|
+
/** Map a local upload path to the API Content-Type (csv vs tsv; ignores .gz suffix). */
|
|
49
|
+
export function resolveUploadContentType(filePath) {
|
|
50
|
+
const lower = filePath.toLowerCase();
|
|
51
|
+
const base = lower.endsWith(".gz") ? lower.slice(0, -3) : lower;
|
|
52
|
+
return path.extname(base) === ".tsv" ? "text/tab-separated-values" : "text/csv";
|
|
53
|
+
}
|
|
54
|
+
export function suggestMeshDatasetPreview(datasetId) {
|
|
55
|
+
return `barmesh_datasets(action=preview, dataset_id=${datasetId}) to verify mesh, feature, and volume columns.`;
|
|
56
|
+
}
|
|
57
|
+
export function suggestAfterCfdSubmit(jobId, prepSuffix = "") {
|
|
58
|
+
return `Run barmesh_training_monitor(job_id="${jobId}") or barmesh_jobs(action=monitor, job_id="${jobId}")${prepSuffix}; on completion call barmesh_results(action=get, job_id="${jobId}").`;
|
|
59
|
+
}
|
|
60
|
+
export function suggestAfterRender(parentJobId, fmt) {
|
|
61
|
+
return `Figures rendered as ${fmt}. Download with barmesh_results(action=download, job_id="${parentJobId}") or barmesh_results(action=image, job_id="${parentJobId}", filename=<figure>.${fmt}).`;
|
|
62
|
+
}
|
|
36
63
|
/** Streaming SHA-256 of a file (idempotency key) without reading it into memory. */
|
|
37
64
|
export async function streamFileSha256(srcPath) {
|
|
38
65
|
return new Promise((resolve, reject) => {
|
|
@@ -80,7 +107,11 @@ export async function putPresignedStream(url, srcPath, contentType, timeoutMs =
|
|
|
80
107
|
const resp = await fetch(url, {
|
|
81
108
|
method: "PUT",
|
|
82
109
|
body: webStream,
|
|
83
|
-
headers: {
|
|
110
|
+
headers: {
|
|
111
|
+
"Content-Type": contentType,
|
|
112
|
+
"Content-Length": String(contentLength),
|
|
113
|
+
"Content-Encoding": "gzip",
|
|
114
|
+
},
|
|
84
115
|
duplex: "half",
|
|
85
116
|
signal: controller.signal,
|
|
86
117
|
});
|
|
@@ -112,6 +143,51 @@ export function getClientSupportsMcpApps() {
|
|
|
112
143
|
export function setClientSupportsMcpApps(value) {
|
|
113
144
|
_clientSupportsMcpApps = value;
|
|
114
145
|
}
|
|
146
|
+
export const BARMESH_VIZ_VIEWS = {
|
|
147
|
+
trainingMonitor: "barmesh-training-monitor",
|
|
148
|
+
resultsExplorer: "barmesh-results-explorer",
|
|
149
|
+
};
|
|
150
|
+
export function buildStandaloneVizPageUrl(port, viewPath, jobId) {
|
|
151
|
+
if (!port)
|
|
152
|
+
return undefined;
|
|
153
|
+
return `http://localhost:${port}/viz/${viewPath}?mode=standalone&job_id=${encodeURIComponent(jobId)}`;
|
|
154
|
+
}
|
|
155
|
+
export function buildVizRelatedUrls(port, jobId, views) {
|
|
156
|
+
if (!port)
|
|
157
|
+
return undefined;
|
|
158
|
+
return {
|
|
159
|
+
trainingMonitor: buildStandaloneVizPageUrl(port, views.trainingMonitor, jobId),
|
|
160
|
+
resultsExplorer: buildStandaloneVizPageUrl(port, views.resultsExplorer, jobId),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Prominent standalone URL blocks for tool results — always emitted when a viz port exists. */
|
|
164
|
+
export function buildVizUrlContentBlocks(options) {
|
|
165
|
+
const { label, toolName, standaloneUrl, port, jobId, clientSupportsMcpApps } = options;
|
|
166
|
+
if (!standaloneUrl)
|
|
167
|
+
return [];
|
|
168
|
+
const embedNote = clientSupportsMcpApps
|
|
169
|
+
? "Embedded panel if your host supports MCP Apps — otherwise open the standalone URL below (expected on some Cursor builds)."
|
|
170
|
+
: "Open the standalone URL below when no inline panel appears.";
|
|
171
|
+
const health = port > 0
|
|
172
|
+
? ` Health check: http://localhost:${port}/api/health?job_id=${encodeURIComponent(jobId)}`
|
|
173
|
+
: "";
|
|
174
|
+
const blocks = [
|
|
175
|
+
{
|
|
176
|
+
type: "text",
|
|
177
|
+
text: `${embedNote}\n` +
|
|
178
|
+
`Standalone ${label} URL (copy if Open is blocked):\n${standaloneUrl}\n` +
|
|
179
|
+
`AGENT: surface this URL prominently in your reply — use a markdown link "[Open ${label}](${standaloneUrl})" or tell the user to copy/paste into a browser. Do not bury it after metrics.\n` +
|
|
180
|
+
`This localhost port is per MCP session and goes stale if the proxy restarts — re-run ${toolName} for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.${health}`,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
if (!clientSupportsMcpApps) {
|
|
184
|
+
blocks.push({
|
|
185
|
+
type: "text",
|
|
186
|
+
text: `[Open ${label}](${standaloneUrl})`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return blocks;
|
|
190
|
+
}
|
|
115
191
|
/** Tool result carrying both a structured payload (for the MCP App view) and text/image content. */
|
|
116
192
|
export function structuredTextResult(structuredContent, text, content) {
|
|
117
193
|
return {
|
|
@@ -147,7 +223,7 @@ export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS)
|
|
|
147
223
|
// Path / sandbox helpers
|
|
148
224
|
// ---------------------------------------------------------------------------
|
|
149
225
|
export function getSandboxRoot() {
|
|
150
|
-
const raw = process.env.BARIVIA_WORKSPACE_ROOT
|
|
226
|
+
const raw = process.env.BARIVIA_WORKSPACE_ROOT;
|
|
151
227
|
if (raw)
|
|
152
228
|
return path.resolve(process.cwd(), raw);
|
|
153
229
|
const workspaceFolder = process.env.CURSOR_WORKSPACE_FOLDER ?? process.env.WORKSPACE_FOLDER;
|
|
@@ -220,47 +296,227 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
|
|
|
220
296
|
const resolved = path.resolve(root, trimmed);
|
|
221
297
|
return checkUnder(resolved);
|
|
222
298
|
}
|
|
223
|
-
|
|
299
|
+
function looksLikeHtml(bodyText) {
|
|
300
|
+
const t = bodyText.trim().slice(0, 200).toLowerCase();
|
|
301
|
+
return t.startsWith("<!doctype") || t.startsWith("<html") || /<\s*html[\s>]/.test(t);
|
|
302
|
+
}
|
|
303
|
+
function isStorageErrorCode(code) {
|
|
304
|
+
if (!code)
|
|
305
|
+
return false;
|
|
306
|
+
const c = code.toLowerCase();
|
|
307
|
+
return c === "storage_upload_failed" || (c.includes("storage") && !c.includes("unavailable"));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Classify an HTTP API failure into a compact structured error.
|
|
311
|
+
* Gateway HTML 502/503 bodies are never echoed — they become api_unreachable.
|
|
312
|
+
*/
|
|
313
|
+
export function classifyApiError(status, bodyText, requestId) {
|
|
224
314
|
let parsed = null;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
315
|
+
const html = looksLikeHtml(bodyText);
|
|
316
|
+
if (!html && bodyText.trim()) {
|
|
317
|
+
try {
|
|
318
|
+
const j = JSON.parse(bodyText);
|
|
319
|
+
if (j && typeof j === "object")
|
|
320
|
+
parsed = j;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
/* non-JSON */
|
|
324
|
+
}
|
|
232
325
|
}
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
326
|
+
const apiCode = parsed?.error_code != null ? String(parsed.error_code) : "";
|
|
327
|
+
const apiMsg = parsed?.error != null && String(parsed.error).trim()
|
|
328
|
+
? String(parsed.error).trim()
|
|
329
|
+
: "";
|
|
330
|
+
let cause = "server";
|
|
331
|
+
let error_code = apiCode || `http_${status}`;
|
|
332
|
+
let message = apiMsg || `HTTP ${status}`;
|
|
333
|
+
let hint = "";
|
|
334
|
+
let retryable = status === 502 || status === 503 || status === 504;
|
|
236
335
|
const accountHint = ` Regenerate or verify your key via ${PUBLIC_SITE_ORIGIN} if needed.`;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
336
|
+
if (status === 400) {
|
|
337
|
+
cause = "client";
|
|
338
|
+
hint = "Check parameter types and required fields.";
|
|
339
|
+
retryable = false;
|
|
340
|
+
}
|
|
341
|
+
else if (status === 401) {
|
|
342
|
+
cause = "auth";
|
|
343
|
+
error_code = apiCode || "unauthorized";
|
|
344
|
+
hint = `Check BARIVIA_API_KEY in your MCP config.${accountHint}`;
|
|
345
|
+
retryable = false;
|
|
346
|
+
}
|
|
347
|
+
else if (status === 403) {
|
|
348
|
+
cause = "auth";
|
|
349
|
+
error_code = apiCode || "forbidden";
|
|
350
|
+
hint = `Access denied — your plan does not include the CFD capability. Add the CFD capability at ${PUBLIC_SITE_ORIGIN}/dashboard to enable the barmesh tools.`;
|
|
351
|
+
retryable = false;
|
|
352
|
+
}
|
|
353
|
+
else if (status === 404) {
|
|
354
|
+
cause = "client";
|
|
355
|
+
hint = "The resource may not exist or may have been deleted.";
|
|
356
|
+
retryable = false;
|
|
357
|
+
}
|
|
358
|
+
else if (status === 409) {
|
|
359
|
+
cause = "client";
|
|
360
|
+
hint = "The job may not be in the expected state.";
|
|
361
|
+
retryable = false;
|
|
362
|
+
}
|
|
363
|
+
else if (status === 429) {
|
|
364
|
+
cause = "rate_limit";
|
|
365
|
+
hint = "Plan limit or rate limit — delete unused datasets or wait and retry.";
|
|
366
|
+
retryable = true;
|
|
367
|
+
}
|
|
368
|
+
else if (status === 502 || status === 503 || status === 504) {
|
|
369
|
+
if (isStorageErrorCode(apiCode) || /object storage|r2\/s3|storage upload/i.test(apiMsg)) {
|
|
370
|
+
cause = "storage";
|
|
371
|
+
error_code = apiCode || "storage_upload_failed";
|
|
372
|
+
message = apiMsg || "Object storage (R2/S3) error from API.";
|
|
373
|
+
hint = "Retry later; feedback may still succeed via DB when storage is degraded.";
|
|
374
|
+
}
|
|
375
|
+
else if (html || !parsed) {
|
|
376
|
+
cause = "api_unreachable";
|
|
377
|
+
error_code = apiCode || "api_unreachable";
|
|
378
|
+
message = "Mesh analysis API temporarily unavailable (gateway/tunnel returned a non-JSON error).";
|
|
379
|
+
hint =
|
|
380
|
+
`Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
|
|
381
|
+
"Use barmesh_api_health — /health does not depend on R2. Keep reviewing last good distances/figures; do not paste HTML.";
|
|
382
|
+
}
|
|
383
|
+
else if (apiCode === "feedback_unavailable" || apiCode === "database_error") {
|
|
384
|
+
cause = "server";
|
|
385
|
+
error_code = apiCode;
|
|
386
|
+
message = apiMsg || "API or database temporarily unavailable.";
|
|
387
|
+
hint =
|
|
388
|
+
`Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s; barmesh_send_feedback queues locally if the API stays down.`;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
cause = "server";
|
|
392
|
+
message = apiMsg || `HTTP ${status}`;
|
|
393
|
+
hint = `API or database temporarily unavailable — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else if (status >= 500) {
|
|
397
|
+
cause = "server";
|
|
398
|
+
hint = `Server error — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
|
|
399
|
+
retryable = true;
|
|
400
|
+
}
|
|
401
|
+
else if (!apiMsg && html) {
|
|
402
|
+
cause = "api_unreachable";
|
|
403
|
+
error_code = "api_unreachable";
|
|
404
|
+
message = `HTTP ${status} (non-JSON gateway body)`;
|
|
405
|
+
hint = `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. Do not paste HTML.`;
|
|
406
|
+
retryable = status >= 500;
|
|
407
|
+
}
|
|
408
|
+
const retry_after_sec = retryable ? UNAVAILABLE_RETRY_AFTER_SEC : undefined;
|
|
409
|
+
return {
|
|
410
|
+
status,
|
|
411
|
+
error_code,
|
|
412
|
+
message,
|
|
413
|
+
hint,
|
|
414
|
+
request_id: requestId,
|
|
415
|
+
retryable,
|
|
416
|
+
cause,
|
|
417
|
+
...(retry_after_sec !== undefined ? { retry_after_sec } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** User-visible API error line (slim; includes request id). Exported for tests. */
|
|
421
|
+
export function formatApiErrorMessage(status, bodyText, requestId) {
|
|
422
|
+
const c = classifyApiError(status, bodyText, requestId);
|
|
423
|
+
const parts = [
|
|
424
|
+
c.message,
|
|
425
|
+
`error_code=${c.error_code}`,
|
|
426
|
+
`cause=${c.cause}`,
|
|
427
|
+
c.retry_after_sec != null ? `retry_after_sec=${c.retry_after_sec}` : "",
|
|
428
|
+
c.hint,
|
|
429
|
+
`request_id=${c.request_id}`,
|
|
430
|
+
].filter((p) => p && String(p).trim());
|
|
431
|
+
return parts.join(" | ");
|
|
258
432
|
}
|
|
259
433
|
function throwApiError(status, bodyText, requestId) {
|
|
434
|
+
const classified = classifyApiError(status, bodyText, requestId);
|
|
260
435
|
const err = new Error(formatApiErrorMessage(status, bodyText, requestId));
|
|
261
436
|
err.httpStatus = status;
|
|
437
|
+
err.errorCode = classified.error_code;
|
|
438
|
+
err.requestId = requestId;
|
|
439
|
+
err.retryable = classified.retryable;
|
|
440
|
+
err.cause = classified.cause;
|
|
441
|
+
err.retryAfterSec = classified.retry_after_sec;
|
|
442
|
+
err.classified = classified;
|
|
262
443
|
throw err;
|
|
263
444
|
}
|
|
445
|
+
/** Final network failure after the burst — same calm shape as gateway HTML 502. */
|
|
446
|
+
function throwNetworkUnreachable(requestId, err) {
|
|
447
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
448
|
+
const classified = {
|
|
449
|
+
status: 0,
|
|
450
|
+
error_code: "api_unreachable",
|
|
451
|
+
message: "Mesh analysis API temporarily unavailable (network error).",
|
|
452
|
+
hint: `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
|
|
453
|
+
`Detail: ${detail.slice(0, 120)}`,
|
|
454
|
+
request_id: requestId,
|
|
455
|
+
retryable: true,
|
|
456
|
+
cause: "api_unreachable",
|
|
457
|
+
retry_after_sec: UNAVAILABLE_RETRY_AFTER_SEC,
|
|
458
|
+
};
|
|
459
|
+
const msg = [
|
|
460
|
+
classified.message,
|
|
461
|
+
`error_code=${classified.error_code}`,
|
|
462
|
+
`cause=${classified.cause}`,
|
|
463
|
+
`retry_after_sec=${classified.retry_after_sec}`,
|
|
464
|
+
classified.hint,
|
|
465
|
+
`request_id=${classified.request_id}`,
|
|
466
|
+
].join(" | ");
|
|
467
|
+
const out = new Error(msg);
|
|
468
|
+
out.httpStatus = 0;
|
|
469
|
+
out.errorCode = classified.error_code;
|
|
470
|
+
out.requestId = requestId;
|
|
471
|
+
out.retryable = true;
|
|
472
|
+
out.cause = "api_unreachable";
|
|
473
|
+
out.retryAfterSec = UNAVAILABLE_RETRY_AFTER_SEC;
|
|
474
|
+
out.classified = classified;
|
|
475
|
+
throw out;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Liveness (+ optional readiness) probe against the API origin.
|
|
479
|
+
* Uses GET /health which never touches R2 — safe during storage outages.
|
|
480
|
+
*/
|
|
481
|
+
export async function probeApiHealth() {
|
|
482
|
+
const base = { storage: "not_probed" };
|
|
483
|
+
try {
|
|
484
|
+
const live = await fetchWithTimeout(`${API_URL}/health`, { method: "GET" }, 8_000);
|
|
485
|
+
const liveText = await live.text();
|
|
486
|
+
if (!live.ok) {
|
|
487
|
+
return { ...base, liveness: "down", readiness: "unknown", detail: `health HTTP ${live.status}` };
|
|
488
|
+
}
|
|
489
|
+
let readiness = "unknown";
|
|
490
|
+
let db;
|
|
491
|
+
let redis;
|
|
492
|
+
try {
|
|
493
|
+
const ready = await fetchWithTimeout(`${API_URL}/ready`, { method: "GET" }, 8_000);
|
|
494
|
+
const readyText = await ready.text();
|
|
495
|
+
try {
|
|
496
|
+
const j = JSON.parse(readyText);
|
|
497
|
+
readiness = ready.ok && j.status === "ready" ? "ready" : "not_ready";
|
|
498
|
+
db = j.db;
|
|
499
|
+
redis = j.redis;
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
readiness = ready.ok ? "ready" : "not_ready";
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
readiness = "unknown";
|
|
507
|
+
}
|
|
508
|
+
void liveText;
|
|
509
|
+
return { ...base, liveness: "ok", readiness, db, redis };
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
return {
|
|
513
|
+
...base,
|
|
514
|
+
liveness: "down",
|
|
515
|
+
readiness: "unknown",
|
|
516
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
264
520
|
// ---- Distributed-trace context (W3C traceparent) ----
|
|
265
521
|
// One trace per logical tool action (scoped via AsyncLocalStorage in registerAuditedTool),
|
|
266
522
|
// so the API + job chain reconstruct end-to-end. Fresh span per API call; falls back to a
|
|
@@ -282,7 +538,7 @@ function _traceparentHeader() {
|
|
|
282
538
|
export async function apiCall(method, pathPart, body, extraHeaders, requestTimeoutMs) {
|
|
283
539
|
const url = `${API_URL}${pathPart}`;
|
|
284
540
|
const contentType = extraHeaders?.["Content-Type"] ?? "application/json";
|
|
285
|
-
const requestId =
|
|
541
|
+
const requestId = newRequestId();
|
|
286
542
|
const headers = {
|
|
287
543
|
Authorization: `Bearer ${API_KEY}`,
|
|
288
544
|
"Content-Type": contentType,
|
|
@@ -310,7 +566,17 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
|
|
|
310
566
|
const text = await resp.text();
|
|
311
567
|
if (!resp.ok) {
|
|
312
568
|
if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
|
|
313
|
-
|
|
569
|
+
const delayMs = retryDelayMs(attempt);
|
|
570
|
+
logWarn("API retry", {
|
|
571
|
+
rid: requestId,
|
|
572
|
+
action: method,
|
|
573
|
+
path: pathPart,
|
|
574
|
+
attempt: attempt + 1,
|
|
575
|
+
max_retries: MAX_RETRIES,
|
|
576
|
+
delay_ms: delayMs,
|
|
577
|
+
error_code: `http_${resp.status}`,
|
|
578
|
+
});
|
|
579
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
314
580
|
continue;
|
|
315
581
|
}
|
|
316
582
|
logInfo("API response", { rid: requestId, outcome: "error", duration_ms: Date.now() - t0, error_code: `http_${resp.status}` });
|
|
@@ -322,21 +588,35 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
|
|
|
322
588
|
catch (err) {
|
|
323
589
|
lastError = err;
|
|
324
590
|
if (attempt < MAX_RETRIES && isTransientError(err)) {
|
|
325
|
-
|
|
591
|
+
const delayMs = retryDelayMs(attempt);
|
|
592
|
+
logWarn("API retry", {
|
|
593
|
+
rid: requestId,
|
|
594
|
+
action: method,
|
|
595
|
+
path: pathPart,
|
|
596
|
+
attempt: attempt + 1,
|
|
597
|
+
max_retries: MAX_RETRIES,
|
|
598
|
+
delay_ms: delayMs,
|
|
599
|
+
error_code: err instanceof Error ? err.name : "fetch_error",
|
|
600
|
+
});
|
|
601
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
326
602
|
continue;
|
|
327
603
|
}
|
|
328
604
|
if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
|
|
329
605
|
throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for slow or large requests. (request id: ${requestId})`);
|
|
330
606
|
}
|
|
607
|
+
if (err instanceof TypeError)
|
|
608
|
+
throwNetworkUnreachable(requestId, err);
|
|
331
609
|
throw err;
|
|
332
610
|
}
|
|
333
611
|
}
|
|
612
|
+
if (lastError instanceof TypeError)
|
|
613
|
+
throwNetworkUnreachable(requestId, lastError);
|
|
334
614
|
throw lastError;
|
|
335
615
|
}
|
|
336
616
|
/** Fetch raw bytes from the API (for image downloads). */
|
|
337
617
|
export async function apiRawCall(pathPart, requestTimeoutMs) {
|
|
338
618
|
const url = `${API_URL}${pathPart}`;
|
|
339
|
-
const requestId =
|
|
619
|
+
const requestId = newRequestId();
|
|
340
620
|
const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
|
|
341
621
|
let lastError;
|
|
342
622
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
@@ -344,7 +624,16 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
|
|
|
344
624
|
const resp = await fetchWithTimeout(url, { method: "GET", headers: { Authorization: `Bearer ${API_KEY}`, "X-Request-ID": requestId, traceparent: _traceparentHeader() } }, effectiveTimeout);
|
|
345
625
|
if (!resp.ok) {
|
|
346
626
|
if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
|
|
347
|
-
|
|
627
|
+
const delayMs = retryDelayMs(attempt);
|
|
628
|
+
logWarn("API retry", {
|
|
629
|
+
rid: requestId,
|
|
630
|
+
path: pathPart,
|
|
631
|
+
attempt: attempt + 1,
|
|
632
|
+
max_retries: MAX_RETRIES,
|
|
633
|
+
delay_ms: delayMs,
|
|
634
|
+
error_code: `http_${resp.status}`,
|
|
635
|
+
});
|
|
636
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
348
637
|
continue;
|
|
349
638
|
}
|
|
350
639
|
const text = await resp.text();
|
|
@@ -359,15 +648,28 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
|
|
|
359
648
|
catch (err) {
|
|
360
649
|
lastError = err;
|
|
361
650
|
if (attempt < MAX_RETRIES && isTransientError(err)) {
|
|
362
|
-
|
|
651
|
+
const delayMs = retryDelayMs(attempt);
|
|
652
|
+
logWarn("API retry", {
|
|
653
|
+
rid: requestId,
|
|
654
|
+
path: pathPart,
|
|
655
|
+
attempt: attempt + 1,
|
|
656
|
+
max_retries: MAX_RETRIES,
|
|
657
|
+
delay_ms: delayMs,
|
|
658
|
+
error_code: err instanceof Error ? err.name : "fetch_error",
|
|
659
|
+
});
|
|
660
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
363
661
|
continue;
|
|
364
662
|
}
|
|
365
663
|
if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
|
|
366
664
|
throw new Error(`Image request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for large images. (request id: ${requestId})`);
|
|
367
665
|
}
|
|
666
|
+
if (err instanceof TypeError)
|
|
667
|
+
throwNetworkUnreachable(requestId, err);
|
|
368
668
|
throw err;
|
|
369
669
|
}
|
|
370
670
|
}
|
|
671
|
+
if (lastError instanceof TypeError)
|
|
672
|
+
throwNetworkUnreachable(requestId, lastError);
|
|
371
673
|
throw lastError;
|
|
372
674
|
}
|
|
373
675
|
// ---------------------------------------------------------------------------
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
3
|
-
import {
|
|
4
|
-
import { apiCall, apiRawCall,
|
|
3
|
+
import { runMcpToolAudit } from "../audit.js";
|
|
4
|
+
import { apiCall, apiRawCall, BARMESH_VIZ_VIEWS, buildStandaloneVizPageUrl, buildVizRelatedUrls, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
|
|
5
5
|
import { FIGURE_SECTION_LABELS, FIGURE_SECTION_ORDER, figureSection, sortRank, } from "../figure_sections.js";
|
|
6
6
|
import { buildMetadataStrip } from "../results_metadata.js";
|
|
7
|
+
import { appendUiDeliveryContent, buildStandaloneVizUrl, resolveUiDelivery, } from "../ui-delivery.js";
|
|
8
|
+
import { buildBarmeshVizStandaloneUrl } from "../viz_links.js";
|
|
7
9
|
export const RESULTS_EXPLORER_URI = "ui://barmesh/results-explorer";
|
|
8
10
|
export function sortFigures(figures) {
|
|
9
11
|
return [...figures].sort((a, b) => FIGURE_SECTION_ORDER.indexOf(a.section ?? "diagnostics") -
|
|
@@ -111,6 +113,10 @@ export function buildPayload(jobId, data, jobMeta) {
|
|
|
111
113
|
const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
|
|
112
114
|
const figures = sortFigures(groupFigures(files));
|
|
113
115
|
const port = getVizPort();
|
|
116
|
+
const standaloneUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.resultsExplorer, jobId) ??
|
|
117
|
+
buildBarmeshVizStandaloneUrl("barmesh-results-explorer", jobId);
|
|
118
|
+
const trainingMonitorUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.trainingMonitor, jobId) ??
|
|
119
|
+
buildBarmeshVizStandaloneUrl("barmesh-training-monitor", jobId);
|
|
114
120
|
return {
|
|
115
121
|
type: "barmesh-results-explorer",
|
|
116
122
|
jobId,
|
|
@@ -120,9 +126,9 @@ export function buildPayload(jobId, data, jobMeta) {
|
|
|
120
126
|
metrics: buildMetrics(summary),
|
|
121
127
|
availableFigures: figures,
|
|
122
128
|
defaultFigureKey: resolveDefaultFigureKey(figures),
|
|
123
|
-
standaloneUrl
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
standaloneUrl,
|
|
130
|
+
trainingMonitorUrl,
|
|
131
|
+
relatedUrls: buildVizRelatedUrls(port, jobId, BARMESH_VIZ_VIEWS),
|
|
126
132
|
};
|
|
127
133
|
}
|
|
128
134
|
async function handleResultsExplorer(job_id) {
|
|
@@ -131,40 +137,40 @@ async function handleResultsExplorer(job_id) {
|
|
|
131
137
|
apiCall("GET", `/v1/jobs/${job_id}`).catch(() => ({})),
|
|
132
138
|
]);
|
|
133
139
|
const payload = buildPayload(job_id, data, jobMeta);
|
|
134
|
-
const
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.join("\n"),
|
|
144
|
-
},
|
|
145
|
-
];
|
|
140
|
+
const summaryText = [
|
|
141
|
+
`Mesh convergence results explorer ready for job ${job_id}.`,
|
|
142
|
+
payload.subtitle ? `Label: ${payload.subtitle}` : "",
|
|
143
|
+
payload.metadataStrip?.[0] ?? "",
|
|
144
|
+
]
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.join("\n");
|
|
147
|
+
const content = [{ type: "text", text: summaryText }];
|
|
148
|
+
let inlineImageAttached = false;
|
|
146
149
|
const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
|
|
147
150
|
if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
|
|
151
|
+
const before = content.length;
|
|
148
152
|
await tryAttachImage(content, job_id, defaultFigure.filename);
|
|
153
|
+
inlineImageAttached = content.length > before && content.some((c) => c.type === "image");
|
|
149
154
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
const uiDelivery = resolveUiDelivery({
|
|
156
|
+
tool: "results_explorer",
|
|
157
|
+
jobId: job_id,
|
|
158
|
+
jobStatus: "completed",
|
|
159
|
+
inlineImageAttached,
|
|
160
|
+
});
|
|
161
|
+
appendUiDeliveryContent(content, uiDelivery, {
|
|
162
|
+
linkLabel: "Open results explorer",
|
|
163
|
+
primaryUrl: payload.standaloneUrl ?? buildStandaloneVizUrl("results_explorer", job_id),
|
|
164
|
+
});
|
|
165
|
+
const monitorUrl = payload.trainingMonitorUrl ?? buildStandaloneVizUrl("training_monitor", job_id);
|
|
166
|
+
if (monitorUrl) {
|
|
159
167
|
content.push({
|
|
160
168
|
type: "text",
|
|
161
|
-
text: `
|
|
162
|
-
`AGENT: surface this to the user as a clickable markdown link in your reply. ` +
|
|
163
|
-
`This localhost port is assigned per MCP session and changes if the proxy restarts — re-run barmesh_results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.`,
|
|
169
|
+
text: `Review training curves: [Open training monitor](${monitorUrl})`,
|
|
164
170
|
});
|
|
165
171
|
}
|
|
166
172
|
return {
|
|
167
|
-
...structuredTextResult(payload, undefined, content),
|
|
173
|
+
...structuredTextResult({ ...payload, ui_delivery: uiDelivery }, undefined, content),
|
|
168
174
|
_meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
|
|
169
175
|
};
|
|
170
176
|
}
|
|
@@ -178,9 +184,14 @@ export function registerResultsExplorerTool(server) {
|
|
|
178
184
|
_meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
|
|
179
185
|
};
|
|
180
186
|
registerAppTool(server, "barmesh_results_explorer", toolConfig, async (args) => runMcpToolAudit("barmesh_results_explorer", "default", args, () => handleResultsExplorer(String(args.job_id))));
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
registerAppTool(server, "_barmesh_fetch_figure", {
|
|
188
|
+
title: "Fetch figure (internal)",
|
|
189
|
+
description: "[INTERNAL — MCP App only; agents MUST NOT call.] Lazy-loads one raster figure as base64 for the Mesh Convergence Results Explorer.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
job_id: z.string(),
|
|
192
|
+
filename: z.string(),
|
|
193
|
+
},
|
|
194
|
+
_meta: { ui: { resourceUri: RESULTS_EXPLORER_URI, visibility: ["app"] } },
|
|
184
195
|
}, async ({ job_id, filename }) => {
|
|
185
196
|
const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
186
197
|
const mime = mimeForFilename(safeFilename);
|