@barivia/barmesh-mcp 0.5.1 → 0.5.3
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 +23 -17
- package/dist/index.js +1 -1
- package/dist/job_monitor.js +208 -0
- package/dist/job_status_format.js +24 -2
- package/dist/shared.js +15 -1
- package/dist/tools/barmesh_results_explorer.js +49 -17
- package/dist/tools/cfd.js +2 -2
- package/dist/tools/guide.js +1 -1
- package/dist/tools/jobs.js +35 -10
- package/dist/tools/results.js +105 -14
- package/dist/tools/training_monitor.js +56 -0
- package/dist/views/src/views/barmesh-results-explorer/index.html +33 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,30 +55,36 @@ API key; otherwise the analysis calls return HTTP 403. Contact Barivia to enable
|
|
|
55
55
|
| `barmesh_datasets` | Upload / preview / list / get / subset / delete the mesh CSV. |
|
|
56
56
|
| `barmesh_mesh_convergence` | SOM fingerprint distances (async job). |
|
|
57
57
|
| `barmesh_richardson` | Richardson/GCI on scalar QoIs (async job). |
|
|
58
|
-
| `barmesh_jobs` | Poll job status / list jobs
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
58
|
+
| `barmesh_jobs` | Poll job status / block until terminal (`action=monitor`) / list jobs. Auto-polls CFD prepare + finalize when applicable. Reports phase, epoch/total, elapsed, ETA, and QE live during the SOM training. |
|
|
59
|
+
| `barmesh_training_monitor` | Alias for `barmesh_jobs(action=monitor)` — server-side poll with throttled snapshots until terminal (preferred after submit). |
|
|
60
|
+
| `barmesh_results` | Distances, convergence reading, and figures. `action=get` inlines headline PNGs; `action=download` saves artifacts to disk; `action=render` produces publication PDFs on demand. |
|
|
61
|
+
| `barmesh_results_explorer` | Interactive MCP App with a **figure dropdown above the plot** (or standalone localhost page) to browse every figure after a job completes. |
|
|
61
62
|
| `barmesh_send_feedback` | Send a short note or bug report to the Barivia team. |
|
|
62
63
|
|
|
63
|
-
### Figures and progress (0.5.
|
|
64
|
+
### Figures and progress (0.5.2)
|
|
64
65
|
|
|
65
|
-
- **Combined overview:**
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
- **Combined overview (`combined.png`):** barsom-style **grid of all component planes**
|
|
67
|
+
on the trained SOM — the primary headline artifact. A separate
|
|
68
|
+
`overview_distances.png` holds the KL/EMD/volume diagnostic mosaic.
|
|
69
|
+
- **Learning curve:** every job produces `learning_curve.png` (QE by epoch) for training
|
|
70
|
+
quality inspection; listed in the results explorer dropdown.
|
|
68
71
|
- **PDFs on demand:** publication vector PDFs are NOT generated by default. Render them
|
|
69
|
-
after completion with `barmesh_results(action=render, format=pdf)`, then download
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
after completion with `barmesh_results(action=render, format=pdf)`, then download with
|
|
73
|
+
`barmesh_results(action=download, folder=...)` or `action=image`.
|
|
74
|
+
- **Headless download:** `barmesh_results(action=download, folder=..., include_json=true)`
|
|
75
|
+
writes PNGs, `summary.json`, and text/CSV artifacts to a per-job subfolder (barsom parity).
|
|
76
|
+
- **Explorer UX:** figure `<select>` above the plot (no scrolling past metrics to switch
|
|
77
|
+
figures). PNG previews inline; PDF/SVG offered for download when rendered.
|
|
78
|
+
- **Uploads:** large CSVs use presigned PUT with explicit `Content-Length`; `.csv.gz` /
|
|
79
|
+
`.tsv.gz` accepted. Pin `@barivia/barmesh-mcp@0.5.2` (clear `~/.npm/_npx` if stale).
|
|
80
|
+
- **Live progress:** `barmesh_training_monitor(job_id)` or `barmesh_jobs(action=monitor)` block
|
|
81
|
+
server-side with compact snapshots (phase, epoch, QE/TE, ETA, ordering_errors tail) until
|
|
82
|
+
terminal or `block_until_sec` (default 900). Waits for `cfd_finalize` by default. One-shot:
|
|
83
|
+
`barmesh_jobs(action=status)`.
|
|
79
84
|
|
|
80
85
|
### Migration notes
|
|
81
86
|
|
|
87
|
+
- **`barmesh_training_monitor` (0.5.3):** server-side blocking monitor with throttled snapshots — preferred after job submit instead of manual `barmesh_jobs(status)` loops. Equivalent to `barmesh_jobs(action=monitor)`.
|
|
82
88
|
- **`send_feedback` → `barmesh_send_feedback` (0.3.0):** the feedback tool was renamed so it no longer collides with the `@barivia/barsom-mcp` tool of the same name when both servers are enabled in one client. Update any direct call sites; the behavior is unchanged.
|
|
83
89
|
|
|
84
90
|
## Data format (mesh_convergence)
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as o,RESOURCE_MIME_TYPE as
|
|
2
|
+
import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as o,RESOURCE_MIME_TYPE as t}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as n}from"./viz-server.js";import{API_KEY as i,CLIENT_VERSION as a,apiCall as c,apiRawCall as m,loadViewHtml as l,setVizPort as d,setClientSupportsMcpApps as h}from"./shared.js";import{registerGuideTool as p}from"./tools/guide.js";import{registerDatasetsTool as b}from"./tools/datasets.js";import{registerCfdTools as f}from"./tools/cfd.js";import{registerJobsTool as u}from"./tools/jobs.js";import{registerResultsTool as _}from"./tools/results.js";import{registerResultsExplorerTool as v,RESULTS_EXPLORER_URI as g}from"./tools/barmesh_results_explorer.js";import{registerTrainingMonitorTool as y}from"./tools/training_monitor.js";import{registerFeedbackTool as j}from"./tools/feedback.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));(async function(){const i=new e({name:"barmesh",version:a},{instructions:"# Barivia barmesh — CFD mesh-convergence analytics\n\nSOM-based mesh-convergence verification: compare CFD meshes of a refinement study by the\nvolume-weighted distribution their cells form on a shared self-organizing map, plus\nclassical Richardson/GCI on scalar quantities.\n\n## Two tracks\n- barmesh_mesh_convergence: high-dimensional field comparison. Symmetric KL and\n Wasserstein-1 (EMD) distances between each mesh's SOM fingerprint and a reference,\n and between consecutive meshes. Decreasing, plateauing distances toward the finest\n mesh indicate sufficiency. Complements (does not replace) numerical uncertainty analysis.\n- barmesh_richardson: classical grid-convergence index on scalar QoIs.\n\n## Workflow (read-only first)\n1. barmesh_guide_workflow — orient and confirm your plan includes CFD tools.\n2. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V).\n3. barmesh_datasets(action=upload) then preview.\n4. barmesh_mesh_convergence (and/or barmesh_richardson) — returns a job id.\n5. barmesh_training_monitor(job_id) or barmesh_jobs(action=monitor) — blocks with throttled snapshots until done (preferred); or barmesh_jobs(status) for one-shot polls every 10-20s.\n6. barmesh_results(action=get) — distances, convergence reading, and figures; then\n barmesh_results_explorer(job_id) to browse every figure interactively.\n\nThese tools are gated by the 'cfd' entitlement; analysis calls return 403 if your plan\ndoes not include it."});o(i,g,g,{mimeType:t},async()=>{const e=await l("barmesh-results-explorer");return{contents:[{uri:g,mimeType:t,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),p(i),v(i),b(i),f(i),u(i),y(i),_(i),j(i);try{const e=await n(c,m,l);d(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("barmesh viz server failed to start:",e)}const w=i.server;w.oninitialized=()=>{const e=w.getClientCapabilities(),r=s(e);h(!!r?.mimeTypes?.includes(t))};const x=new r;await i.connect(x),console.error(`barmesh-mcp ${a} ready (API: ${process.env.BARIVIA_API_URL??"https://api.barivia.se"})`)})().catch(e=>{console.error("Fatal error starting barmesh-mcp:",e),process.exit(1)});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side job monitor for barmesh async workflows.
|
|
3
|
+
* Polls GET /v1/jobs/:id until terminal (or block_until timeout) and emits
|
|
4
|
+
* compact throttled snapshots so agents avoid manual status poll loops.
|
|
5
|
+
*/
|
|
6
|
+
import { apiCall } from "./shared.js";
|
|
7
|
+
import { formatJobStatusText } from "./job_status_format.js";
|
|
8
|
+
import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "./cfd_finalize.js";
|
|
9
|
+
export const DEFAULT_BLOCK_UNTIL_SEC = 900;
|
|
10
|
+
export const DEFAULT_POLL_INTERVAL_SEC = 5;
|
|
11
|
+
export const MIN_POLL_INTERVAL_SEC = 5;
|
|
12
|
+
export const HEARTBEAT_POLLS = 1;
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
}
|
|
16
|
+
function num(data, key) {
|
|
17
|
+
const v = data[key];
|
|
18
|
+
if (v == null || Number.isNaN(Number(v)))
|
|
19
|
+
return undefined;
|
|
20
|
+
return Number(v);
|
|
21
|
+
}
|
|
22
|
+
function str(data, key) {
|
|
23
|
+
const v = data[key];
|
|
24
|
+
if (v == null || String(v) === "")
|
|
25
|
+
return undefined;
|
|
26
|
+
return String(v);
|
|
27
|
+
}
|
|
28
|
+
function tailOrderingErrors(data, n = 4) {
|
|
29
|
+
const raw = data.ordering_errors;
|
|
30
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
return raw.slice(-n).map((x) => Number(x)).filter((x) => !Number.isNaN(x));
|
|
33
|
+
}
|
|
34
|
+
export function snapshotFromJob(data, elapsedSec, note) {
|
|
35
|
+
const status = String(data.status ?? "unknown");
|
|
36
|
+
const progress_pct = (data.progress ?? 0) * 100;
|
|
37
|
+
const snap = {
|
|
38
|
+
elapsed_sec: elapsedSec,
|
|
39
|
+
status,
|
|
40
|
+
progress_pct: Math.round(progress_pct * 10) / 10,
|
|
41
|
+
};
|
|
42
|
+
const phase = str(data, "progress_phase");
|
|
43
|
+
if (phase)
|
|
44
|
+
snap.phase = phase;
|
|
45
|
+
const epoch = num(data, "epoch");
|
|
46
|
+
const total = num(data, "total_epochs");
|
|
47
|
+
if (epoch != null)
|
|
48
|
+
snap.epoch = epoch;
|
|
49
|
+
if (total != null)
|
|
50
|
+
snap.total_epochs = total;
|
|
51
|
+
const qe = num(data, "quantization_error");
|
|
52
|
+
const te = num(data, "topographic_error");
|
|
53
|
+
if (qe != null)
|
|
54
|
+
snap.qe = Math.round(qe * 10_000) / 10_000;
|
|
55
|
+
if (te != null)
|
|
56
|
+
snap.te = Math.round(te * 10_000) / 10_000;
|
|
57
|
+
const eta = num(data, "training_eta_sec");
|
|
58
|
+
if (eta != null && eta > 0)
|
|
59
|
+
snap.eta_sec = Math.round(eta);
|
|
60
|
+
const tail = tailOrderingErrors(data);
|
|
61
|
+
if (tail && tail.length > 0)
|
|
62
|
+
snap.ordering_errors_tail = tail;
|
|
63
|
+
if (note)
|
|
64
|
+
snap.note = note;
|
|
65
|
+
return snap;
|
|
66
|
+
}
|
|
67
|
+
/** True when a new snapshot is worth recording (phase/epoch/progress/status change). */
|
|
68
|
+
export function shouldRecordSnapshot(prev, next) {
|
|
69
|
+
if (!prev)
|
|
70
|
+
return true;
|
|
71
|
+
if (prev.status !== next.status)
|
|
72
|
+
return true;
|
|
73
|
+
if (next.note)
|
|
74
|
+
return true;
|
|
75
|
+
if (prev.phase !== next.phase)
|
|
76
|
+
return true;
|
|
77
|
+
if (prev.epoch !== next.epoch)
|
|
78
|
+
return true;
|
|
79
|
+
if (Math.abs(prev.progress_pct - next.progress_pct) >= 1)
|
|
80
|
+
return true;
|
|
81
|
+
if (prev.qe !== next.qe || prev.te !== next.te)
|
|
82
|
+
return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
export function formatSnapshotLine(s) {
|
|
86
|
+
const parts = [`[+${s.elapsed_sec}s] ${s.status} ${s.progress_pct.toFixed(1)}%`];
|
|
87
|
+
if (s.phase)
|
|
88
|
+
parts.push(`phase ${s.phase}`);
|
|
89
|
+
if (s.epoch != null && s.total_epochs != null)
|
|
90
|
+
parts.push(`epoch ${s.epoch}/${s.total_epochs}`);
|
|
91
|
+
if (s.qe != null)
|
|
92
|
+
parts.push(`QE ${s.qe.toFixed(4)}`);
|
|
93
|
+
if (s.te != null)
|
|
94
|
+
parts.push(`TE ${s.te.toFixed(4)}`);
|
|
95
|
+
if (s.eta_sec != null)
|
|
96
|
+
parts.push(`ETA ~${s.eta_sec}s`);
|
|
97
|
+
if (s.ordering_errors_tail?.length) {
|
|
98
|
+
parts.push(`ordering_errors tail [${s.ordering_errors_tail.map((x) => x.toFixed(4)).join(", ")}]`);
|
|
99
|
+
}
|
|
100
|
+
if (s.note)
|
|
101
|
+
parts.push(s.note);
|
|
102
|
+
return parts.join(" | ");
|
|
103
|
+
}
|
|
104
|
+
export function formatMonitorText(result, opts) {
|
|
105
|
+
const lines = [
|
|
106
|
+
`Job ${result.job_id} monitor (block_until=${opts.block_until_sec}s, poll=${opts.poll_interval_sec}s):`,
|
|
107
|
+
];
|
|
108
|
+
for (const s of result.snapshots)
|
|
109
|
+
lines.push(formatSnapshotLine(s));
|
|
110
|
+
lines.push("");
|
|
111
|
+
if (result.timed_out) {
|
|
112
|
+
lines.push(`Timed out before terminal state. Last status: ${result.status_text}`);
|
|
113
|
+
lines.push(`Re-run barmesh_training_monitor(job_id="${result.job_id}") or barmesh_jobs(action=monitor, job_id="${result.job_id}").`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
lines.push(`Terminal: ${result.status_text}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push(result.suggested_next_step);
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
function suggestedNextStep(job_id, data) {
|
|
122
|
+
const status = String(data.status ?? "");
|
|
123
|
+
if (status === "completed") {
|
|
124
|
+
const finalizeId = str(data, "finalize_job_id");
|
|
125
|
+
if (finalizeId) {
|
|
126
|
+
return `Compute done; cfd_finalize may still be running. Poll barmesh_jobs(status) or re-run monitor with wait_finalize=true, then barmesh_results(action=get, job_id="${job_id}").`;
|
|
127
|
+
}
|
|
128
|
+
return `Next: barmesh_results(action=get, job_id="${job_id}") or barmesh_results_explorer(job_id="${job_id}").`;
|
|
129
|
+
}
|
|
130
|
+
if (status === "failed") {
|
|
131
|
+
const stage = str(data, "failure_stage");
|
|
132
|
+
return `Job failed${stage ? ` at ${stage}` : ""}. Read the error above before retrying.`;
|
|
133
|
+
}
|
|
134
|
+
if (status === "cancelled")
|
|
135
|
+
return `Job cancelled. Confirm with barmesh_jobs(action=status, job_id="${job_id}").`;
|
|
136
|
+
return `Still running — re-run barmesh_training_monitor(job_id="${job_id}") to continue waiting.`;
|
|
137
|
+
}
|
|
138
|
+
async function fetchTrainingLogHint(job_id, data) {
|
|
139
|
+
const status = String(data.status ?? "");
|
|
140
|
+
if (status !== "completed" && status !== "failed")
|
|
141
|
+
return undefined;
|
|
142
|
+
try {
|
|
143
|
+
await apiCall("GET", `/v1/results/${job_id}/training-log`);
|
|
144
|
+
return `Training log available at GET /v1/results/${job_id}/training-log (learning_curve.png in results after finalize).`;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export async function monitorJob(job_id, options = {}) {
|
|
151
|
+
const block_until_sec = Math.max(30, options.block_until_sec ?? DEFAULT_BLOCK_UNTIL_SEC);
|
|
152
|
+
const poll_interval_sec = Math.max(MIN_POLL_INTERVAL_SEC, options.poll_interval_sec ?? DEFAULT_POLL_INTERVAL_SEC);
|
|
153
|
+
const wait_finalize = options.wait_finalize !== false;
|
|
154
|
+
const blockMs = block_until_sec * 1000;
|
|
155
|
+
const pollMs = poll_interval_sec * 1000;
|
|
156
|
+
const start = Date.now();
|
|
157
|
+
const snapshots = [];
|
|
158
|
+
let lastSnap = null;
|
|
159
|
+
let data = {};
|
|
160
|
+
let heartbeat = 0;
|
|
161
|
+
while (Date.now() - start < blockMs) {
|
|
162
|
+
data = (await apiCall("GET", `/v1/jobs/${job_id}`));
|
|
163
|
+
const elapsedSec = Math.round((Date.now() - start) / 1000);
|
|
164
|
+
const snap = snapshotFromJob(data, elapsedSec);
|
|
165
|
+
heartbeat += 1;
|
|
166
|
+
const heartbeatDue = heartbeat >= HEARTBEAT_POLLS;
|
|
167
|
+
if (shouldRecordSnapshot(lastSnap, snap) || heartbeatDue) {
|
|
168
|
+
snapshots.push(snap);
|
|
169
|
+
lastSnap = snap;
|
|
170
|
+
heartbeat = 0;
|
|
171
|
+
}
|
|
172
|
+
const status = String(data.status ?? "");
|
|
173
|
+
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
await sleep(pollMs);
|
|
177
|
+
}
|
|
178
|
+
const status = String(data.status ?? "");
|
|
179
|
+
const terminal = status === "completed" || status === "failed" || status === "cancelled";
|
|
180
|
+
const timed_out = !terminal;
|
|
181
|
+
if (terminal && status === "completed" && wait_finalize && data.finalize_job_id) {
|
|
182
|
+
const finalizeId = String(data.finalize_job_id);
|
|
183
|
+
const elapsedSec = Math.round((Date.now() - start) / 1000);
|
|
184
|
+
snapshots.push(snapshotFromJob(data, elapsedSec, `cfd_finalize ${finalizeId} started — waiting for figure render`));
|
|
185
|
+
try {
|
|
186
|
+
const { note } = await pollCfdFinalizeIfPresent(job_id, data, Math.max(0, blockMs - (Date.now() - start)));
|
|
187
|
+
data = await refreshJobAfterFinalize(job_id);
|
|
188
|
+
snapshots.push(snapshotFromJob(data, Math.round((Date.now() - start) / 1000), note ?? `cfd_finalize ${finalizeId} completed`));
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
snapshots.push(snapshotFromJob(data, Math.round((Date.now() - start) / 1000), `cfd_finalize failed: ${err.message}`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const statusText = await formatJobStatusText(job_id, data);
|
|
195
|
+
const logHint = terminal ? await fetchTrainingLogHint(job_id, data) : undefined;
|
|
196
|
+
let suggested = suggestedNextStep(job_id, data);
|
|
197
|
+
if (logHint)
|
|
198
|
+
suggested += ` ${logHint}`;
|
|
199
|
+
return {
|
|
200
|
+
job_id,
|
|
201
|
+
terminal,
|
|
202
|
+
timed_out,
|
|
203
|
+
snapshots,
|
|
204
|
+
status_text: statusText,
|
|
205
|
+
data,
|
|
206
|
+
suggested_next_step: suggested,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Surfaces phase, elapsed, ETA, and epoch N/M during the SOM training of a
|
|
4
4
|
* cfd_mesh_convergence job, plus CFD finalize (figure render) sub-status.
|
|
5
5
|
*/
|
|
6
|
+
import { apiCall } from "./shared.js";
|
|
6
7
|
function formatElapsedPart(data, status) {
|
|
7
8
|
const wall = data.wall_elapsed_sec != null && !Number.isNaN(Number(data.wall_elapsed_sec))
|
|
8
9
|
? Number(data.wall_elapsed_sec)
|
|
@@ -32,7 +33,27 @@ function formatElapsedPart(data, status) {
|
|
|
32
33
|
}
|
|
33
34
|
return null;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
+
async function finalizeStatusLine(finalizeId) {
|
|
37
|
+
try {
|
|
38
|
+
const fin = (await apiCall("GET", `/v1/jobs/${finalizeId}`));
|
|
39
|
+
const st = String(fin.status ?? "unknown");
|
|
40
|
+
const prog = fin.progress != null ? `${(Number(fin.progress) * 100).toFixed(0)}%` : null;
|
|
41
|
+
const phase = fin.progress_phase != null ? String(fin.progress_phase) : null;
|
|
42
|
+
const elapsed = formatElapsedPart(fin, st);
|
|
43
|
+
const bits = [`cfd_finalize ${finalizeId}: ${st}`];
|
|
44
|
+
if (prog)
|
|
45
|
+
bits.push(prog);
|
|
46
|
+
if (phase)
|
|
47
|
+
bits.push(`phase ${phase}`);
|
|
48
|
+
if (elapsed)
|
|
49
|
+
bits.push(elapsed);
|
|
50
|
+
return bits.join(", ");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return `cfd_finalize ${finalizeId} still running (poll barmesh_jobs(action=status, job_id="${finalizeId}"))`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function formatJobStatusText(job_id, data) {
|
|
36
57
|
const status = String(data.status ?? "unknown");
|
|
37
58
|
const progress = (data.progress ?? 0) * 100;
|
|
38
59
|
const label = data.label != null && data.label !== "" ? String(data.label) : null;
|
|
@@ -72,7 +93,8 @@ export function formatJobStatusText(job_id, data) {
|
|
|
72
93
|
if (status === "completed") {
|
|
73
94
|
const finalizeId = data.finalize_job_id != null && String(data.finalize_job_id) !== "" ? String(data.finalize_job_id) : null;
|
|
74
95
|
if (finalizeId) {
|
|
75
|
-
parts.push(
|
|
96
|
+
parts.push(await finalizeStatusLine(finalizeId));
|
|
97
|
+
parts.push(`Compute done; figures may still be uploading. Poll barmesh_jobs(status) until finalize completes before barmesh_results(get).`);
|
|
76
98
|
}
|
|
77
99
|
else {
|
|
78
100
|
parts.push(`Results ready. Use barmesh_results(action=get, job_id="${job_id}") for distances and figures, or barmesh_results_explorer(job_id="${job_id}") to browse interactively.`);
|
package/dist/shared.js
CHANGED
|
@@ -22,7 +22,7 @@ export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ??
|
|
|
22
22
|
export const MAX_RETRIES = 2;
|
|
23
23
|
export const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
24
24
|
/** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
|
|
25
|
-
export const CLIENT_VERSION = "0.5.
|
|
25
|
+
export const CLIENT_VERSION = "0.5.3";
|
|
26
26
|
export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
|
|
27
27
|
/** Large per-cell CSV uploads may exceed the default fetch timeout. */
|
|
28
28
|
export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
|
|
@@ -157,6 +157,14 @@ export function getSandboxRoot() {
|
|
|
157
157
|
return pwd;
|
|
158
158
|
return process.cwd();
|
|
159
159
|
}
|
|
160
|
+
export function sandboxPath(userPath, root) {
|
|
161
|
+
const resolved = path.resolve(root, userPath);
|
|
162
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
163
|
+
throw new Error("Path must be within the workspace directory. " +
|
|
164
|
+
"Set BARIVIA_WORKSPACE_ROOT to your project path if needed.");
|
|
165
|
+
}
|
|
166
|
+
return resolved;
|
|
167
|
+
}
|
|
160
168
|
export async function getWorkspaceRootAsync(mcpServer) {
|
|
161
169
|
try {
|
|
162
170
|
const result = await mcpServer.server.listRoots();
|
|
@@ -378,6 +386,12 @@ export function mimeForFilename(fname) {
|
|
|
378
386
|
/** Per-image caption for multimodal LLM context (what to look for). */
|
|
379
387
|
export function getCaptionForImage(filename) {
|
|
380
388
|
const base = filename.replace(/\.(png|pdf|svg)$/i, "");
|
|
389
|
+
if (base === "combined")
|
|
390
|
+
return "All feature component planes on the trained SOM in one grid — primary comparison artifact (barsom-style combined view).";
|
|
391
|
+
if (base === "overview_distances")
|
|
392
|
+
return "Diagnostic mosaic: volume fingerprints plus KL and EMD vs the reference mesh.";
|
|
393
|
+
if (base === "learning_curve")
|
|
394
|
+
return "Quantization error by epoch: ordering phase (steel blue) then convergence (coral).";
|
|
381
395
|
if (base === "KL_ref")
|
|
382
396
|
return "KL divergence to the reference mesh: steel blue is from the reference, coral is to the reference. Lower and converging toward the finest mesh indicates sufficiency.";
|
|
383
397
|
if (base === "KL_stepwise")
|
|
@@ -3,10 +3,47 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
|
3
3
|
import { registerAuditedTool, runMcpToolAudit } from "../audit.js";
|
|
4
4
|
import { apiCall, apiRawCall, getClientSupportsMcpApps, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
|
|
5
5
|
export const RESULTS_EXPLORER_URI = "ui://barmesh/results-explorer";
|
|
6
|
+
const FIGURE_SORT_RANK = {
|
|
7
|
+
combined: 0,
|
|
8
|
+
overview_distances: 1,
|
|
9
|
+
KL_ref: 10,
|
|
10
|
+
KL_stepwise: 11,
|
|
11
|
+
EMD_ref: 12,
|
|
12
|
+
EMD_stepwise: 13,
|
|
13
|
+
learning_curve: 14,
|
|
14
|
+
plot_vol_coarse_fine: 20,
|
|
15
|
+
};
|
|
16
|
+
function sortRank(key) {
|
|
17
|
+
if (key in FIGURE_SORT_RANK)
|
|
18
|
+
return FIGURE_SORT_RANK[key];
|
|
19
|
+
if (key.startsWith("plot_vol"))
|
|
20
|
+
return 25;
|
|
21
|
+
if (key.startsWith("plot_"))
|
|
22
|
+
return 30;
|
|
23
|
+
return 50;
|
|
24
|
+
}
|
|
25
|
+
export function sortFigures(figures) {
|
|
26
|
+
return [...figures].sort((a, b) => sortRank(a.key) - sortRank(b.key) || a.label.localeCompare(b.label));
|
|
27
|
+
}
|
|
28
|
+
export function resolveDefaultFigureKey(figures) {
|
|
29
|
+
const byKey = (k) => figures.find((f) => f.key === k);
|
|
30
|
+
if (byKey("combined"))
|
|
31
|
+
return "combined";
|
|
32
|
+
const raster = figures.find((f) => /\.(png|svg)$/i.test(f.filename));
|
|
33
|
+
if (raster)
|
|
34
|
+
return raster.key;
|
|
35
|
+
if (byKey("KL_ref"))
|
|
36
|
+
return "KL_ref";
|
|
37
|
+
return figures[0]?.key;
|
|
38
|
+
}
|
|
6
39
|
function labelForFigure(filename) {
|
|
7
40
|
const base = filename.replace(/\.(png|pdf|svg)$/i, "");
|
|
8
41
|
if (base === "combined")
|
|
9
|
-
return "
|
|
42
|
+
return "Component planes (overview)";
|
|
43
|
+
if (base === "overview_distances")
|
|
44
|
+
return "Distances & volume overview";
|
|
45
|
+
if (base === "learning_curve")
|
|
46
|
+
return "Learning curve (QE by epoch)";
|
|
10
47
|
if (base === "KL_ref")
|
|
11
48
|
return "KL divergence vs reference";
|
|
12
49
|
if (base === "KL_stepwise")
|
|
@@ -65,10 +102,9 @@ function buildHighlights(summary) {
|
|
|
65
102
|
/**
|
|
66
103
|
* Group `<base>.<ext>` figure files into one logical figure per base, collecting the
|
|
67
104
|
* available formats. Preview is the inline-displayable raster (PNG, else SVG); the
|
|
68
|
-
* download target is the best quality (SVG > PDF > PNG).
|
|
69
|
-
* in `files` (combined first, then diagnostics, then component planes).
|
|
105
|
+
* download target is the best quality (SVG > PDF > PNG).
|
|
70
106
|
*/
|
|
71
|
-
function groupFigures(files) {
|
|
107
|
+
export function groupFigures(files) {
|
|
72
108
|
const byBase = new Map();
|
|
73
109
|
for (const f of files) {
|
|
74
110
|
const m = f.match(/^(.*)\.(png|pdf|svg)$/i);
|
|
@@ -85,10 +121,14 @@ function groupFigures(files) {
|
|
|
85
121
|
const preview = extSet.has("png") ? `${base}.png` : extSet.has("svg") ? `${base}.svg` : `${base}.${formats[0]}`;
|
|
86
122
|
const downloadFilename = extSet.has("svg") ? `${base}.svg` : extSet.has("pdf") ? `${base}.pdf` : `${base}.png`;
|
|
87
123
|
const vector = formats.filter((e) => e === "pdf" || e === "svg");
|
|
124
|
+
const vectorOnly = !/\.(png|svg)$/i.test(preview);
|
|
88
125
|
const baseCaption = getCaptionForImage(preview) || "";
|
|
89
126
|
const vectorNote = vector.length > 0
|
|
90
127
|
? `High-quality ${vector.join("/").toUpperCase()} available — download it (standalone page) or fetch with barmesh_results(action=image, filename="${downloadFilename}").`
|
|
91
128
|
: "";
|
|
129
|
+
const emptyNote = vectorOnly
|
|
130
|
+
? "Vector figure — no inline preview. Use Download or barmesh_results(action=render, format=png) for a raster."
|
|
131
|
+
: "";
|
|
92
132
|
return {
|
|
93
133
|
key: base,
|
|
94
134
|
label: labelForFigure(preview),
|
|
@@ -96,17 +136,14 @@ function groupFigures(files) {
|
|
|
96
136
|
downloadFilename,
|
|
97
137
|
formats,
|
|
98
138
|
kind: figureKind(base),
|
|
99
|
-
caption: [baseCaption, vectorNote].filter(Boolean).join(" ") || undefined,
|
|
139
|
+
caption: [baseCaption, vectorNote, emptyNote].filter(Boolean).join(" ") || undefined,
|
|
100
140
|
};
|
|
101
141
|
});
|
|
102
142
|
}
|
|
103
143
|
export function buildPayload(jobId, data) {
|
|
104
144
|
const summary = (data.summary ?? {});
|
|
105
145
|
const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
|
|
106
|
-
|
|
107
|
-
// lazy-load as base64 through the _barmesh_fetch_figure bridge tool. One entry per
|
|
108
|
-
// logical figure; PNG previews inline, vector (PDF/SVG) is offered for download.
|
|
109
|
-
const figures = groupFigures(files);
|
|
146
|
+
const figures = sortFigures(groupFigures(files));
|
|
110
147
|
const port = getVizPort();
|
|
111
148
|
return {
|
|
112
149
|
type: "barmesh-results-explorer",
|
|
@@ -116,9 +153,7 @@ export function buildPayload(jobId, data) {
|
|
|
116
153
|
metrics: buildMetrics(summary),
|
|
117
154
|
highlights: buildHighlights(summary),
|
|
118
155
|
availableFigures: figures,
|
|
119
|
-
defaultFigureKey: figures
|
|
120
|
-
figures.find((f) => f.key === "KL_ref")?.key ??
|
|
121
|
-
figures[0]?.key,
|
|
156
|
+
defaultFigureKey: resolveDefaultFigureKey(figures),
|
|
122
157
|
standaloneUrl: port
|
|
123
158
|
? `http://localhost:${port}/viz/barmesh-results-explorer?mode=standalone&job_id=${jobId}`
|
|
124
159
|
: undefined,
|
|
@@ -140,7 +175,7 @@ async function handleResultsExplorer(job_id) {
|
|
|
140
175
|
},
|
|
141
176
|
];
|
|
142
177
|
const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
|
|
143
|
-
if (defaultFigure) {
|
|
178
|
+
if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
|
|
144
179
|
await tryAttachImage(content, job_id, defaultFigure.filename);
|
|
145
180
|
}
|
|
146
181
|
if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
|
|
@@ -156,16 +191,13 @@ async function handleResultsExplorer(job_id) {
|
|
|
156
191
|
export function registerResultsExplorerTool(server) {
|
|
157
192
|
const toolConfig = {
|
|
158
193
|
title: "Mesh Convergence Results Explorer",
|
|
159
|
-
description: "PREFERRED way to browse a completed mesh_convergence job after a first barmesh_results(get) glance. Interactive explorer
|
|
194
|
+
description: "PREFERRED way to browse a completed mesh_convergence job after a first barmesh_results(get) glance. Interactive explorer with a figure dropdown above the plot (component-plane mosaic headline, distances, volume fingerprints, learning curve, per-feature planes). Each figure shows its PNG preview inline (PDFs cannot render in the sandboxed panel) and offers the best-quality vector (PDF/SVG) for download when rendered (barmesh_results(action=render, format=pdf)). Embeds as an MCP App or falls back to a standalone localhost page. barmesh_results(action=get) remains the headless/metrics path.",
|
|
160
195
|
inputSchema: {
|
|
161
196
|
job_id: z.string().describe("Job ID of a completed cfd_mesh_convergence job"),
|
|
162
197
|
},
|
|
163
198
|
_meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
|
|
164
199
|
};
|
|
165
200
|
registerAppTool(server, "barmesh_results_explorer", toolConfig, async (args) => runMcpToolAudit("barmesh_results_explorer", "default", args, () => handleResultsExplorer(String(args.job_id))));
|
|
166
|
-
// MUST stay a registered tool: the explorer App view lazy-loads non-default raster
|
|
167
|
-
// figures through the App bridge (mcpApp.callServerTool), which can only reach
|
|
168
|
-
// registered server tools. Agents must not call it directly from chat.
|
|
169
201
|
registerAuditedTool(server, "_barmesh_fetch_figure", "[INTERNAL — host / MCP App only; agents MUST NOT call this.] The Mesh Convergence Results Explorer view uses it to lazy-load one raster figure as base64. From chat, use barmesh_results(action=get) or barmesh_results_explorer instead.", {
|
|
170
202
|
job_id: z.string(),
|
|
171
203
|
filename: z.string(),
|
package/dist/tools/cfd.js
CHANGED
|
@@ -47,7 +47,7 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
|
|
|
47
47
|
const id = data.id;
|
|
48
48
|
if (id != null) {
|
|
49
49
|
const prep = data.prepare_job_id != null ? " (dataset prepare complete)" : "";
|
|
50
|
-
data.suggested_next_step = `
|
|
50
|
+
data.suggested_next_step = `Run barmesh_training_monitor(job_id="${id}") or barmesh_jobs(action=monitor, job_id="${id}")${prep}; on completion call barmesh_results(action=get, job_id="${id}").`;
|
|
51
51
|
}
|
|
52
52
|
return textResult(data);
|
|
53
53
|
});
|
|
@@ -80,7 +80,7 @@ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with diff
|
|
|
80
80
|
const data = (await apiCall("POST", "/v1/cfd/richardson", body));
|
|
81
81
|
const id = data.id;
|
|
82
82
|
if (id != null)
|
|
83
|
-
data.suggested_next_step = `
|
|
83
|
+
data.suggested_next_step = `Run barmesh_training_monitor(job_id="${id}") or barmesh_jobs(action=monitor, job_id="${id}"); on completion call barmesh_results(action=get, job_id="${id}").`;
|
|
84
84
|
return textResult(data);
|
|
85
85
|
});
|
|
86
86
|
}
|
package/dist/tools/guide.js
CHANGED
|
@@ -2,7 +2,7 @@ import { registerAuditedTool } from "../audit.js";
|
|
|
2
2
|
import { apiCall, textResult } from "../shared.js";
|
|
3
3
|
const OFFLINE_GUIDE = `barmesh: CFD mesh-convergence on the Barivia API.
|
|
4
4
|
Two tracks: barmesh_mesh_convergence (SOM fingerprint distances) and barmesh_richardson (classical GCI).
|
|
5
|
-
Workflow: barmesh_prepare_mesh_data -> barmesh_datasets(upload) -> barmesh_mesh_convergence / barmesh_richardson -> barmesh_jobs
|
|
5
|
+
Workflow: barmesh_prepare_mesh_data -> barmesh_datasets(upload) -> barmesh_mesh_convergence / barmesh_richardson -> barmesh_training_monitor (or barmesh_jobs monitor/status) -> barmesh_results(get).
|
|
6
6
|
(API unreachable; this is the offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
|
|
7
7
|
const OFFLINE_PREP = `barmesh mesh-data prep (offline summary; API unreachable):
|
|
8
8
|
Build ONE combined per-cell CSV across all meshes of the refinement study:
|
package/dist/tools/jobs.js
CHANGED
|
@@ -3,28 +3,53 @@ import { registerAuditedTool } from "../audit.js";
|
|
|
3
3
|
import { apiCall, textResult } from "../shared.js";
|
|
4
4
|
import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "../cfd_finalize.js";
|
|
5
5
|
import { formatJobStatusText } from "../job_status_format.js";
|
|
6
|
+
import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC } from "../job_monitor.js";
|
|
7
|
+
import { runMonitor } from "./training_monitor.js";
|
|
6
8
|
export function registerJobsTool(server) {
|
|
7
|
-
registerAuditedTool(server, "barmesh_jobs", `Check job status or list jobs.
|
|
9
|
+
registerAuditedTool(server, "barmesh_jobs", `Check job status, block until terminal, or list jobs.
|
|
8
10
|
|
|
9
|
-
BEST FOR:
|
|
10
|
-
ASYNC PROTOCOL:
|
|
11
|
+
BEST FOR: action=monitor after submit (one call, throttled snapshots — preferred for agents). action=status for a single one-shot check.
|
|
12
|
+
ASYNC PROTOCOL: monitor blocks server-side until completed/failed or block_until timeout (default ${DEFAULT_BLOCK_UNTIL_SEC}s, poll every ${DEFAULT_POLL_INTERVAL_SEC}s). status is one-shot; poll every 10-20s manually if not using monitor. When status=completed, call barmesh_results(action=get, job_id=...).
|
|
11
13
|
ESCALATION: status=failed returns an error message and (when available) a failure_stage; read it before retrying.`, {
|
|
12
|
-
action: z
|
|
13
|
-
|
|
14
|
+
action: z
|
|
15
|
+
.enum(["status", "monitor", "list"])
|
|
16
|
+
.describe("status: one-shot check; monitor: block until terminal with snapshots; list: recent jobs"),
|
|
17
|
+
job_id: z.string().optional().describe("Job ID (required for status and monitor)"),
|
|
18
|
+
block_until_sec: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.min(30)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(`action=monitor only: max wait seconds (default ${DEFAULT_BLOCK_UNTIL_SEC})`),
|
|
24
|
+
poll_interval_sec: z
|
|
25
|
+
.number()
|
|
26
|
+
.int()
|
|
27
|
+
.min(5)
|
|
28
|
+
.optional()
|
|
29
|
+
.describe(`action=monitor only: poll interval (default ${DEFAULT_POLL_INTERVAL_SEC})`),
|
|
30
|
+
wait_finalize: z
|
|
31
|
+
.boolean()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("action=monitor only: wait for cfd_finalize (default true)"),
|
|
14
34
|
}, async (args) => {
|
|
15
|
-
const { action, job_id } = args;
|
|
35
|
+
const { action, job_id, block_until_sec, poll_interval_sec, wait_finalize } = args;
|
|
36
|
+
if (action === "monitor") {
|
|
37
|
+
if (!job_id)
|
|
38
|
+
throw new Error("barmesh_jobs(monitor) requires job_id.");
|
|
39
|
+
return runMonitor({ job_id, block_until_sec, poll_interval_sec, wait_finalize });
|
|
40
|
+
}
|
|
16
41
|
if (action === "status") {
|
|
17
42
|
if (!job_id)
|
|
18
43
|
throw new Error("barmesh_jobs(status) requires job_id.");
|
|
19
44
|
let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
|
|
20
45
|
const status = String(data.status ?? "");
|
|
46
|
+
let note = null;
|
|
21
47
|
if (status === "completed" && data.finalize_job_id) {
|
|
22
|
-
|
|
48
|
+
({ note } = await pollCfdFinalizeIfPresent(job_id, data));
|
|
23
49
|
data = await refreshJobAfterFinalize(job_id);
|
|
24
|
-
const statusText = note ? `${formatJobStatusText(job_id, data)}\n${note}` : formatJobStatusText(job_id, data);
|
|
25
|
-
return textResult({ ...data, status_text: statusText });
|
|
26
50
|
}
|
|
27
|
-
|
|
51
|
+
const statusText = await formatJobStatusText(job_id, data);
|
|
52
|
+
return textResult({ ...data, status_text: note ? `${statusText}\n${note}` : statusText });
|
|
28
53
|
}
|
|
29
54
|
const data = await apiCall("GET", "/v1/jobs");
|
|
30
55
|
return textResult(data);
|