@json-to-office/mcp-server 1.4.0 → 1.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/README.md +33 -15
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +846 -109
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +284 -3
- package/dist/index.js +815 -107
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
8
8
|
|
|
9
9
|
// src/lib/version.ts
|
|
10
|
-
var SERVER_VERSION = true ? "1.
|
|
10
|
+
var SERVER_VERSION = true ? "1.8.0" : "dev-mode";
|
|
11
11
|
var SERVER_NAME = "json-to-office";
|
|
12
12
|
var PACKAGE_NAME = "@json-to-office/mcp-server";
|
|
13
13
|
|
|
@@ -221,6 +221,8 @@ var ERROR_CODES = {
|
|
|
221
221
|
HOST_NOTE: "W_HOST_NOTE",
|
|
222
222
|
/** A generation warning the core raised without a code of its own. */
|
|
223
223
|
GENERATION: "W_GENERATION",
|
|
224
|
+
/** A design-quality rule threw, so its whole class of findings is missing. */
|
|
225
|
+
QUALITY_RULE_ERROR: "W_QUALITY_RULE_ERROR",
|
|
224
226
|
/** A required host binary (LibreOffice, poppler) is absent. */
|
|
225
227
|
DEPENDENCY_MISSING: "E_DEPENDENCY_MISSING",
|
|
226
228
|
/** The client cancelled the request. */
|
|
@@ -317,6 +319,25 @@ function toolResult(payload) {
|
|
|
317
319
|
};
|
|
318
320
|
}
|
|
319
321
|
var HOST_DEPENDENCY_ERRORS = /* @__PURE__ */ new Set([RENDERER_DEPENDENCY_MISSING]);
|
|
322
|
+
function qualityOptionDiagnostic(error) {
|
|
323
|
+
const code = qualityCallerCode(error);
|
|
324
|
+
if (code === void 0 || code === ERROR_CODES.INVALID_DOCUMENT) {
|
|
325
|
+
return void 0;
|
|
326
|
+
}
|
|
327
|
+
return diagnostic(
|
|
328
|
+
code,
|
|
329
|
+
error instanceof Error ? error.message : String(error)
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
function qualityCallerCode(error) {
|
|
333
|
+
const code = error?.code;
|
|
334
|
+
if (code === "QUALITY_PROFILE_INCOMPATIBLE")
|
|
335
|
+
return OPTION_ERROR_CODES.INVALID_QUALITY_PROFILE;
|
|
336
|
+
if (code === "QUALITY_POLICY_INVALID")
|
|
337
|
+
return OPTION_ERROR_CODES.INVALID_QUALITY_POLICY;
|
|
338
|
+
if (code === "QUALITY_GATE_FAILED") return ERROR_CODES.INVALID_DOCUMENT;
|
|
339
|
+
return void 0;
|
|
340
|
+
}
|
|
320
341
|
function stackAllowed() {
|
|
321
342
|
const flag = process.env.JTO_MCP_DEBUG_STACKS;
|
|
322
343
|
return flag === "1" || flag === "true";
|
|
@@ -367,7 +388,7 @@ async function guarded(body) {
|
|
|
367
388
|
return withHostNotes(result, notes);
|
|
368
389
|
} catch (error) {
|
|
369
390
|
const message2 = error instanceof Error ? error.message : String(error);
|
|
370
|
-
const code = error instanceof Error && HOST_DEPENDENCY_ERRORS.has(error.name) ? ERROR_CODES.DEPENDENCY_MISSING : ERROR_CODES.INTERNAL;
|
|
391
|
+
const code = qualityCallerCode(error) ?? (error instanceof Error && HOST_DEPENDENCY_ERRORS.has(error.name) ? ERROR_CODES.DEPENDENCY_MISSING : ERROR_CODES.INTERNAL);
|
|
371
392
|
return withHostNotes(
|
|
372
393
|
failure(code, message2, {
|
|
373
394
|
context: {
|
|
@@ -386,7 +407,11 @@ var OPTION_ERROR_CODES = {
|
|
|
386
407
|
/** `themePath` is not a data-only JSON theme path. */
|
|
387
408
|
INVALID_THEME_PATH: "E_INVALID_THEME_PATH",
|
|
388
409
|
/** The tool does not support the requested format. */
|
|
389
|
-
UNSUPPORTED_FORMAT: "E_UNSUPPORTED_FORMAT"
|
|
410
|
+
UNSUPPORTED_FORMAT: "E_UNSUPPORTED_FORMAT",
|
|
411
|
+
/** `quality.profile` does not cover the format or renderer of this run. */
|
|
412
|
+
INVALID_QUALITY_PROFILE: "E_INVALID_QUALITY_PROFILE",
|
|
413
|
+
/** `quality.policy` sets a gate, severity or budget that is not a legal value. */
|
|
414
|
+
INVALID_QUALITY_POLICY: "E_INVALID_QUALITY_POLICY"
|
|
390
415
|
};
|
|
391
416
|
var DEFERRED_TO_COMPILER = /* @__PURE__ */ new Set([
|
|
392
417
|
ERROR_CODES.UNSUPPORTED_RENDERER_FEATURE
|
|
@@ -395,9 +420,9 @@ var ROOT_SENTINELS = /* @__PURE__ */ new Set(["", "/", "#", "root"]);
|
|
|
395
420
|
function escapePointerSegment(segment) {
|
|
396
421
|
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
397
422
|
}
|
|
398
|
-
function toJsonPointer(
|
|
399
|
-
if (
|
|
400
|
-
const trimmed =
|
|
423
|
+
function toJsonPointer(path7) {
|
|
424
|
+
if (path7 === void 0) return void 0;
|
|
425
|
+
const trimmed = path7.trim();
|
|
401
426
|
if (ROOT_SENTINELS.has(trimmed)) return "";
|
|
402
427
|
const segments = trimmed.startsWith("/") ? trimmed.slice(1).split("/") : trimmed.replace(/\[(\d+)\]/g, ".$1").split(".").filter((segment) => segment !== "");
|
|
403
428
|
if (segments.length === 0) return "";
|
|
@@ -426,6 +451,34 @@ function validationDiagnostics(errors) {
|
|
|
426
451
|
})
|
|
427
452
|
);
|
|
428
453
|
}
|
|
454
|
+
function qualityAnalysisDiagnostics(analysis) {
|
|
455
|
+
return analysis.diagnostics.map((diagnostic2) => ({
|
|
456
|
+
source: diagnostic2.source,
|
|
457
|
+
ruleId: diagnostic2.ruleId,
|
|
458
|
+
category: diagnostic2.category,
|
|
459
|
+
certainty: diagnostic2.certainty,
|
|
460
|
+
severity: diagnostic2.severity,
|
|
461
|
+
code: diagnostic2.code,
|
|
462
|
+
message: diagnostic2.message,
|
|
463
|
+
path: diagnostic2.path,
|
|
464
|
+
blocking: diagnostic2.blocking,
|
|
465
|
+
...diagnostic2.suggestion !== void 0 && {
|
|
466
|
+
suggestion: diagnostic2.suggestion
|
|
467
|
+
},
|
|
468
|
+
...diagnostic2.context !== void 0 && {
|
|
469
|
+
context: { ...diagnostic2.context }
|
|
470
|
+
},
|
|
471
|
+
...diagnostic2.relatedPaths !== void 0 && {
|
|
472
|
+
relatedPaths: diagnostic2.relatedPaths
|
|
473
|
+
},
|
|
474
|
+
...diagnostic2.evidence !== void 0 && {
|
|
475
|
+
evidence: { ...diagnostic2.evidence }
|
|
476
|
+
},
|
|
477
|
+
...diagnostic2.fixes !== void 0 && {
|
|
478
|
+
fixes: diagnostic2.fixes
|
|
479
|
+
}
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
429
482
|
function looksLikeValidationErrors(value) {
|
|
430
483
|
return Array.isArray(value) && value.length > 0 && value.every(
|
|
431
484
|
(entry) => typeof entry === "object" && entry !== null && typeof entry.message === "string"
|
|
@@ -570,11 +623,11 @@ async function probeService(rawUrl, envVar) {
|
|
|
570
623
|
}
|
|
571
624
|
const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
|
|
572
625
|
const net = await import("net");
|
|
573
|
-
return new Promise((
|
|
626
|
+
return new Promise((resolve3) => {
|
|
574
627
|
const socket = net.connect({ host: target.hostname, port });
|
|
575
628
|
const settle = (detail) => {
|
|
576
629
|
socket.destroy();
|
|
577
|
-
|
|
630
|
+
resolve3({
|
|
578
631
|
available: detail === void 0,
|
|
579
632
|
url: target.origin,
|
|
580
633
|
envVar,
|
|
@@ -614,7 +667,7 @@ function register(server, deps) {
|
|
|
614
667
|
"jto_info",
|
|
615
668
|
{
|
|
616
669
|
title: "Server info",
|
|
617
|
-
description: "Versions, supported formats with each renderer and whether its backend loads here, workspace availability, output-root and size limits, and whether the optional host dependencies (LibreOffice and poppler for jto_preview, a Highcharts export server for the DOCX `highcharts` component) are present on this host. Call this first.",
|
|
670
|
+
description: "Versions, supported formats with each renderer and whether its backend loads here, workspace availability and whether workspaces survive a lost connection, output-root and size limits, and whether the optional host dependencies (LibreOffice and poppler for jto_preview, a Highcharts export server for the DOCX `highcharts` component) are present on this host. Call this first.",
|
|
618
671
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
619
672
|
inputSchema: S({
|
|
620
673
|
type: "object",
|
|
@@ -699,9 +752,17 @@ function register(server, deps) {
|
|
|
699
752
|
type: "object",
|
|
700
753
|
properties: {
|
|
701
754
|
available: { type: "boolean" },
|
|
702
|
-
open: { type: "integer" }
|
|
755
|
+
open: { type: "integer" },
|
|
756
|
+
persistent: {
|
|
757
|
+
type: "boolean",
|
|
758
|
+
description: "True when revisions are mirrored to disk and survive a lost connection; false when handles are memory-only, which is the default."
|
|
759
|
+
},
|
|
760
|
+
root: {
|
|
761
|
+
type: "string",
|
|
762
|
+
description: "Directory the revisions are mirrored to, when persistent."
|
|
763
|
+
}
|
|
703
764
|
},
|
|
704
|
-
required: ["available", "open"],
|
|
765
|
+
required: ["available", "open", "persistent"],
|
|
705
766
|
additionalProperties: false
|
|
706
767
|
},
|
|
707
768
|
output: {
|
|
@@ -862,7 +923,11 @@ function register(server, deps) {
|
|
|
862
923
|
formats,
|
|
863
924
|
workspaces: {
|
|
864
925
|
available: store.available,
|
|
865
|
-
open: listed.ok ? listed.records.length : 0
|
|
926
|
+
open: listed.ok ? listed.records.length : 0,
|
|
927
|
+
persistent: deps.workspacePersistence !== void 0,
|
|
928
|
+
...deps.workspacePersistence && {
|
|
929
|
+
root: deps.workspacePersistence.root
|
|
930
|
+
}
|
|
866
931
|
},
|
|
867
932
|
output: {
|
|
868
933
|
root: deps.outputRoot.path,
|
|
@@ -1122,10 +1187,10 @@ var STARTERS = [
|
|
|
1122
1187
|
id: "pptx-minimal",
|
|
1123
1188
|
format: "pptx",
|
|
1124
1189
|
title: "Minimal presentation",
|
|
1125
|
-
description: "The smallest
|
|
1190
|
+
description: "The smallest well-formed .pptx: root with a declared 16:9 canvas, one slide, one title text. The canvas stays: without it the renderer silently falls back to 4:3.",
|
|
1126
1191
|
document: {
|
|
1127
1192
|
name: "pptx",
|
|
1128
|
-
props: { title: "Untitled deck" },
|
|
1193
|
+
props: { title: "Untitled deck", slideWidth: 13.333, slideHeight: 7.5 },
|
|
1129
1194
|
children: [
|
|
1130
1195
|
{
|
|
1131
1196
|
name: "slide",
|
|
@@ -1982,17 +2047,26 @@ function capDiagnostics(diagnostics, limit) {
|
|
|
1982
2047
|
if (diagnostics.length <= limit)
|
|
1983
2048
|
return { kept: diagnostics, truncated: false };
|
|
1984
2049
|
const ordered = [...diagnostics].sort(
|
|
1985
|
-
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
2050
|
+
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || Number(b.blocking === true) - Number(a.blocking === true)
|
|
1986
2051
|
);
|
|
1987
2052
|
return { kept: ordered.slice(0, limit), truncated: true };
|
|
1988
2053
|
}
|
|
2054
|
+
function ruleErrorDiagnostics(analysis) {
|
|
2055
|
+
return analysis.ruleErrors.map(
|
|
2056
|
+
(entry) => diagnostic(
|
|
2057
|
+
ERROR_CODES.QUALITY_RULE_ERROR,
|
|
2058
|
+
`Quality rule "${entry.ruleId}" failed: ${entry.message}`,
|
|
2059
|
+
{ severity: "warning", source: "quality", ruleId: entry.ruleId }
|
|
2060
|
+
)
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
1989
2063
|
var DEFAULT_MAX_DIAGNOSTICS = 100;
|
|
1990
2064
|
function register4(server, deps) {
|
|
1991
2065
|
server.registerTool(
|
|
1992
2066
|
"jto_validate",
|
|
1993
2067
|
{
|
|
1994
2068
|
title: "Validate a document",
|
|
1995
|
-
description: "Check a document against its format schema and report every defect as a path-addressed diagnostic. Paths are RFC 6901 JSON Pointers into the document you passed, so they can be used directly as patch targets; codes are the stable `E_`/`W_` vocabulary
|
|
2069
|
+
description: "Check a document against its format schema and report every defect as a path-addressed diagnostic. Paths are RFC 6901 JSON Pointers into the document you passed, so they can be used directly as patch targets; codes are the stable `E_`/`W_` vocabulary. `ok` mirrors generation: schema and semantic errors block; design-quality `W_QUALITY_*` findings advise by default and block only when `quality.policy.gate` requests it. A broken document is a normal result with `ok: false`, never a protocol error.",
|
|
1996
2070
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1997
2071
|
inputSchema: S({
|
|
1998
2072
|
type: "object",
|
|
@@ -2008,6 +2082,29 @@ function register4(server, deps) {
|
|
|
2008
2082
|
minimum: 1,
|
|
2009
2083
|
maximum: 1e3,
|
|
2010
2084
|
description: `Cap on returned diagnostics (default ${DEFAULT_MAX_DIAGNOSTICS}). Errors are kept ahead of warnings when the cap bites.`
|
|
2085
|
+
},
|
|
2086
|
+
quality: {
|
|
2087
|
+
type: "object",
|
|
2088
|
+
description: "Optional design profile plus per-run enforcement policy.",
|
|
2089
|
+
properties: {
|
|
2090
|
+
profile: {
|
|
2091
|
+
type: "object",
|
|
2092
|
+
properties: { id: { type: "string", minLength: 1 } },
|
|
2093
|
+
required: ["id"],
|
|
2094
|
+
additionalProperties: true
|
|
2095
|
+
},
|
|
2096
|
+
policy: {
|
|
2097
|
+
type: "object",
|
|
2098
|
+
properties: {
|
|
2099
|
+
gate: {
|
|
2100
|
+
type: "string",
|
|
2101
|
+
enum: ["none", "error", "warning", "info"]
|
|
2102
|
+
}
|
|
2103
|
+
},
|
|
2104
|
+
additionalProperties: true
|
|
2105
|
+
}
|
|
2106
|
+
},
|
|
2107
|
+
additionalProperties: false
|
|
2011
2108
|
}
|
|
2012
2109
|
},
|
|
2013
2110
|
required: ["format"],
|
|
@@ -2038,7 +2135,11 @@ function register4(server, deps) {
|
|
|
2038
2135
|
},
|
|
2039
2136
|
truncated: {
|
|
2040
2137
|
type: "boolean",
|
|
2041
|
-
description: "`diagnostics` was capped by `maxDiagnostics
|
|
2138
|
+
description: "`diagnostics` was capped, by `maxDiagnostics` or by the budget the quality policy set."
|
|
2139
|
+
},
|
|
2140
|
+
profileId: {
|
|
2141
|
+
type: "string",
|
|
2142
|
+
description: "The quality profile the design analysis ran under, when one applied."
|
|
2042
2143
|
}
|
|
2043
2144
|
})
|
|
2044
2145
|
)
|
|
@@ -2058,24 +2159,52 @@ function register4(server, deps) {
|
|
|
2058
2159
|
resolved.document,
|
|
2059
2160
|
args.renderer
|
|
2060
2161
|
);
|
|
2061
|
-
|
|
2162
|
+
let analysis;
|
|
2163
|
+
let qualityOption;
|
|
2164
|
+
if (adapter.analyzeQuality) {
|
|
2165
|
+
try {
|
|
2166
|
+
analysis = await adapter.analyzeQuality(resolved.document, {
|
|
2167
|
+
renderer: args.renderer,
|
|
2168
|
+
quality: args.quality
|
|
2169
|
+
});
|
|
2170
|
+
} catch (error) {
|
|
2171
|
+
const option = result.valid ? void 0 : qualityOptionDiagnostic(error);
|
|
2172
|
+
if (!option) throw error;
|
|
2173
|
+
qualityOption = option;
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const structural = [
|
|
2062
2177
|
...validationDiagnostics(result.errors),
|
|
2063
|
-
...unavailable2 ? [unavailable2] : []
|
|
2178
|
+
...unavailable2 ? [unavailable2] : [],
|
|
2179
|
+
...qualityOption ? [qualityOption] : []
|
|
2180
|
+
];
|
|
2181
|
+
const all = [
|
|
2182
|
+
...structural,
|
|
2183
|
+
...analysis ? [
|
|
2184
|
+
...qualityAnalysisDiagnostics(analysis),
|
|
2185
|
+
...ruleErrorDiagnostics(analysis)
|
|
2186
|
+
] : []
|
|
2064
2187
|
];
|
|
2065
2188
|
const counts = countDiagnostics(all);
|
|
2189
|
+
const blocked = countDiagnostics(structural).error > 0 || analysis?.blocked === true;
|
|
2066
2190
|
const { kept, truncated } = capDiagnostics(
|
|
2067
2191
|
all,
|
|
2068
2192
|
args.maxDiagnostics ?? DEFAULT_MAX_DIAGNOSTICS
|
|
2069
2193
|
);
|
|
2070
2194
|
return {
|
|
2071
|
-
ok:
|
|
2195
|
+
ok: !blocked,
|
|
2072
2196
|
diagnostics: kept,
|
|
2073
|
-
valid:
|
|
2197
|
+
valid: !blocked,
|
|
2074
2198
|
format: args.format,
|
|
2075
2199
|
...args.renderer !== void 0 && { renderer: args.renderer },
|
|
2076
2200
|
source: sourceSummary(resolved),
|
|
2077
2201
|
counts,
|
|
2078
|
-
|
|
2202
|
+
// The engine caps its own list under a policy budget, so a report
|
|
2203
|
+
// shortened there would otherwise come back reading complete.
|
|
2204
|
+
truncated: truncated || analysis?.truncated === true,
|
|
2205
|
+
...analysis?.profileId !== void 0 && {
|
|
2206
|
+
profileId: analysis.profileId
|
|
2207
|
+
}
|
|
2079
2208
|
};
|
|
2080
2209
|
})
|
|
2081
2210
|
)
|
|
@@ -3019,9 +3148,9 @@ async function digestAssets(document, baseDir) {
|
|
|
3019
3148
|
[...references].map(async (reference) => {
|
|
3020
3149
|
const resolved = path4.resolve(root, reference);
|
|
3021
3150
|
try {
|
|
3022
|
-
const
|
|
3023
|
-
if (!
|
|
3024
|
-
entries.push(`${resolved}|${
|
|
3151
|
+
const stat2 = await fs4.stat(resolved);
|
|
3152
|
+
if (!stat2.isFile()) return;
|
|
3153
|
+
entries.push(`${resolved}|${stat2.size}|${stat2.mtimeMs}`);
|
|
3025
3154
|
} catch {
|
|
3026
3155
|
}
|
|
3027
3156
|
})
|
|
@@ -3051,8 +3180,8 @@ function collectAssetReferences(value, into) {
|
|
|
3051
3180
|
async function digestThemeFile(themePath) {
|
|
3052
3181
|
if (!themePath) return "none";
|
|
3053
3182
|
try {
|
|
3054
|
-
const
|
|
3055
|
-
return `${path4.resolve(themePath)}|${
|
|
3183
|
+
const stat2 = await fs4.stat(themePath);
|
|
3184
|
+
return `${path4.resolve(themePath)}|${stat2.size}|${stat2.mtimeMs}`;
|
|
3056
3185
|
} catch {
|
|
3057
3186
|
return `${path4.resolve(themePath)}|missing`;
|
|
3058
3187
|
}
|
|
@@ -3124,18 +3253,18 @@ function missingDependencyFailure(dependencies) {
|
|
|
3124
3253
|
);
|
|
3125
3254
|
}
|
|
3126
3255
|
async function binaryIdentity(binary, args, signal) {
|
|
3127
|
-
const [version,
|
|
3256
|
+
const [version, stat2] = await Promise.all([
|
|
3128
3257
|
readVersion(binary, args, signal),
|
|
3129
3258
|
fs5.stat(binary).catch(() => void 0)
|
|
3130
3259
|
]);
|
|
3131
3260
|
const identity = [
|
|
3132
3261
|
version ?? "unknown",
|
|
3133
|
-
|
|
3262
|
+
stat2 ? `${stat2.size}:${stat2.mtimeMs}` : "nostat"
|
|
3134
3263
|
].join("|");
|
|
3135
3264
|
return { identity, ...version !== void 0 && { version } };
|
|
3136
3265
|
}
|
|
3137
3266
|
function readVersion(binary, args, signal) {
|
|
3138
|
-
return new Promise((
|
|
3267
|
+
return new Promise((resolve3) => {
|
|
3139
3268
|
execFile(
|
|
3140
3269
|
binary,
|
|
3141
3270
|
args,
|
|
@@ -3149,8 +3278,8 @@ function readVersion(binary, args, signal) {
|
|
|
3149
3278
|
const text = `${stdout ?? ""}
|
|
3150
3279
|
${stderr ?? ""}`.trim();
|
|
3151
3280
|
const line = text.split("\n")[0]?.trim();
|
|
3152
|
-
if (!line && error) return
|
|
3153
|
-
|
|
3281
|
+
if (!line && error) return resolve3(void 0);
|
|
3282
|
+
resolve3(line || void 0);
|
|
3154
3283
|
}
|
|
3155
3284
|
);
|
|
3156
3285
|
});
|
|
@@ -3195,12 +3324,12 @@ function defaultPreviewCacheDir() {
|
|
|
3195
3324
|
}
|
|
3196
3325
|
var CACHE_ENTRY_NAME = /^[a-f0-9]{64}\.(?:png|meta\.json)(?:\.tmp-\d+-\d+)?$/;
|
|
3197
3326
|
async function inspectPrivateCacheDir(cacheDir) {
|
|
3198
|
-
const
|
|
3199
|
-
if (!
|
|
3200
|
-
if (typeof process.getuid === "function" &&
|
|
3327
|
+
const stat2 = await fs6.lstat(cacheDir).catch(() => void 0);
|
|
3328
|
+
if (!stat2 || stat2.isSymbolicLink() || !stat2.isDirectory()) return false;
|
|
3329
|
+
if (typeof process.getuid === "function" && stat2.uid !== process.getuid()) {
|
|
3201
3330
|
return false;
|
|
3202
3331
|
}
|
|
3203
|
-
if (process.platform !== "win32" && (
|
|
3332
|
+
if (process.platform !== "win32" && (stat2.mode & 63) !== 0) {
|
|
3204
3333
|
try {
|
|
3205
3334
|
await fs6.chmod(cacheDir, 448);
|
|
3206
3335
|
} catch {
|
|
@@ -3230,9 +3359,9 @@ async function sweepPreviewCache(cacheDir, options = {}) {
|
|
|
3230
3359
|
for (const name of names) {
|
|
3231
3360
|
if (!CACHE_ENTRY_NAME.test(name)) continue;
|
|
3232
3361
|
const file = path5.join(cacheDir, name);
|
|
3233
|
-
const
|
|
3234
|
-
if (!
|
|
3235
|
-
entries.push({ file, mtimeMs:
|
|
3362
|
+
const stat2 = await fs6.lstat(file).catch(() => void 0);
|
|
3363
|
+
if (!stat2?.isFile()) continue;
|
|
3364
|
+
entries.push({ file, mtimeMs: stat2.mtimeMs, size: stat2.size });
|
|
3236
3365
|
}
|
|
3237
3366
|
const doomed = entries.filter((entry) => now - entry.mtimeMs > maxAgeMs);
|
|
3238
3367
|
const kept = entries.filter((entry) => now - entry.mtimeMs <= maxAgeMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
@@ -3319,7 +3448,7 @@ function countPdfPages(pdf) {
|
|
|
3319
3448
|
return matches ? matches.length : 0;
|
|
3320
3449
|
}
|
|
3321
3450
|
function exec(binary, args, timeoutMs, options = {}) {
|
|
3322
|
-
return new Promise((
|
|
3451
|
+
return new Promise((resolve3, reject) => {
|
|
3323
3452
|
execFile2(
|
|
3324
3453
|
binary,
|
|
3325
3454
|
args,
|
|
@@ -3330,7 +3459,7 @@ function exec(binary, args, timeoutMs, options = {}) {
|
|
|
3330
3459
|
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
3331
3460
|
...options.signal && { signal: options.signal }
|
|
3332
3461
|
},
|
|
3333
|
-
(error) => error ? reject(error) :
|
|
3462
|
+
(error) => error ? reject(error) : resolve3()
|
|
3334
3463
|
);
|
|
3335
3464
|
});
|
|
3336
3465
|
}
|
|
@@ -3424,7 +3553,7 @@ async function resolvePdfinfo(pdftoppmPath) {
|
|
|
3424
3553
|
return status.available ? status.path : void 0;
|
|
3425
3554
|
}
|
|
3426
3555
|
async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
|
|
3427
|
-
return new Promise((
|
|
3556
|
+
return new Promise((resolve3) => {
|
|
3428
3557
|
execFile2(
|
|
3429
3558
|
pdfinfo,
|
|
3430
3559
|
[pdfPath],
|
|
@@ -3435,9 +3564,9 @@ async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
|
|
|
3435
3564
|
...signal && { signal }
|
|
3436
3565
|
},
|
|
3437
3566
|
(error, stdout) => {
|
|
3438
|
-
if (error && !stdout) return
|
|
3567
|
+
if (error && !stdout) return resolve3(void 0);
|
|
3439
3568
|
const match = /^Pages:\s+(\d+)/m.exec(stdout ?? "");
|
|
3440
|
-
|
|
3569
|
+
resolve3(match ? Number(match[1]) : void 0);
|
|
3441
3570
|
}
|
|
3442
3571
|
);
|
|
3443
3572
|
});
|
|
@@ -4550,9 +4679,9 @@ function compilePatch(operations) {
|
|
|
4550
4679
|
}
|
|
4551
4680
|
);
|
|
4552
4681
|
}
|
|
4553
|
-
const
|
|
4554
|
-
if (!
|
|
4555
|
-
return problem(PATCH_ERROR_CODES.INVALID_POINTER,
|
|
4682
|
+
const path7 = parsePointer(raw.path);
|
|
4683
|
+
if (!path7.ok) {
|
|
4684
|
+
return problem(PATCH_ERROR_CODES.INVALID_POINTER, path7.message, index, {
|
|
4556
4685
|
context: { op, pointer: raw.path },
|
|
4557
4686
|
suggestion: 'Pointers are RFC 6901: "/children/0/props/text", with "~0" for "~" and "~1" for "/".'
|
|
4558
4687
|
});
|
|
@@ -4560,7 +4689,7 @@ function compilePatch(operations) {
|
|
|
4560
4689
|
const entry = {
|
|
4561
4690
|
op,
|
|
4562
4691
|
path: raw.path,
|
|
4563
|
-
tokens:
|
|
4692
|
+
tokens: path7.tokens
|
|
4564
4693
|
};
|
|
4565
4694
|
if (VALUE_OPS.has(op)) {
|
|
4566
4695
|
if (raw.value === void 0) {
|
|
@@ -4910,12 +5039,338 @@ function jsonEqualTokens(left, right) {
|
|
|
4910
5039
|
}
|
|
4911
5040
|
|
|
4912
5041
|
// src/workspace/store.ts
|
|
5042
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
5043
|
+
|
|
5044
|
+
// src/workspace/persistence.ts
|
|
5045
|
+
import * as fs7 from "fs/promises";
|
|
5046
|
+
import * as path6 from "path";
|
|
4913
5047
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
5048
|
+
var WORKSPACE_DIR_ENV = "JTO_MCP_WORKSPACE_DIR";
|
|
5049
|
+
var SCHEMA_VERSION = 1;
|
|
5050
|
+
var META_FILE = "meta.json";
|
|
5051
|
+
var REVISION_PREFIX = "rev-";
|
|
5052
|
+
var REVISION_SUFFIX = ".json";
|
|
5053
|
+
var TEMP_SUFFIX = ".tmp";
|
|
5054
|
+
var STALE_TEMP_MS = 60 * 60 * 1e3;
|
|
5055
|
+
var SAFE_HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5056
|
+
function isPersistableHandle(handle) {
|
|
5057
|
+
return SAFE_HANDLE.test(handle);
|
|
5058
|
+
}
|
|
5059
|
+
var DEFAULT_PERSISTENCE_LIMITS = {
|
|
5060
|
+
maxWorkspaces: 32,
|
|
5061
|
+
maxRevisionsPerWorkspace: 9,
|
|
5062
|
+
maxEntryBytes: 16 * 1024 * 1024
|
|
5063
|
+
};
|
|
5064
|
+
function createWorkspacePersistence(options = {}) {
|
|
5065
|
+
const env = options.env ?? process.env;
|
|
5066
|
+
const configured = options.flagDir?.trim() || env[WORKSPACE_DIR_ENV]?.trim();
|
|
5067
|
+
if (!configured) return void 0;
|
|
5068
|
+
return createWorkspacePersistenceAt(path6.resolve(configured), options);
|
|
5069
|
+
}
|
|
5070
|
+
function createWorkspacePersistenceAt(root, limits = {}) {
|
|
5071
|
+
const bounds = {
|
|
5072
|
+
maxWorkspaces: limits.maxWorkspaces ?? DEFAULT_PERSISTENCE_LIMITS.maxWorkspaces,
|
|
5073
|
+
maxRevisionsPerWorkspace: limits.maxRevisionsPerWorkspace ?? DEFAULT_PERSISTENCE_LIMITS.maxRevisionsPerWorkspace,
|
|
5074
|
+
maxEntryBytes: limits.maxEntryBytes ?? DEFAULT_PERSISTENCE_LIMITS.maxEntryBytes
|
|
5075
|
+
};
|
|
5076
|
+
let ensured;
|
|
5077
|
+
const authored = /* @__PURE__ */ new Map();
|
|
5078
|
+
function claim(handle, revision) {
|
|
5079
|
+
const owned = authored.get(handle) ?? /* @__PURE__ */ new Set();
|
|
5080
|
+
owned.add(revision);
|
|
5081
|
+
authored.set(handle, owned);
|
|
5082
|
+
}
|
|
5083
|
+
async function ensureRoot() {
|
|
5084
|
+
if (ensured === void 0) {
|
|
5085
|
+
ensured = fs7.mkdir(root, { recursive: true, mode: 448 }).then(
|
|
5086
|
+
() => void 0,
|
|
5087
|
+
(error) => {
|
|
5088
|
+
ensured = void 0;
|
|
5089
|
+
throw error;
|
|
5090
|
+
}
|
|
5091
|
+
);
|
|
5092
|
+
}
|
|
5093
|
+
return ensured;
|
|
5094
|
+
}
|
|
5095
|
+
function dirFor(handle) {
|
|
5096
|
+
return path6.join(root, handle);
|
|
5097
|
+
}
|
|
5098
|
+
async function writeAtomic2(target, contents) {
|
|
5099
|
+
const temp = `${target}.${randomBytes2(6).toString("hex")}${TEMP_SUFFIX}`;
|
|
5100
|
+
try {
|
|
5101
|
+
await fs7.writeFile(temp, contents, { encoding: "utf8", mode: 384 });
|
|
5102
|
+
await fs7.rename(temp, target);
|
|
5103
|
+
} catch (error) {
|
|
5104
|
+
await fs7.rm(temp, { force: true }).catch(() => void 0);
|
|
5105
|
+
throw error;
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
function revisionFile(handle, revision) {
|
|
5109
|
+
return path6.join(
|
|
5110
|
+
dirFor(handle),
|
|
5111
|
+
`${REVISION_PREFIX}${revision}${REVISION_SUFFIX}`
|
|
5112
|
+
);
|
|
5113
|
+
}
|
|
5114
|
+
async function readMeta(handle) {
|
|
5115
|
+
let raw;
|
|
5116
|
+
try {
|
|
5117
|
+
raw = await fs7.readFile(path6.join(dirFor(handle), META_FILE), "utf8");
|
|
5118
|
+
} catch {
|
|
5119
|
+
return void 0;
|
|
5120
|
+
}
|
|
5121
|
+
return parseMeta(raw, handle);
|
|
5122
|
+
}
|
|
5123
|
+
async function readRevision(handle, revision) {
|
|
5124
|
+
let text;
|
|
5125
|
+
try {
|
|
5126
|
+
text = await fs7.readFile(revisionFile(handle, revision), "utf8");
|
|
5127
|
+
} catch {
|
|
5128
|
+
return void 0;
|
|
5129
|
+
}
|
|
5130
|
+
try {
|
|
5131
|
+
const parsed = JSON.parse(text);
|
|
5132
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
5133
|
+
return void 0;
|
|
5134
|
+
} catch {
|
|
5135
|
+
return void 0;
|
|
5136
|
+
}
|
|
5137
|
+
return { revision, text, bytes: Buffer.byteLength(text, "utf8") };
|
|
5138
|
+
}
|
|
5139
|
+
async function handleDirs() {
|
|
5140
|
+
try {
|
|
5141
|
+
const entries = await fs7.readdir(root, { withFileTypes: true });
|
|
5142
|
+
return entries.filter(
|
|
5143
|
+
(entry) => entry.isDirectory() && isPersistableHandle(entry.name)
|
|
5144
|
+
).map((entry) => entry.name);
|
|
5145
|
+
} catch {
|
|
5146
|
+
return [];
|
|
5147
|
+
}
|
|
5148
|
+
}
|
|
5149
|
+
async function pruneWorkspaces(incoming) {
|
|
5150
|
+
const handles = (await handleDirs()).filter(
|
|
5151
|
+
(handle) => handle !== incoming
|
|
5152
|
+
);
|
|
5153
|
+
const excess = handles.length + 1 - bounds.maxWorkspaces;
|
|
5154
|
+
if (excess <= 0) return;
|
|
5155
|
+
const metas = await Promise.all(
|
|
5156
|
+
handles.map(async (handle) => ({
|
|
5157
|
+
handle,
|
|
5158
|
+
// An unreadable entry sorts first: it is the one worth losing.
|
|
5159
|
+
updatedAt: (await readMeta(handle))?.updatedAt ?? 0
|
|
5160
|
+
}))
|
|
5161
|
+
);
|
|
5162
|
+
metas.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
5163
|
+
for (const victim of metas.slice(0, excess)) {
|
|
5164
|
+
await fs7.rm(dirFor(victim.handle), { recursive: true, force: true }).catch(() => void 0);
|
|
5165
|
+
authored.delete(victim.handle);
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
async function pruneRevisions(handle, retained) {
|
|
5169
|
+
let names;
|
|
5170
|
+
try {
|
|
5171
|
+
names = await fs7.readdir(dirFor(handle));
|
|
5172
|
+
} catch {
|
|
5173
|
+
return;
|
|
5174
|
+
}
|
|
5175
|
+
const owned = authored.get(handle);
|
|
5176
|
+
for (const name of names) {
|
|
5177
|
+
const target = path6.join(dirFor(handle), name);
|
|
5178
|
+
if (name.endsWith(TEMP_SUFFIX)) {
|
|
5179
|
+
if (await isStale(target)) {
|
|
5180
|
+
await fs7.rm(target, { force: true }).catch(() => void 0);
|
|
5181
|
+
}
|
|
5182
|
+
continue;
|
|
5183
|
+
}
|
|
5184
|
+
const revision = revisionOf(name);
|
|
5185
|
+
if (revision === void 0 || retained.has(revision)) continue;
|
|
5186
|
+
await fs7.rm(target, { force: true }).catch(() => void 0);
|
|
5187
|
+
owned?.delete(revision);
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
5190
|
+
return {
|
|
5191
|
+
root,
|
|
5192
|
+
limits: bounds,
|
|
5193
|
+
async save(snapshot) {
|
|
5194
|
+
if (!isPersistableHandle(snapshot.handle)) {
|
|
5195
|
+
throw new Error(
|
|
5196
|
+
`Workspace handle ${JSON.stringify(
|
|
5197
|
+
snapshot.handle
|
|
5198
|
+
)} cannot be used as a directory name.`
|
|
5199
|
+
);
|
|
5200
|
+
}
|
|
5201
|
+
if (snapshot.head.bytes > bounds.maxEntryBytes) {
|
|
5202
|
+
throw new Error(
|
|
5203
|
+
`Revision ${snapshot.head.revision} is ${snapshot.head.bytes} bytes, over the ${bounds.maxEntryBytes}-byte persistence limit.`
|
|
5204
|
+
);
|
|
5205
|
+
}
|
|
5206
|
+
await ensureRoot();
|
|
5207
|
+
const dir = dirFor(snapshot.handle);
|
|
5208
|
+
const fresh = !await exists(dir);
|
|
5209
|
+
if (fresh) await pruneWorkspaces(snapshot.handle);
|
|
5210
|
+
await fs7.mkdir(dir, { recursive: true, mode: 448 });
|
|
5211
|
+
const files = [snapshot.head];
|
|
5212
|
+
const retained = /* @__PURE__ */ new Set([snapshot.head.revision]);
|
|
5213
|
+
const pinned = [];
|
|
5214
|
+
for (const pin of [...snapshot.pins].sort(
|
|
5215
|
+
(a, b) => b.revision - a.revision
|
|
5216
|
+
)) {
|
|
5217
|
+
if (!retained.has(pin.revision)) {
|
|
5218
|
+
if (files.length >= bounds.maxRevisionsPerWorkspace) continue;
|
|
5219
|
+
files.push(pin);
|
|
5220
|
+
retained.add(pin.revision);
|
|
5221
|
+
}
|
|
5222
|
+
pinned.push(pin.revision);
|
|
5223
|
+
}
|
|
5224
|
+
const owned = authored.get(snapshot.handle);
|
|
5225
|
+
for (const document of files) {
|
|
5226
|
+
if (owned?.has(document.revision)) continue;
|
|
5227
|
+
await writeAtomic2(
|
|
5228
|
+
revisionFile(snapshot.handle, document.revision),
|
|
5229
|
+
document.text
|
|
5230
|
+
);
|
|
5231
|
+
claim(snapshot.handle, document.revision);
|
|
5232
|
+
}
|
|
5233
|
+
const meta = {
|
|
5234
|
+
schema: SCHEMA_VERSION,
|
|
5235
|
+
handle: snapshot.handle,
|
|
5236
|
+
format: snapshot.format,
|
|
5237
|
+
revision: snapshot.head.revision,
|
|
5238
|
+
bytes: snapshot.head.bytes,
|
|
5239
|
+
createdAt: snapshot.createdAt,
|
|
5240
|
+
updatedAt: snapshot.updatedAt,
|
|
5241
|
+
...snapshot.title !== void 0 && { title: snapshot.title },
|
|
5242
|
+
pinnedRevisions: pinned.sort((a, b) => a - b)
|
|
5243
|
+
};
|
|
5244
|
+
await writeAtomic2(path6.join(dir, META_FILE), JSON.stringify(meta));
|
|
5245
|
+
await pruneRevisions(snapshot.handle, retained);
|
|
5246
|
+
},
|
|
5247
|
+
async list() {
|
|
5248
|
+
const handles = await handleDirs();
|
|
5249
|
+
const metas = await Promise.all(
|
|
5250
|
+
handles.map((handle) => readMeta(handle))
|
|
5251
|
+
);
|
|
5252
|
+
return metas.filter((meta) => meta !== void 0).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
5253
|
+
},
|
|
5254
|
+
async load(handle) {
|
|
5255
|
+
if (!isPersistableHandle(handle)) return void 0;
|
|
5256
|
+
const meta = await readMeta(handle);
|
|
5257
|
+
if (!meta) return void 0;
|
|
5258
|
+
const head = await readRevision(handle, meta.revision);
|
|
5259
|
+
if (!head) {
|
|
5260
|
+
await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
|
|
5261
|
+
return void 0;
|
|
5262
|
+
}
|
|
5263
|
+
const pins = [];
|
|
5264
|
+
for (const revision of meta.pinnedRevisions) {
|
|
5265
|
+
const pin = await readRevision(handle, revision);
|
|
5266
|
+
if (pin) pins.push(pin);
|
|
5267
|
+
}
|
|
5268
|
+
return {
|
|
5269
|
+
...meta,
|
|
5270
|
+
// A pin whose file went missing is dropped rather than advertised: the
|
|
5271
|
+
// record has to describe what can actually be read back.
|
|
5272
|
+
pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
|
|
5273
|
+
head,
|
|
5274
|
+
pins
|
|
5275
|
+
};
|
|
5276
|
+
},
|
|
5277
|
+
async remove(handle) {
|
|
5278
|
+
if (!isPersistableHandle(handle)) return false;
|
|
5279
|
+
const dir = dirFor(handle);
|
|
5280
|
+
if (!await exists(dir)) {
|
|
5281
|
+
authored.delete(handle);
|
|
5282
|
+
return false;
|
|
5283
|
+
}
|
|
5284
|
+
await fs7.rm(dir, { recursive: true, force: true });
|
|
5285
|
+
authored.delete(handle);
|
|
5286
|
+
return true;
|
|
5287
|
+
}
|
|
5288
|
+
};
|
|
5289
|
+
}
|
|
5290
|
+
async function exists(target) {
|
|
5291
|
+
try {
|
|
5292
|
+
await fs7.stat(target);
|
|
5293
|
+
return true;
|
|
5294
|
+
} catch {
|
|
5295
|
+
return false;
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
async function isStale(target) {
|
|
5299
|
+
try {
|
|
5300
|
+
const { mtimeMs } = await fs7.stat(target);
|
|
5301
|
+
return Date.now() - mtimeMs > STALE_TEMP_MS;
|
|
5302
|
+
} catch {
|
|
5303
|
+
return false;
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
function revisionOf(name) {
|
|
5307
|
+
if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
|
|
5308
|
+
return void 0;
|
|
5309
|
+
}
|
|
5310
|
+
const digits = name.slice(
|
|
5311
|
+
REVISION_PREFIX.length,
|
|
5312
|
+
name.length - REVISION_SUFFIX.length
|
|
5313
|
+
);
|
|
5314
|
+
if (!/^[0-9]+$/.test(digits)) return void 0;
|
|
5315
|
+
return Number(digits);
|
|
5316
|
+
}
|
|
5317
|
+
function parseMeta(raw, handle) {
|
|
5318
|
+
let parsed;
|
|
5319
|
+
try {
|
|
5320
|
+
parsed = JSON.parse(raw);
|
|
5321
|
+
} catch {
|
|
5322
|
+
return void 0;
|
|
5323
|
+
}
|
|
5324
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5325
|
+
return void 0;
|
|
5326
|
+
}
|
|
5327
|
+
const meta = parsed;
|
|
5328
|
+
if (meta.schema !== SCHEMA_VERSION) return void 0;
|
|
5329
|
+
if (meta.handle !== handle) return void 0;
|
|
5330
|
+
if (!FORMAT_NAMES.includes(meta.format)) return void 0;
|
|
5331
|
+
if (!isPositiveInteger(meta.revision)) return void 0;
|
|
5332
|
+
if (!isNonNegativeInteger(meta.bytes)) return void 0;
|
|
5333
|
+
if (!isNonNegativeInteger(meta.createdAt)) return void 0;
|
|
5334
|
+
if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
|
|
5335
|
+
if (meta.title !== void 0 && typeof meta.title !== "string")
|
|
5336
|
+
return void 0;
|
|
5337
|
+
const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
|
|
5338
|
+
return {
|
|
5339
|
+
handle,
|
|
5340
|
+
format: meta.format,
|
|
5341
|
+
revision: meta.revision,
|
|
5342
|
+
bytes: meta.bytes,
|
|
5343
|
+
createdAt: meta.createdAt,
|
|
5344
|
+
updatedAt: meta.updatedAt,
|
|
5345
|
+
...typeof meta.title === "string" && { title: meta.title },
|
|
5346
|
+
pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
|
|
5347
|
+
};
|
|
5348
|
+
}
|
|
5349
|
+
function isPositiveInteger(value) {
|
|
5350
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
5351
|
+
}
|
|
5352
|
+
function isNonNegativeInteger(value) {
|
|
5353
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
5354
|
+
}
|
|
5355
|
+
|
|
5356
|
+
// src/workspace/store.ts
|
|
4914
5357
|
var WORKSPACE_ERROR_CODES = {
|
|
4915
5358
|
EVICTED: "E_WORKSPACE_EVICTED",
|
|
4916
5359
|
LIMIT: "E_WORKSPACE_LIMIT",
|
|
4917
5360
|
DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
|
|
4918
|
-
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
|
|
5361
|
+
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
|
|
5362
|
+
/**
|
|
5363
|
+
* A revision could not be mirrored to disk (#290). A warning, never a
|
|
5364
|
+
* failure: the edit landed and the workspace is usable, but this connection
|
|
5365
|
+
* has stopped being survivable and saying so is the whole point.
|
|
5366
|
+
*/
|
|
5367
|
+
NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
|
|
5368
|
+
/**
|
|
5369
|
+
* A close could not delete the durable copy, so nothing was released (#290).
|
|
5370
|
+
* An error rather than a warning: the agent asked for the document to be
|
|
5371
|
+
* destroyed, and it is still there to be read by the next connection.
|
|
5372
|
+
*/
|
|
5373
|
+
NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
|
|
4919
5374
|
};
|
|
4920
5375
|
var DEFAULT_WORKSPACE_LIMITS = {
|
|
4921
5376
|
maxWorkspaces: 16,
|
|
@@ -4935,6 +5390,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
4935
5390
|
};
|
|
4936
5391
|
const now = options.now ?? Date.now;
|
|
4937
5392
|
const newHandle = options.newHandle ?? defaultHandle;
|
|
5393
|
+
const persistence = options.persistence;
|
|
4938
5394
|
const entries = /* @__PURE__ */ new Map();
|
|
4939
5395
|
const tombstones = /* @__PURE__ */ new Map();
|
|
4940
5396
|
function footprint(entry) {
|
|
@@ -4947,12 +5403,8 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
4947
5403
|
for (const entry of entries.values()) total += footprint(entry);
|
|
4948
5404
|
return total;
|
|
4949
5405
|
}
|
|
4950
|
-
function tombstone(
|
|
4951
|
-
tombstones.set(
|
|
4952
|
-
reason,
|
|
4953
|
-
at: now(),
|
|
4954
|
-
revision: entry.revision
|
|
4955
|
-
});
|
|
5406
|
+
function tombstone(handle, reason, revision) {
|
|
5407
|
+
tombstones.set(handle, { reason, at: now(), revision });
|
|
4956
5408
|
while (tombstones.size > MAX_TOMBSTONES) {
|
|
4957
5409
|
const oldest = tombstones.keys().next();
|
|
4958
5410
|
if (oldest.done) break;
|
|
@@ -4962,7 +5414,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
4962
5414
|
function evict(entry, reason) {
|
|
4963
5415
|
entries.delete(entry.handle);
|
|
4964
5416
|
tombstones.delete(entry.handle);
|
|
4965
|
-
tombstone(entry, reason);
|
|
5417
|
+
tombstone(entry.handle, reason, entry.revision);
|
|
4966
5418
|
}
|
|
4967
5419
|
function sweep() {
|
|
4968
5420
|
const at = now();
|
|
@@ -4970,6 +5422,108 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
4970
5422
|
if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
|
|
4971
5423
|
}
|
|
4972
5424
|
}
|
|
5425
|
+
async function persist(entry) {
|
|
5426
|
+
if (!persistence) return void 0;
|
|
5427
|
+
try {
|
|
5428
|
+
await persistence.save({
|
|
5429
|
+
handle: entry.handle,
|
|
5430
|
+
format: entry.format,
|
|
5431
|
+
createdAt: entry.createdAt,
|
|
5432
|
+
updatedAt: entry.updatedAt,
|
|
5433
|
+
...entry.title !== void 0 && { title: entry.title },
|
|
5434
|
+
head: {
|
|
5435
|
+
revision: entry.revision,
|
|
5436
|
+
text: entry.text,
|
|
5437
|
+
bytes: entry.bytes
|
|
5438
|
+
},
|
|
5439
|
+
pins: [...entry.pins.entries()].map(([revision, pin]) => ({
|
|
5440
|
+
revision,
|
|
5441
|
+
text: pin.text,
|
|
5442
|
+
bytes: pin.bytes
|
|
5443
|
+
}))
|
|
5444
|
+
});
|
|
5445
|
+
entry.persisted = true;
|
|
5446
|
+
return void 0;
|
|
5447
|
+
} catch (error) {
|
|
5448
|
+
entry.persisted = false;
|
|
5449
|
+
return diagnostic(
|
|
5450
|
+
WORKSPACE_ERROR_CODES.NOT_PERSISTED,
|
|
5451
|
+
`Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${persistence.root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
5452
|
+
{
|
|
5453
|
+
severity: "warning",
|
|
5454
|
+
suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
|
|
5455
|
+
context: {
|
|
5456
|
+
handle: entry.handle,
|
|
5457
|
+
revision: entry.revision,
|
|
5458
|
+
workspaceRoot: persistence.root
|
|
5459
|
+
}
|
|
5460
|
+
}
|
|
5461
|
+
);
|
|
5462
|
+
}
|
|
5463
|
+
}
|
|
5464
|
+
function committed(record, warning) {
|
|
5465
|
+
return { ok: true, record, ...warning && { warnings: [warning] } };
|
|
5466
|
+
}
|
|
5467
|
+
async function rehydrate(handle) {
|
|
5468
|
+
if (!persistence || !isPersistableHandle(handle)) return void 0;
|
|
5469
|
+
let loaded;
|
|
5470
|
+
try {
|
|
5471
|
+
loaded = await persistence.load(handle);
|
|
5472
|
+
} catch {
|
|
5473
|
+
return void 0;
|
|
5474
|
+
}
|
|
5475
|
+
if (!loaded) return void 0;
|
|
5476
|
+
const restored = loaded;
|
|
5477
|
+
const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
|
|
5478
|
+
const needed = pins.reduce(
|
|
5479
|
+
(total, pin) => total + pin.bytes,
|
|
5480
|
+
restored.head.bytes
|
|
5481
|
+
);
|
|
5482
|
+
if (restored.head.bytes > limits.maxDocumentBytes) {
|
|
5483
|
+
return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
|
|
5484
|
+
}
|
|
5485
|
+
if (!hasRoom(needed, true)) {
|
|
5486
|
+
return failure(
|
|
5487
|
+
WORKSPACE_ERROR_CODES.LIMIT,
|
|
5488
|
+
`Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
|
|
5489
|
+
{
|
|
5490
|
+
suggestion: "Close a workspace you have finished with, then use this handle again.",
|
|
5491
|
+
context: {
|
|
5492
|
+
handle,
|
|
5493
|
+
bytes: needed,
|
|
5494
|
+
maxTotalBytes: limits.maxTotalBytes,
|
|
5495
|
+
maxWorkspaces: limits.maxWorkspaces
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
);
|
|
5499
|
+
}
|
|
5500
|
+
const entry = {
|
|
5501
|
+
handle,
|
|
5502
|
+
format: restored.format,
|
|
5503
|
+
revision: restored.head.revision,
|
|
5504
|
+
text: restored.head.text,
|
|
5505
|
+
bytes: restored.head.bytes,
|
|
5506
|
+
createdAt: restored.createdAt,
|
|
5507
|
+
updatedAt: restored.updatedAt,
|
|
5508
|
+
touchedAt: now(),
|
|
5509
|
+
...restored.title !== void 0 && { title: restored.title },
|
|
5510
|
+
pins: new Map(
|
|
5511
|
+
pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
|
|
5512
|
+
),
|
|
5513
|
+
persisted: true
|
|
5514
|
+
};
|
|
5515
|
+
entries.set(handle, entry);
|
|
5516
|
+
tombstones.delete(handle);
|
|
5517
|
+
return { ok: true, entry };
|
|
5518
|
+
}
|
|
5519
|
+
async function resolve3(handle) {
|
|
5520
|
+
const entry = entries.get(handle);
|
|
5521
|
+
if (entry) return { entry };
|
|
5522
|
+
const restored = await rehydrate(handle);
|
|
5523
|
+
if (restored === void 0) return missing(handle);
|
|
5524
|
+
if (!restored.ok) return restored;
|
|
5525
|
+
return { entry: restored.entry };
|
|
5526
|
+
}
|
|
4973
5527
|
function missing(handle) {
|
|
4974
5528
|
const grave = tombstones.get(handle);
|
|
4975
5529
|
if (grave?.reason === "ttl") {
|
|
@@ -5005,12 +5559,29 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5005
5559
|
createdAt: new Date(entry.createdAt).toISOString(),
|
|
5006
5560
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
5007
5561
|
...entry.title !== void 0 && { title: entry.title },
|
|
5008
|
-
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
|
|
5562
|
+
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
|
|
5563
|
+
...persistence && { persisted: entry.persisted }
|
|
5564
|
+
};
|
|
5565
|
+
}
|
|
5566
|
+
function fromMeta(meta) {
|
|
5567
|
+
return {
|
|
5568
|
+
handle: meta.handle,
|
|
5569
|
+
format: meta.format,
|
|
5570
|
+
revision: meta.revision,
|
|
5571
|
+
bytes: meta.bytes,
|
|
5572
|
+
createdAt: new Date(meta.createdAt).toISOString(),
|
|
5573
|
+
updatedAt: new Date(meta.updatedAt).toISOString(),
|
|
5574
|
+
...meta.title !== void 0 && { title: meta.title },
|
|
5575
|
+
pinnedRevisions: [...meta.pinnedRevisions],
|
|
5576
|
+
persisted: true
|
|
5009
5577
|
};
|
|
5010
5578
|
}
|
|
5011
5579
|
return {
|
|
5012
5580
|
available: true,
|
|
5013
5581
|
limits,
|
|
5582
|
+
...persistence && {
|
|
5583
|
+
persistence: { root: persistence.root, limits: persistence.limits }
|
|
5584
|
+
},
|
|
5014
5585
|
usage() {
|
|
5015
5586
|
sweep();
|
|
5016
5587
|
return { workspaces: entries.size, bytes: totalBytes() };
|
|
@@ -5051,15 +5622,18 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5051
5622
|
updatedAt: at,
|
|
5052
5623
|
touchedAt: at,
|
|
5053
5624
|
...input.title !== void 0 && { title: input.title },
|
|
5054
|
-
pins: /* @__PURE__ */ new Map()
|
|
5625
|
+
pins: /* @__PURE__ */ new Map(),
|
|
5626
|
+
persisted: false
|
|
5055
5627
|
};
|
|
5056
5628
|
entries.set(entry.handle, entry);
|
|
5057
|
-
|
|
5629
|
+
const warning = await persist(entry);
|
|
5630
|
+
return committed(toRecord(entry), warning);
|
|
5058
5631
|
},
|
|
5059
5632
|
async get(handle, readOptions) {
|
|
5060
5633
|
sweep();
|
|
5061
|
-
const
|
|
5062
|
-
if (!entry) return
|
|
5634
|
+
const found = await resolve3(handle);
|
|
5635
|
+
if (!("entry" in found)) return found;
|
|
5636
|
+
const entry = found.entry;
|
|
5063
5637
|
entry.touchedAt = now();
|
|
5064
5638
|
let text = entry.text;
|
|
5065
5639
|
let bytes = entry.bytes;
|
|
@@ -5084,15 +5658,16 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5084
5658
|
context: { handle, pointer }
|
|
5085
5659
|
});
|
|
5086
5660
|
}
|
|
5087
|
-
const
|
|
5088
|
-
if (
|
|
5661
|
+
const found2 = resolvePointer(document, parsed.tokens);
|
|
5662
|
+
if (found2.found) projection[pointer] = found2.value;
|
|
5089
5663
|
}
|
|
5090
5664
|
return { ok: true, record, document, projection };
|
|
5091
5665
|
},
|
|
5092
5666
|
async patch(input) {
|
|
5093
5667
|
sweep();
|
|
5094
|
-
const
|
|
5095
|
-
if (!entry) return
|
|
5668
|
+
const found = await resolve3(input.handle);
|
|
5669
|
+
if (!("entry" in found)) return found;
|
|
5670
|
+
const entry = found.entry;
|
|
5096
5671
|
entry.touchedAt = now();
|
|
5097
5672
|
if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
|
|
5098
5673
|
return stale(
|
|
@@ -5158,12 +5733,14 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5158
5733
|
entry.revision += 1;
|
|
5159
5734
|
entry.updatedAt = now();
|
|
5160
5735
|
entry.touchedAt = entry.updatedAt;
|
|
5161
|
-
|
|
5736
|
+
const warning = await persist(entry);
|
|
5737
|
+
return committed(toRecord(entry), warning);
|
|
5162
5738
|
},
|
|
5163
5739
|
async snapshot(handle) {
|
|
5164
5740
|
sweep();
|
|
5165
|
-
const
|
|
5166
|
-
if (!entry) return
|
|
5741
|
+
const found = await resolve3(handle);
|
|
5742
|
+
if (!("entry" in found)) return found;
|
|
5743
|
+
const entry = found.entry;
|
|
5167
5744
|
entry.touchedAt = now();
|
|
5168
5745
|
const revision = entry.revision;
|
|
5169
5746
|
const document = JSON.parse(entry.text);
|
|
@@ -5172,24 +5749,70 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5172
5749
|
entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
|
|
5173
5750
|
}
|
|
5174
5751
|
}
|
|
5175
|
-
|
|
5176
|
-
},
|
|
5177
|
-
async list() {
|
|
5178
|
-
sweep();
|
|
5752
|
+
const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
|
|
5179
5753
|
return {
|
|
5180
5754
|
ok: true,
|
|
5181
|
-
|
|
5755
|
+
record: toRecord(entry),
|
|
5756
|
+
document,
|
|
5757
|
+
...warning && { warnings: [warning] }
|
|
5182
5758
|
};
|
|
5183
5759
|
},
|
|
5760
|
+
async list() {
|
|
5761
|
+
sweep();
|
|
5762
|
+
const records = [...entries.values()].map((entry) => toRecord(entry));
|
|
5763
|
+
if (!persistence) return { ok: true, records };
|
|
5764
|
+
const resident = new Set(records.map((record) => record.handle));
|
|
5765
|
+
let stored = [];
|
|
5766
|
+
try {
|
|
5767
|
+
stored = await persistence.list();
|
|
5768
|
+
} catch {
|
|
5769
|
+
}
|
|
5770
|
+
for (const meta of stored) {
|
|
5771
|
+
if (resident.has(meta.handle)) continue;
|
|
5772
|
+
if (tombstones.get(meta.handle)?.reason === "closed") continue;
|
|
5773
|
+
records.push(fromMeta(meta));
|
|
5774
|
+
}
|
|
5775
|
+
return { ok: true, records };
|
|
5776
|
+
},
|
|
5184
5777
|
async close(handle) {
|
|
5185
5778
|
sweep();
|
|
5186
5779
|
const entry = entries.get(handle);
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5780
|
+
let durable = false;
|
|
5781
|
+
if (persistence) {
|
|
5782
|
+
try {
|
|
5783
|
+
durable = await persistence.remove(handle);
|
|
5784
|
+
} catch (error) {
|
|
5785
|
+
return failure(
|
|
5786
|
+
WORKSPACE_ERROR_CODES.NOT_CLOSED,
|
|
5787
|
+
`Workspace ${handle} could not be removed from ${persistence.root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
|
|
5788
|
+
{
|
|
5789
|
+
suggestion: "Check the workspace directory is writable, then close again.",
|
|
5790
|
+
context: { handle, workspaceRoot: persistence.root }
|
|
5791
|
+
}
|
|
5792
|
+
);
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
if (entry) evict(entry, "closed");
|
|
5796
|
+
else if (durable) tombstone(handle, "closed", 0);
|
|
5797
|
+
return { ok: true, handle, closed: entry !== void 0 || durable };
|
|
5190
5798
|
},
|
|
5799
|
+
/**
|
|
5800
|
+
* Release memory, keep the disk.
|
|
5801
|
+
*
|
|
5802
|
+
* The asymmetry with `close` is the point: this exists for a host
|
|
5803
|
+
* reclaiming memory at a moment of its own choosing, and #290 is precisely
|
|
5804
|
+
* about an event that ends a connection not being allowed to destroy
|
|
5805
|
+
* revisions. The handles come back from disk on next use.
|
|
5806
|
+
*/
|
|
5191
5807
|
async closeAll() {
|
|
5192
|
-
for (const entry of [...entries.values()])
|
|
5808
|
+
for (const entry of [...entries.values()]) {
|
|
5809
|
+
if (!persistence) {
|
|
5810
|
+
evict(entry, "closed");
|
|
5811
|
+
continue;
|
|
5812
|
+
}
|
|
5813
|
+
entries.delete(entry.handle);
|
|
5814
|
+
tombstones.delete(entry.handle);
|
|
5815
|
+
}
|
|
5193
5816
|
}
|
|
5194
5817
|
};
|
|
5195
5818
|
function stale(entry, wanted, pinned, mutation = false) {
|
|
@@ -5209,7 +5832,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5209
5832
|
}
|
|
5210
5833
|
}
|
|
5211
5834
|
function defaultHandle() {
|
|
5212
|
-
return `ws_${
|
|
5835
|
+
return `ws_${randomBytes3(9).toString("base64url")}`;
|
|
5213
5836
|
}
|
|
5214
5837
|
function serialize(document) {
|
|
5215
5838
|
let text;
|
|
@@ -5262,7 +5885,7 @@ var workspaceSchema = {
|
|
|
5262
5885
|
properties: {
|
|
5263
5886
|
handle: {
|
|
5264
5887
|
type: "string",
|
|
5265
|
-
description: "Opaque
|
|
5888
|
+
description: "Opaque and server-generated. Valid only on this connection, unless the server has a workspace directory \u2014 then it survives a reconnect and jto_workspace_list hands it back."
|
|
5266
5889
|
},
|
|
5267
5890
|
format: { type: "string", enum: [...FORMAT_NAMES] },
|
|
5268
5891
|
revision: {
|
|
@@ -5280,6 +5903,10 @@ var workspaceSchema = {
|
|
|
5280
5903
|
type: "array",
|
|
5281
5904
|
items: { type: "integer" },
|
|
5282
5905
|
description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
|
|
5906
|
+
},
|
|
5907
|
+
persisted: {
|
|
5908
|
+
type: "boolean",
|
|
5909
|
+
description: "True when this revision is mirrored to disk and so survives a lost connection. Absent when this connection has no workspace directory configured."
|
|
5283
5910
|
}
|
|
5284
5911
|
},
|
|
5285
5912
|
required: [
|
|
@@ -5293,6 +5920,26 @@ var workspaceSchema = {
|
|
|
5293
5920
|
],
|
|
5294
5921
|
additionalProperties: false
|
|
5295
5922
|
};
|
|
5923
|
+
var persistenceSchema = {
|
|
5924
|
+
type: "object",
|
|
5925
|
+
description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
|
|
5926
|
+
properties: {
|
|
5927
|
+
root: { type: "string" },
|
|
5928
|
+
maxWorkspaces: { type: "integer" },
|
|
5929
|
+
maxRevisionsPerWorkspace: {
|
|
5930
|
+
type: "integer",
|
|
5931
|
+
description: "Revision files kept per handle: the head plus its pins."
|
|
5932
|
+
},
|
|
5933
|
+
maxEntryBytes: { type: "integer" }
|
|
5934
|
+
},
|
|
5935
|
+
required: [
|
|
5936
|
+
"root",
|
|
5937
|
+
"maxWorkspaces",
|
|
5938
|
+
"maxRevisionsPerWorkspace",
|
|
5939
|
+
"maxEntryBytes"
|
|
5940
|
+
],
|
|
5941
|
+
additionalProperties: false
|
|
5942
|
+
};
|
|
5296
5943
|
var limitsSchema = {
|
|
5297
5944
|
type: "object",
|
|
5298
5945
|
description: "What this connection allows. Bounded on purpose; see the codes above.",
|
|
@@ -5324,7 +5971,9 @@ function isMemoryStore(store) {
|
|
|
5324
5971
|
}
|
|
5325
5972
|
function ensureStore(deps) {
|
|
5326
5973
|
if (deps.workspaces().available || hasWorkspaceStore()) return;
|
|
5327
|
-
const owned = createMemoryWorkspaceStore(
|
|
5974
|
+
const owned = createMemoryWorkspaceStore(
|
|
5975
|
+
deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
|
|
5976
|
+
);
|
|
5328
5977
|
deps.workspaces = () => owned;
|
|
5329
5978
|
}
|
|
5330
5979
|
function register8(server, deps) {
|
|
@@ -5367,9 +6016,9 @@ function register8(server, deps) {
|
|
|
5367
6016
|
...args.title !== void 0 && { title: args.title }
|
|
5368
6017
|
});
|
|
5369
6018
|
if (!created.ok) return created;
|
|
5370
|
-
return success(
|
|
5371
|
-
|
|
5372
|
-
seeded ? [
|
|
6019
|
+
return success({ workspace: created.record }, [
|
|
6020
|
+
...created.warnings ?? [],
|
|
6021
|
+
...seeded ? [
|
|
5373
6022
|
diagnostic(
|
|
5374
6023
|
"W_BLANK_DOCUMENT",
|
|
5375
6024
|
`Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
|
|
@@ -5379,7 +6028,7 @@ function register8(server, deps) {
|
|
|
5379
6028
|
}
|
|
5380
6029
|
)
|
|
5381
6030
|
] : []
|
|
5382
|
-
);
|
|
6031
|
+
]);
|
|
5383
6032
|
})
|
|
5384
6033
|
)
|
|
5385
6034
|
);
|
|
@@ -5445,17 +6094,20 @@ function register8(server, deps) {
|
|
|
5445
6094
|
const missingPaths = projecting ? args.paths.filter(
|
|
5446
6095
|
(pointer) => !(pointer in projection)
|
|
5447
6096
|
) : [];
|
|
5448
|
-
const diagnostics =
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
6097
|
+
const diagnostics = [
|
|
6098
|
+
...read.warnings ?? [],
|
|
6099
|
+
...missingPaths.map(
|
|
6100
|
+
(pointer) => diagnostic(
|
|
6101
|
+
"W_PATH_NOT_FOUND",
|
|
6102
|
+
`${pointer} does not resolve in revision ${read.record.revision}.`,
|
|
6103
|
+
{
|
|
6104
|
+
severity: "warning",
|
|
6105
|
+
path: pointer,
|
|
6106
|
+
suggestion: "Read a shorter prefix of the pointer to see what is actually there."
|
|
6107
|
+
}
|
|
6108
|
+
)
|
|
5457
6109
|
)
|
|
5458
|
-
|
|
6110
|
+
];
|
|
5459
6111
|
return success(
|
|
5460
6112
|
{
|
|
5461
6113
|
workspace: read.record,
|
|
@@ -5524,7 +6176,7 @@ function register8(server, deps) {
|
|
|
5524
6176
|
}
|
|
5525
6177
|
});
|
|
5526
6178
|
if (!patched.ok) return patched;
|
|
5527
|
-
return success({ workspace: patched.record });
|
|
6179
|
+
return success({ workspace: patched.record }, patched.warnings ?? []);
|
|
5528
6180
|
})
|
|
5529
6181
|
)
|
|
5530
6182
|
);
|
|
@@ -5570,7 +6222,7 @@ function register8(server, deps) {
|
|
|
5570
6222
|
await guarded(async () => {
|
|
5571
6223
|
const snapshot = await deps.workspaces().snapshot(args.handle);
|
|
5572
6224
|
if (!snapshot.ok) return snapshot;
|
|
5573
|
-
const diagnostics = [];
|
|
6225
|
+
const diagnostics = [...snapshot.warnings ?? []];
|
|
5574
6226
|
if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
|
|
5575
6227
|
diagnostics.push(
|
|
5576
6228
|
diagnostic(
|
|
@@ -5613,7 +6265,7 @@ function register8(server, deps) {
|
|
|
5613
6265
|
"jto_workspace_list",
|
|
5614
6266
|
{
|
|
5615
6267
|
title: "List open workspaces",
|
|
5616
|
-
description: "Every document open on this connection, with its revision and size. Call this to recover handles you no longer have \u2014 it is the cheapest way back after losing track of what you opened. An empty list means nothing is open, not that anything failed.",
|
|
6268
|
+
description: "Every document open on this connection, with its revision and size. Call this to recover handles you no longer have \u2014 it is the cheapest way back after losing track of what you opened, including after a reconnect when the server has a workspace directory. An empty list means nothing is open, not that anything failed.",
|
|
5617
6269
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
5618
6270
|
inputSchema: S({
|
|
5619
6271
|
type: "object",
|
|
@@ -5628,6 +6280,7 @@ function register8(server, deps) {
|
|
|
5628
6280
|
description: "False when this connection has workspaces switched off."
|
|
5629
6281
|
},
|
|
5630
6282
|
limits: limitsSchema,
|
|
6283
|
+
persistence: persistenceSchema,
|
|
5631
6284
|
usage: {
|
|
5632
6285
|
type: "object",
|
|
5633
6286
|
properties: {
|
|
@@ -5645,12 +6298,24 @@ function register8(server, deps) {
|
|
|
5645
6298
|
const store = deps.workspaces();
|
|
5646
6299
|
const listed = await store.list();
|
|
5647
6300
|
if (!listed.ok) return listed;
|
|
5648
|
-
const budget = isMemoryStore(store) ? {
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
6301
|
+
const budget = isMemoryStore(store) ? {
|
|
6302
|
+
limits: store.limits,
|
|
6303
|
+
usage: store.usage(),
|
|
6304
|
+
...store.persistence && {
|
|
6305
|
+
persistence: {
|
|
6306
|
+
root: store.persistence.root,
|
|
6307
|
+
...store.persistence.limits
|
|
6308
|
+
}
|
|
6309
|
+
}
|
|
6310
|
+
} : {};
|
|
6311
|
+
return success(
|
|
6312
|
+
{
|
|
6313
|
+
workspaces: listed.records,
|
|
6314
|
+
available: store.available,
|
|
6315
|
+
...budget
|
|
6316
|
+
},
|
|
6317
|
+
listed.warnings ?? []
|
|
6318
|
+
);
|
|
5654
6319
|
})
|
|
5655
6320
|
)
|
|
5656
6321
|
);
|
|
@@ -5658,7 +6323,7 @@ function register8(server, deps) {
|
|
|
5658
6323
|
"jto_workspace_close",
|
|
5659
6324
|
{
|
|
5660
6325
|
title: "Close a workspace",
|
|
5661
|
-
description: "Release a handle and
|
|
6326
|
+
description: "Release a handle and everything behind it \u2014 the document, its pinned snapshots, and the durable copy when this connection keeps one. Idempotent: closing a handle that is already gone reports `closed: false` rather than failing. Snapshot anything you still want first \u2014 closing is not recoverable.",
|
|
5662
6327
|
annotations: {
|
|
5663
6328
|
readOnlyHint: false,
|
|
5664
6329
|
destructiveHint: true,
|
|
@@ -5705,6 +6370,7 @@ var RESOURCE_URIS = {
|
|
|
5705
6370
|
catalog: "jto://catalog",
|
|
5706
6371
|
renderers: "jto://renderers",
|
|
5707
6372
|
themes: "jto://themes",
|
|
6373
|
+
themeValues: "jto://themes/values",
|
|
5708
6374
|
templates: "jto://templates",
|
|
5709
6375
|
documentSchema: (format) => `jto://schema/${format}/document`,
|
|
5710
6376
|
themeSchema: (format) => `jto://schema/${format}/theme`
|
|
@@ -5756,7 +6422,7 @@ function register9(server, deps) {
|
|
|
5756
6422
|
RESOURCE_URIS.themes,
|
|
5757
6423
|
{
|
|
5758
6424
|
title: "Built-in themes",
|
|
5759
|
-
description: "Theme names shipped with each format, usable as a document\u2019s props.theme or as the tools\u2019 theme option.",
|
|
6425
|
+
description: "Theme names shipped with each format, usable as a document\u2019s props.theme or as the tools\u2019 theme option. jto://themes/values carries what each name actually looks like.",
|
|
5760
6426
|
mimeType: JSON_MIME
|
|
5761
6427
|
},
|
|
5762
6428
|
async (uri) => {
|
|
@@ -5769,6 +6435,33 @@ function register9(server, deps) {
|
|
|
5769
6435
|
});
|
|
5770
6436
|
}
|
|
5771
6437
|
);
|
|
6438
|
+
server.registerResource(
|
|
6439
|
+
"theme-values",
|
|
6440
|
+
RESOURCE_URIS.themeValues,
|
|
6441
|
+
{
|
|
6442
|
+
title: "Built-in theme values",
|
|
6443
|
+
description: "The palette, fonts, style tables and component defaults behind every built-in theme name \u2014 what a document actually opts into with props.theme. A name alone cannot tell you whether a theme fits the brief; this can.",
|
|
6444
|
+
mimeType: JSON_MIME
|
|
6445
|
+
},
|
|
6446
|
+
async (uri) => {
|
|
6447
|
+
const formats = await Promise.all(
|
|
6448
|
+
FORMAT_NAMES.map(async (format) => {
|
|
6449
|
+
const adapter = deps.getAdapter(format);
|
|
6450
|
+
let themes;
|
|
6451
|
+
try {
|
|
6452
|
+
themes = adapter.getBuiltinThemeValues ? await adapter.getBuiltinThemeValues() : adapter.getBuiltinThemes();
|
|
6453
|
+
} catch {
|
|
6454
|
+
themes = adapter.getBuiltinThemes();
|
|
6455
|
+
}
|
|
6456
|
+
return {
|
|
6457
|
+
format,
|
|
6458
|
+
themes
|
|
6459
|
+
};
|
|
6460
|
+
})
|
|
6461
|
+
);
|
|
6462
|
+
return jsonContents(uri, { formats });
|
|
6463
|
+
}
|
|
6464
|
+
);
|
|
5772
6465
|
server.registerResource(
|
|
5773
6466
|
"templates",
|
|
5774
6467
|
RESOURCE_URIS.templates,
|
|
@@ -5817,6 +6510,7 @@ Working rules:
|
|
|
5817
6510
|
- Discover before authoring. Call jto_info first, then jto_discover and jto_describe_component (or read the jto:// resources) for the components and renderer ids a format actually supports.
|
|
5818
6511
|
- Make small edits. With a workspace handle, patch precisely (RFC 6902 over RFC 6901 paths) instead of resending the whole document; without one, change one region at a time.
|
|
5819
6512
|
- Validate often. Run jto_validate after each edit rather than once at the end; diagnostics are path-addressed, so they map straight back onto the JSON you just changed.
|
|
6513
|
+
- Treat design findings as defects. Schema-valid is not well-designed: jto_validate also lints layout and legibility (W_QUALITY_* \u2014 undeclared slide canvas, text overflowing its box, overcrowded slides, table widths exceeding their section). These never block generation, but they almost always show in the rendered result \u2014 repair them like errors.
|
|
5820
6514
|
- Preview when the answer is visual. jto_preview renders pages to PNG; use it whenever layout, overflow or fit is in question, not only before finishing.
|
|
5821
6515
|
- Snapshot before risky changes. jto_workspace_snapshot pins the current revision so a restructuring you cannot cleanly undo is still recoverable.
|
|
5822
6516
|
|
|
@@ -5846,6 +6540,12 @@ function createServerFactory(deps) {
|
|
|
5846
6540
|
|
|
5847
6541
|
// src/lib/deps.ts
|
|
5848
6542
|
function createToolDeps(options = {}) {
|
|
6543
|
+
const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
|
|
6544
|
+
...options.workspaceDir !== void 0 && {
|
|
6545
|
+
flagDir: options.workspaceDir
|
|
6546
|
+
},
|
|
6547
|
+
...options.env !== void 0 && { env: options.env }
|
|
6548
|
+
});
|
|
5849
6549
|
return {
|
|
5850
6550
|
serverVersion: options.serverVersion ?? SERVER_VERSION,
|
|
5851
6551
|
outputRoot: options.outputRoot ?? createOutputRoot({
|
|
@@ -5854,23 +6554,45 @@ function createToolDeps(options = {}) {
|
|
|
5854
6554
|
}),
|
|
5855
6555
|
getAdapter: options.getAdapter ?? getAdapter,
|
|
5856
6556
|
workspaces: options.workspaces ?? getWorkspaceStore,
|
|
6557
|
+
...workspacePersistence !== void 0 && { workspacePersistence },
|
|
5857
6558
|
maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
|
|
5858
6559
|
};
|
|
5859
6560
|
}
|
|
5860
6561
|
|
|
5861
6562
|
// src/cli.ts
|
|
6563
|
+
function takeValue(argv, index) {
|
|
6564
|
+
const next = argv[index];
|
|
6565
|
+
if (next === void 0 || next.startsWith("-") || next.trim() === "") {
|
|
6566
|
+
return void 0;
|
|
6567
|
+
}
|
|
6568
|
+
return { value: next };
|
|
6569
|
+
}
|
|
5862
6570
|
function parseArgs(argv) {
|
|
5863
|
-
const parsed = {
|
|
6571
|
+
const parsed = {
|
|
6572
|
+
version: false,
|
|
6573
|
+
help: false,
|
|
6574
|
+
unknown: [],
|
|
6575
|
+
missingValue: []
|
|
6576
|
+
};
|
|
5864
6577
|
for (let index = 0; index < argv.length; index += 1) {
|
|
5865
6578
|
const arg = argv[index];
|
|
5866
6579
|
if (arg === "--version" || arg === "-v") {
|
|
5867
6580
|
parsed.version = true;
|
|
5868
6581
|
} else if (arg === "--help" || arg === "-h") {
|
|
5869
6582
|
parsed.help = true;
|
|
5870
|
-
} else if (arg === "--output-dir") {
|
|
5871
|
-
|
|
6583
|
+
} else if (arg === "--output-dir" || arg === "--workspace-dir") {
|
|
6584
|
+
const taken = takeValue(argv, index + 1);
|
|
6585
|
+
if (!taken) {
|
|
6586
|
+
parsed.missingValue.push(arg);
|
|
6587
|
+
continue;
|
|
6588
|
+
}
|
|
6589
|
+
index += 1;
|
|
6590
|
+
if (arg === "--output-dir") parsed.outputDir = taken.value;
|
|
6591
|
+
else parsed.workspaceDir = taken.value;
|
|
5872
6592
|
} else if (arg.startsWith("--output-dir=")) {
|
|
5873
6593
|
parsed.outputDir = arg.slice("--output-dir=".length);
|
|
6594
|
+
} else if (arg.startsWith("--workspace-dir=")) {
|
|
6595
|
+
parsed.workspaceDir = arg.slice("--workspace-dir=".length);
|
|
5874
6596
|
} else {
|
|
5875
6597
|
parsed.unknown.push(arg);
|
|
5876
6598
|
}
|
|
@@ -5888,11 +6610,16 @@ Options:
|
|
|
5888
6610
|
--output-dir <path> Directory generated files are written to. Overrides
|
|
5889
6611
|
${OUTPUT_DIR_ENV}. Defaults to a per-connection
|
|
5890
6612
|
directory under the system temp dir.
|
|
6613
|
+
--workspace-dir <p> Directory workspace revisions are mirrored to, so a lost
|
|
6614
|
+
client session does not destroy them. Overrides
|
|
6615
|
+
${WORKSPACE_DIR_ENV}. Off by default:
|
|
6616
|
+
without it, handles live only in memory.
|
|
5891
6617
|
-v, --version Print the version and exit.
|
|
5892
6618
|
-h, --help Print this help and exit.
|
|
5893
6619
|
|
|
5894
6620
|
Environment:
|
|
5895
6621
|
${OUTPUT_DIR_ENV} Output root, when --output-dir is absent.
|
|
6622
|
+
${WORKSPACE_DIR_ENV} Workspace root, when --workspace-dir is absent.
|
|
5896
6623
|
LIBREOFFICE_PATH LibreOffice binary, for preview.
|
|
5897
6624
|
PDFTOPPM_PATH poppler pdftoppm binary, for preview.
|
|
5898
6625
|
|
|
@@ -5915,13 +6642,23 @@ function main(argv) {
|
|
|
5915
6642
|
process.stderr.write(
|
|
5916
6643
|
`jto-mcp: unknown argument ${args.unknown[0]}
|
|
5917
6644
|
Run jto-mcp --help.
|
|
6645
|
+
`
|
|
6646
|
+
);
|
|
6647
|
+
process.exitCode = 1;
|
|
6648
|
+
return;
|
|
6649
|
+
}
|
|
6650
|
+
if (args.missingValue.length > 0) {
|
|
6651
|
+
process.stderr.write(
|
|
6652
|
+
`jto-mcp: ${args.missingValue[0]} needs a directory path.
|
|
6653
|
+
Run jto-mcp --help.
|
|
5918
6654
|
`
|
|
5919
6655
|
);
|
|
5920
6656
|
process.exitCode = 1;
|
|
5921
6657
|
return;
|
|
5922
6658
|
}
|
|
5923
6659
|
const deps = createToolDeps({
|
|
5924
|
-
...args.outputDir !== void 0 && { outputDir: args.outputDir }
|
|
6660
|
+
...args.outputDir !== void 0 && { outputDir: args.outputDir },
|
|
6661
|
+
...args.workspaceDir !== void 0 && { workspaceDir: args.workspaceDir }
|
|
5925
6662
|
});
|
|
5926
6663
|
const handle = serveStdio(createServerFactory(deps), {
|
|
5927
6664
|
legacy: "serve",
|