@barivia/barmesh-mcp 0.4.0 → 0.5.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 +29 -4
- package/dist/index.js +1 -1
- package/dist/job_status_format.js +89 -0
- package/dist/shared.js +84 -9
- package/dist/tools/barmesh_results_explorer.js +158 -0
- package/dist/tools/datasets.js +191 -28
- package/dist/tools/jobs.js +4 -9
- package/dist/tools/results.js +32 -7
- package/dist/upload_hints.js +1 -0
- package/dist/views/src/views/barmesh-results-explorer/index.html +180 -0
- package/dist/viz-server.js +124 -0
- package/package.json +9 -4
package/dist/tools/datasets.js
CHANGED
|
@@ -4,54 +4,140 @@ import { z } from "zod";
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { registerAuditedTool } from "../audit.js";
|
|
7
|
-
import { apiCall, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, } from "../shared.js";
|
|
7
|
+
import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, } from "../shared.js";
|
|
8
|
+
import { GZIP_UPLOAD_HINT } from "../upload_hints.js";
|
|
9
|
+
/**
|
|
10
|
+
* Normalize a nullable string field from the API. Returns "" for absent values,
|
|
11
|
+
* empty strings, and the literal SQL/serialization sentinels "missing"/"null".
|
|
12
|
+
*/
|
|
13
|
+
function cleanNullable(v) {
|
|
14
|
+
if (v == null)
|
|
15
|
+
return "";
|
|
16
|
+
const s = String(v).trim();
|
|
17
|
+
return s === "" || s.toLowerCase() === "missing" || s.toLowerCase() === "null" ? "" : s;
|
|
18
|
+
}
|
|
8
19
|
export function registerDatasetsTool(server) {
|
|
9
|
-
registerAuditedTool(server, "barmesh_datasets", `Upload, preview, or
|
|
20
|
+
registerAuditedTool(server, "barmesh_datasets", `Upload, preview, list, get, subset, or delete the combined per-cell mesh CSV used for convergence analysis.
|
|
21
|
+
|
|
22
|
+
Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~64 MB, prefer .csv.gz (often 2–3× smaller); large uploads use presigned direct-to-storage PUT and accept gzip bodies.
|
|
10
23
|
|
|
11
24
|
| Action | Use when |
|
|
12
25
|
|--------|----------|
|
|
13
26
|
| upload | You have prepared a combined per-cell CSV (mesh_id + feature columns + cell volume V). Do this first. |
|
|
14
27
|
| preview | After upload — verify the mesh column, feature columns, and volume column are present and numeric. |
|
|
28
|
+
| get | Fetch one dataset by id — status, staging fields, ingest_error (use after upload or when staging is slow). |
|
|
15
29
|
| list | Find dataset IDs for analysis. |
|
|
30
|
+
| subset | Shrink a huge per-cell table server-side (row_range, filters, or sample_n). |
|
|
31
|
+
| delete | Remove a dataset permanently. |
|
|
32
|
+
|
|
33
|
+
action=upload: PREFER file_path — server reads from workspace root (token-efficient). Accepts .csv, .tsv, .csv.gz, .tsv.gz. Use csv_data only for small inline pastes (<10KB). If plain CSV exceeds the 5 GB upload cap, gzip it first (.csv.gz).
|
|
16
34
|
|
|
17
35
|
BEST FOR: One combined CSV holding all meshes of a refinement study (one row per cell, a mesh label column, the physical channels, and a cell-volume column).
|
|
18
36
|
NOT FOR: Raw OpenFOAM case directories — extract a per-cell CSV first (see barmesh_prepare_mesh_data).
|
|
19
37
|
COMMON MISTAKES: omitting the cell-volume column (defaults to equal weights, which weakens the fingerprint); inconsistent feature columns across meshes.
|
|
20
38
|
ESCALATION: If preview shows a feature column as non-numeric, fix the extraction and re-upload.`, {
|
|
21
|
-
action: z
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
action: z
|
|
40
|
+
.enum(["upload", "preview", "list", "get", "subset", "delete"])
|
|
41
|
+
.describe("upload: add CSV or .csv.gz; preview: inspect columns; list: see all datasets; get: fetch one dataset metadata (status/staging); subset: create filtered subset; delete: remove dataset"),
|
|
42
|
+
name: z.string().optional().describe("Dataset name (required for upload and subset)"),
|
|
43
|
+
file_path: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Path to local CSV or .csv.gz (PREFERRED): absolute path, file:// URI, or path relative to the workspace root. NOTE: relative paths resolve against the MCP workspace root — in Cursor/IDE clients that root is often the MCP install dir, not your project, so set BARIVIA_WORKSPACE_ROOT in the MCP config env (or pass an absolute path) if a relative path is 'not accessible'. Use .csv.gz for large mesh tables."),
|
|
24
47
|
csv_data: z.string().optional().describe("Inline CSV string for small pastes only (<10KB). Prefer file_path."),
|
|
25
|
-
dataset_id: z.string().optional().describe("Dataset ID (required for preview)"),
|
|
48
|
+
dataset_id: z.string().optional().describe("Dataset ID (required for preview, get, subset, and delete)"),
|
|
26
49
|
n_rows: z.number().int().optional().default(5).describe("Sample rows to return (preview only)"),
|
|
27
|
-
|
|
28
|
-
|
|
50
|
+
row_range: z
|
|
51
|
+
.tuple([z.number().int(), z.number().int()])
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("For subset: [start, end] 1-based inclusive row range (e.g. [1, 2000])"),
|
|
54
|
+
filters: z.preprocess((v) => {
|
|
55
|
+
if (v === undefined || v === null)
|
|
56
|
+
return v;
|
|
57
|
+
if (Array.isArray(v))
|
|
58
|
+
return v;
|
|
59
|
+
if (typeof v === "object" && v !== null && "column" in v)
|
|
60
|
+
return [v];
|
|
61
|
+
return v;
|
|
62
|
+
}, z
|
|
63
|
+
.array(z.object({
|
|
64
|
+
column: z.string(),
|
|
65
|
+
op: z.enum(["eq", "ne", "in", "gt", "lt", "gte", "lte", "between"]),
|
|
66
|
+
value: z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]),
|
|
67
|
+
}))
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("For subset: filter conditions (AND logic). Single object or array.")),
|
|
70
|
+
filter: z
|
|
71
|
+
.object({
|
|
72
|
+
column: z.string(),
|
|
73
|
+
op: z.enum(["eq", "ne", "in", "gt", "lt", "gte", "lte", "between"]),
|
|
74
|
+
value: z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]),
|
|
75
|
+
})
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("Deprecated — use filters instead. Single filter condition."),
|
|
78
|
+
sample_n: z
|
|
79
|
+
.number()
|
|
80
|
+
.int()
|
|
81
|
+
.min(1)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("action=subset: keep a random N-row sample (seeded, row order preserved). Use to shrink a huge table server-side."),
|
|
84
|
+
sample_seed: z
|
|
85
|
+
.number()
|
|
86
|
+
.int()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("action=subset: RNG seed for sample_n (default 42)."),
|
|
89
|
+
}, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, sample_n, sample_seed, }) => {
|
|
29
90
|
if (action === "upload") {
|
|
30
91
|
if (!name)
|
|
31
92
|
throw new Error("barmesh_datasets(upload) requires name.");
|
|
32
93
|
let body;
|
|
33
94
|
if (file_path && file_path.length > 0) {
|
|
34
|
-
// Preflight: warm plan/limits and reject over-limit uploads before reading the file.
|
|
35
95
|
await apiCall("GET", "/v1/system/info");
|
|
36
96
|
const resolved = await resolveFilePathForUpload(file_path, server);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
97
|
+
const lower = resolved.toLowerCase();
|
|
98
|
+
const isGzipInput = lower.endsWith(".gz");
|
|
99
|
+
const baseExt = path.extname(isGzipInput ? lower.slice(0, -3) : lower);
|
|
100
|
+
if (baseExt !== ".csv" && baseExt !== ".tsv") {
|
|
101
|
+
throw new Error("Only .csv, .tsv, .csv.gz, or .tsv.gz files can be uploaded as datasets.");
|
|
40
102
|
}
|
|
41
103
|
const HARD_MAX_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
|
|
42
|
-
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = await fs.stat(resolved);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`File not accessible at resolved path. Easiest fix: pass an ABSOLUTE path ` +
|
|
110
|
+
`(e.g. "/home/you/project/data.csv" or "C:\\\\Users\\\\you\\\\data.csv") or a file:// URI. ` +
|
|
111
|
+
`Relative paths resolve against the MCP workspace root (current: ${await getWorkspaceRootAsync(server)}); ` +
|
|
112
|
+
`set BARIVIA_WORKSPACE_ROOT in your MCP config env to your project directory to use them.`);
|
|
113
|
+
}
|
|
43
114
|
if (stat.size > HARD_MAX_BYTES) {
|
|
44
|
-
|
|
115
|
+
const gzipHint = isGzipInput ? "" : ` ${GZIP_UPLOAD_HINT}`;
|
|
116
|
+
throw new Error(`File too large (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB). Maximum upload size is 5 GB.${gzipHint}`);
|
|
45
117
|
}
|
|
46
118
|
if (stat.size >= LARGE_UPLOAD_BYTES) {
|
|
47
119
|
const idem = await streamFileSha256(resolved);
|
|
48
|
-
|
|
120
|
+
let init;
|
|
121
|
+
try {
|
|
122
|
+
init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
126
|
+
if (msg.includes("dataset_too_large") && !isGzipInput) {
|
|
127
|
+
throw new Error(`${msg} ${GZIP_UPLOAD_HINT}`);
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
49
131
|
const datasetId = (init.dataset_id ?? init.id);
|
|
50
|
-
if (init.idempotent_replay) {
|
|
51
|
-
return textResult({
|
|
52
|
-
|
|
132
|
+
if (init.idempotent_replay && !init.upload_url) {
|
|
133
|
+
return textResult({
|
|
134
|
+
id: datasetId,
|
|
135
|
+
status: init.status,
|
|
136
|
+
idempotent_replay: true,
|
|
137
|
+
suggested_next_step: `barmesh_datasets(action=preview, dataset_id=${datasetId})`,
|
|
138
|
+
});
|
|
53
139
|
}
|
|
54
|
-
await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS);
|
|
140
|
+
await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
|
|
55
141
|
const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
|
|
56
142
|
const jobId = (fin.id ?? fin.job_id);
|
|
57
143
|
const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
|
|
@@ -59,10 +145,28 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
|
|
|
59
145
|
return textResult({ id: datasetId, status: "failed", error: poll.error ?? "staging failed" });
|
|
60
146
|
}
|
|
61
147
|
const ready = poll.status === "completed";
|
|
62
|
-
return textResult({
|
|
148
|
+
return textResult({
|
|
149
|
+
id: datasetId,
|
|
150
|
+
status: ready ? "ready" : "staging",
|
|
151
|
+
job_id: jobId,
|
|
63
152
|
suggested_next_step: ready
|
|
64
|
-
? `barmesh_datasets(action=preview, dataset_id=${datasetId})
|
|
65
|
-
: `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}")
|
|
153
|
+
? `barmesh_datasets(action=preview, dataset_id=${datasetId}) to verify mesh, feature, and volume columns.`
|
|
154
|
+
: `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}") then barmesh_datasets(action=preview, dataset_id=${datasetId}).`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (isGzipInput) {
|
|
158
|
+
const gzBytes = await fs.readFile(resolved);
|
|
159
|
+
const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
|
|
160
|
+
"X-Dataset-Name": name,
|
|
161
|
+
"Content-Type": "text/csv",
|
|
162
|
+
"Content-Encoding": "gzip",
|
|
163
|
+
"Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
|
|
164
|
+
}, UPLOAD_DATASET_TIMEOUT_MS));
|
|
165
|
+
const gid = data.id ?? data.dataset_id;
|
|
166
|
+
if (gid != null) {
|
|
167
|
+
data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${gid}) to verify the mesh, feature, and volume columns.`;
|
|
168
|
+
}
|
|
169
|
+
return textResult(data);
|
|
66
170
|
}
|
|
67
171
|
body = await fs.readFile(resolved, "utf-8");
|
|
68
172
|
}
|
|
@@ -76,8 +180,6 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
|
|
|
76
180
|
const uploadHeaders = {
|
|
77
181
|
"X-Dataset-Name": name,
|
|
78
182
|
"Content-Type": "text/csv",
|
|
79
|
-
// Deterministic key so a timed-out retry of the SAME upload reconciles to
|
|
80
|
-
// the original dataset server-side instead of creating a duplicate.
|
|
81
183
|
"Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
|
|
82
184
|
};
|
|
83
185
|
let uploadBody = body;
|
|
@@ -87,8 +189,9 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
|
|
|
87
189
|
}
|
|
88
190
|
const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
|
|
89
191
|
const id = data.id ?? data.dataset_id;
|
|
90
|
-
if (id != null)
|
|
192
|
+
if (id != null) {
|
|
91
193
|
data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${id}) to verify the mesh, feature, and volume columns.`;
|
|
194
|
+
}
|
|
92
195
|
return textResult(data);
|
|
93
196
|
}
|
|
94
197
|
if (action === "preview") {
|
|
@@ -97,8 +200,68 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
|
|
|
97
200
|
const data = await apiCall("GET", `/v1/datasets/${dataset_id}/preview?n_rows=${n_rows ?? 5}`);
|
|
98
201
|
return textResult(data);
|
|
99
202
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
203
|
+
if (action === "subset") {
|
|
204
|
+
if (!dataset_id)
|
|
205
|
+
throw new Error("barmesh_datasets(subset) requires dataset_id.");
|
|
206
|
+
if (!name)
|
|
207
|
+
throw new Error("barmesh_datasets(subset) requires name.");
|
|
208
|
+
const allFilters = filters ?? (filter ? [filter] : undefined);
|
|
209
|
+
if (row_range === undefined && allFilters === undefined && sample_n === undefined) {
|
|
210
|
+
throw new Error("barmesh_datasets(subset) requires at least one of row_range, filters, or sample_n.");
|
|
211
|
+
}
|
|
212
|
+
const body = { name };
|
|
213
|
+
if (row_range !== undefined)
|
|
214
|
+
body.row_range = row_range;
|
|
215
|
+
if (allFilters !== undefined)
|
|
216
|
+
body.filters = allFilters;
|
|
217
|
+
if (sample_n !== undefined)
|
|
218
|
+
body.sample_n = sample_n;
|
|
219
|
+
if (sample_seed !== undefined)
|
|
220
|
+
body.sample_seed = sample_seed;
|
|
221
|
+
const data = await apiCall("POST", `/v1/datasets/${dataset_id}/subset`, body);
|
|
222
|
+
return textResult(data);
|
|
223
|
+
}
|
|
224
|
+
if (action === "list") {
|
|
225
|
+
const data = (await apiCall("GET", "/v1/datasets"));
|
|
226
|
+
if (Array.isArray(data)) {
|
|
227
|
+
const lines = data.map((ds) => {
|
|
228
|
+
const id = String(ds.id ?? "");
|
|
229
|
+
const dsName = String(ds.name ?? "");
|
|
230
|
+
const rows = ds.rows != null ? Number(ds.rows) : "?";
|
|
231
|
+
const cols = ds.cols != null ? Number(ds.cols) : "?";
|
|
232
|
+
const st = ds.status != null ? String(ds.status) : "ready";
|
|
233
|
+
const statusBit = st !== "ready" ? ` | status=${st}` : "";
|
|
234
|
+
const ingestErr = cleanNullable(ds.ingest_error);
|
|
235
|
+
const err = ingestErr ? ` | ingest_error=${ingestErr}` : "";
|
|
236
|
+
return `${dsName} (${id}) — ${rows}×${cols}${statusBit}${err}`;
|
|
237
|
+
});
|
|
238
|
+
return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
|
|
239
|
+
}
|
|
240
|
+
return textResult(data);
|
|
241
|
+
}
|
|
242
|
+
if (action === "get") {
|
|
243
|
+
if (!dataset_id)
|
|
244
|
+
throw new Error("barmesh_datasets(get) requires dataset_id.");
|
|
245
|
+
const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
|
|
246
|
+
const lines = [
|
|
247
|
+
`Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
|
|
248
|
+
`Status: ${ds.status ?? "ready"}`,
|
|
249
|
+
`Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
|
|
250
|
+
ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
|
|
251
|
+
ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
|
|
252
|
+
ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
|
|
253
|
+
ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status))` : "",
|
|
254
|
+
cleanNullable(ds.ingest_error) ? `Ingest error: ${cleanNullable(ds.ingest_error)}` : "",
|
|
255
|
+
ds.created_at != null ? `Created: ${String(ds.created_at)}` : "",
|
|
256
|
+
].filter(Boolean);
|
|
257
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
258
|
+
}
|
|
259
|
+
if (action === "delete") {
|
|
260
|
+
if (!dataset_id)
|
|
261
|
+
throw new Error("barmesh_datasets(delete) requires dataset_id.");
|
|
262
|
+
const data = await apiCall("DELETE", `/v1/datasets/${dataset_id}`);
|
|
263
|
+
return textResult(data);
|
|
264
|
+
}
|
|
265
|
+
throw new Error("Invalid action");
|
|
103
266
|
});
|
|
104
267
|
}
|
package/dist/tools/jobs.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { registerAuditedTool } from "../audit.js";
|
|
3
3
|
import { apiCall, textResult } from "../shared.js";
|
|
4
4
|
import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "../cfd_finalize.js";
|
|
5
|
+
import { formatJobStatusText } from "../job_status_format.js";
|
|
5
6
|
export function registerJobsTool(server) {
|
|
6
7
|
registerAuditedTool(server, "barmesh_jobs", `Check job status or list jobs.
|
|
7
8
|
|
|
@@ -20,16 +21,10 @@ ESCALATION: status=failed returns an error message and (when available) a failur
|
|
|
20
21
|
if (status === "completed" && data.finalize_job_id) {
|
|
21
22
|
const { note } = await pollCfdFinalizeIfPresent(job_id, data);
|
|
22
23
|
data = await refreshJobAfterFinalize(job_id);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
data.label != null ? `Label: ${String(data.label)}` : null,
|
|
26
|
-
data.progress != null ? `Progress: ${String(data.progress)}` : null,
|
|
27
|
-
data.result_ref != null ? `Results: ${String(data.result_ref)}` : null,
|
|
28
|
-
note,
|
|
29
|
-
].filter(Boolean);
|
|
30
|
-
return textResult({ ...data, status_text: lines.join("\n") });
|
|
24
|
+
const statusText = note ? `${formatJobStatusText(job_id, data)}\n${note}` : formatJobStatusText(job_id, data);
|
|
25
|
+
return textResult({ ...data, status_text: statusText });
|
|
31
26
|
}
|
|
32
|
-
return textResult(data);
|
|
27
|
+
return textResult({ ...data, status_text: formatJobStatusText(job_id, data) });
|
|
33
28
|
}
|
|
34
29
|
const data = await apiCall("GET", "/v1/jobs");
|
|
35
30
|
return textResult(data);
|
package/dist/tools/results.js
CHANGED
|
@@ -1,27 +1,52 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { registerAuditedTool } from "../audit.js";
|
|
3
|
-
import { apiCall, apiRawCall, textResult, tryAttachImage } from "../shared.js";
|
|
4
|
-
|
|
3
|
+
import { apiCall, apiRawCall, textResult, tryAttachImage, pollUntilComplete, POLL_STAGE_MAX_MS } from "../shared.js";
|
|
4
|
+
// Headline figures when present, best first: the combined overview (barsom parity),
|
|
5
|
+
// then KL/EMD-vs-reference and the coarse/fine volume pair.
|
|
6
|
+
const MESH_DEFAULT_FIGURES = ["combined", "KL_ref", "EMD_ref", "plot_vol_coarse_fine"];
|
|
5
7
|
export function registerResultsTool(server) {
|
|
6
8
|
registerAuditedTool(server, "barmesh_results", `Fetch results of a completed CFD job: distances, convergence reading, and figures.
|
|
7
9
|
|
|
8
10
|
| Action | Use when |
|
|
9
11
|
|--------|----------|
|
|
10
12
|
| get | Read the summary (distances per mesh, stepwise, convergence reading) and inline key figures. |
|
|
11
|
-
| image | Download one figure by filename (e.g. KL_ref.
|
|
13
|
+
| image | Download one figure by filename (e.g. KL_ref.png, EMD_stepwise.pdf). |
|
|
14
|
+
| render | Re-render the figures as publication PDFs (or SVG) on demand — PDFs are NOT generated by default. |
|
|
12
15
|
|
|
13
16
|
BEST FOR: After barmesh_jobs(action=status) shows completed.
|
|
14
|
-
FIGURES:
|
|
17
|
+
FIGURES: Default job output is metrics + PNG previews, including a single combined.png overview. action=get inlines the combined headline plus KL_ref/EMD_ref/volume PNGs with captions; pass figures="all" for every PNG, "none" for metrics only, or a list of names.
|
|
18
|
+
PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image to download each <name>.pdf. For an interactive browser use barmesh_results_explorer.
|
|
15
19
|
NOT FOR: Submitting jobs.`, {
|
|
16
|
-
action: z.enum(["get", "image"]).describe("get: summary + figures; image: download one figure file"),
|
|
20
|
+
action: z.enum(["get", "image", "render"]).describe("get: summary + figures; image: download one figure file; render: re-render figures as PDF/SVG on demand"),
|
|
17
21
|
job_id: z.string().describe("Job ID"),
|
|
18
|
-
filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.pdf)"),
|
|
22
|
+
filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.png or, after render, KL_ref.pdf)"),
|
|
23
|
+
format: z.enum(["pdf", "png", "svg"]).optional().describe("For action=render: output format to generate (default pdf). PNG previews already exist by default."),
|
|
19
24
|
figures: z
|
|
20
25
|
.union([z.enum(["default", "all", "none"]), z.array(z.string())])
|
|
21
26
|
.optional()
|
|
22
27
|
.describe("For action=get: which figures to inline (default headline panels; 'all'; 'none'; or a list of names)"),
|
|
23
28
|
}, async (args) => {
|
|
24
|
-
const { action, job_id, filename, figures } = args;
|
|
29
|
+
const { action, job_id, filename, format, figures } = args;
|
|
30
|
+
if (action === "render") {
|
|
31
|
+
const fmt = format ?? "pdf";
|
|
32
|
+
const submit = (await apiCall("POST", "/v1/cfd/render", { job_id, format: fmt }));
|
|
33
|
+
const renderId = (submit.id ?? submit.job_id);
|
|
34
|
+
if (!renderId)
|
|
35
|
+
throw new Error("cfd render did not return a job id");
|
|
36
|
+
const poll = await pollUntilComplete(renderId, POLL_STAGE_MAX_MS);
|
|
37
|
+
if (poll.status === "failed") {
|
|
38
|
+
return textResult({ status: "failed", render_job_id: renderId, error: poll.error ?? "render failed" });
|
|
39
|
+
}
|
|
40
|
+
if (poll.status !== "completed") {
|
|
41
|
+
return textResult({ status: poll.status, render_job_id: renderId, note: `Still rendering; poll barmesh_jobs(action=status, job_id="${renderId}").` });
|
|
42
|
+
}
|
|
43
|
+
return textResult({
|
|
44
|
+
status: "completed",
|
|
45
|
+
render_job_id: renderId,
|
|
46
|
+
format: fmt,
|
|
47
|
+
suggested_next_step: `Figures rendered as ${fmt}. Download one with barmesh_results(action=image, job_id="${job_id}", filename="KL_ref.${fmt}"), or list filenames via barmesh_results(action=get, job_id="${job_id}").`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
25
50
|
if (action === "image") {
|
|
26
51
|
if (!filename)
|
|
27
52
|
throw new Error("barmesh_results(image) requires filename.");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const GZIP_UPLOAD_HINT = "Try gzip: save as .csv.gz (often 2–3× smaller). The upload limit applies to the compressed file size on presigned/large uploads.";
|