@json-to-office/mcp-server 1.5.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 +29 -14
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +687 -96
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +268 -2
- package/dist/index.js +656 -94
- package/dist/index.js.map +1 -1
- package/package.json +5 -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
|
|
|
@@ -420,9 +420,9 @@ var ROOT_SENTINELS = /* @__PURE__ */ new Set(["", "/", "#", "root"]);
|
|
|
420
420
|
function escapePointerSegment(segment) {
|
|
421
421
|
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
422
422
|
}
|
|
423
|
-
function toJsonPointer(
|
|
424
|
-
if (
|
|
425
|
-
const trimmed =
|
|
423
|
+
function toJsonPointer(path7) {
|
|
424
|
+
if (path7 === void 0) return void 0;
|
|
425
|
+
const trimmed = path7.trim();
|
|
426
426
|
if (ROOT_SENTINELS.has(trimmed)) return "";
|
|
427
427
|
const segments = trimmed.startsWith("/") ? trimmed.slice(1).split("/") : trimmed.replace(/\[(\d+)\]/g, ".$1").split(".").filter((segment) => segment !== "");
|
|
428
428
|
if (segments.length === 0) return "";
|
|
@@ -623,11 +623,11 @@ async function probeService(rawUrl, envVar) {
|
|
|
623
623
|
}
|
|
624
624
|
const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
|
|
625
625
|
const net = await import("net");
|
|
626
|
-
return new Promise((
|
|
626
|
+
return new Promise((resolve3) => {
|
|
627
627
|
const socket = net.connect({ host: target.hostname, port });
|
|
628
628
|
const settle = (detail) => {
|
|
629
629
|
socket.destroy();
|
|
630
|
-
|
|
630
|
+
resolve3({
|
|
631
631
|
available: detail === void 0,
|
|
632
632
|
url: target.origin,
|
|
633
633
|
envVar,
|
|
@@ -667,7 +667,7 @@ function register(server, deps) {
|
|
|
667
667
|
"jto_info",
|
|
668
668
|
{
|
|
669
669
|
title: "Server info",
|
|
670
|
-
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.",
|
|
671
671
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
672
672
|
inputSchema: S({
|
|
673
673
|
type: "object",
|
|
@@ -752,9 +752,17 @@ function register(server, deps) {
|
|
|
752
752
|
type: "object",
|
|
753
753
|
properties: {
|
|
754
754
|
available: { type: "boolean" },
|
|
755
|
-
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
|
+
}
|
|
756
764
|
},
|
|
757
|
-
required: ["available", "open"],
|
|
765
|
+
required: ["available", "open", "persistent"],
|
|
758
766
|
additionalProperties: false
|
|
759
767
|
},
|
|
760
768
|
output: {
|
|
@@ -915,7 +923,11 @@ function register(server, deps) {
|
|
|
915
923
|
formats,
|
|
916
924
|
workspaces: {
|
|
917
925
|
available: store.available,
|
|
918
|
-
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
|
+
}
|
|
919
931
|
},
|
|
920
932
|
output: {
|
|
921
933
|
root: deps.outputRoot.path,
|
|
@@ -3136,9 +3148,9 @@ async function digestAssets(document, baseDir) {
|
|
|
3136
3148
|
[...references].map(async (reference) => {
|
|
3137
3149
|
const resolved = path4.resolve(root, reference);
|
|
3138
3150
|
try {
|
|
3139
|
-
const
|
|
3140
|
-
if (!
|
|
3141
|
-
entries.push(`${resolved}|${
|
|
3151
|
+
const stat2 = await fs4.stat(resolved);
|
|
3152
|
+
if (!stat2.isFile()) return;
|
|
3153
|
+
entries.push(`${resolved}|${stat2.size}|${stat2.mtimeMs}`);
|
|
3142
3154
|
} catch {
|
|
3143
3155
|
}
|
|
3144
3156
|
})
|
|
@@ -3168,8 +3180,8 @@ function collectAssetReferences(value, into) {
|
|
|
3168
3180
|
async function digestThemeFile(themePath) {
|
|
3169
3181
|
if (!themePath) return "none";
|
|
3170
3182
|
try {
|
|
3171
|
-
const
|
|
3172
|
-
return `${path4.resolve(themePath)}|${
|
|
3183
|
+
const stat2 = await fs4.stat(themePath);
|
|
3184
|
+
return `${path4.resolve(themePath)}|${stat2.size}|${stat2.mtimeMs}`;
|
|
3173
3185
|
} catch {
|
|
3174
3186
|
return `${path4.resolve(themePath)}|missing`;
|
|
3175
3187
|
}
|
|
@@ -3241,18 +3253,18 @@ function missingDependencyFailure(dependencies) {
|
|
|
3241
3253
|
);
|
|
3242
3254
|
}
|
|
3243
3255
|
async function binaryIdentity(binary, args, signal) {
|
|
3244
|
-
const [version,
|
|
3256
|
+
const [version, stat2] = await Promise.all([
|
|
3245
3257
|
readVersion(binary, args, signal),
|
|
3246
3258
|
fs5.stat(binary).catch(() => void 0)
|
|
3247
3259
|
]);
|
|
3248
3260
|
const identity = [
|
|
3249
3261
|
version ?? "unknown",
|
|
3250
|
-
|
|
3262
|
+
stat2 ? `${stat2.size}:${stat2.mtimeMs}` : "nostat"
|
|
3251
3263
|
].join("|");
|
|
3252
3264
|
return { identity, ...version !== void 0 && { version } };
|
|
3253
3265
|
}
|
|
3254
3266
|
function readVersion(binary, args, signal) {
|
|
3255
|
-
return new Promise((
|
|
3267
|
+
return new Promise((resolve3) => {
|
|
3256
3268
|
execFile(
|
|
3257
3269
|
binary,
|
|
3258
3270
|
args,
|
|
@@ -3266,8 +3278,8 @@ function readVersion(binary, args, signal) {
|
|
|
3266
3278
|
const text = `${stdout ?? ""}
|
|
3267
3279
|
${stderr ?? ""}`.trim();
|
|
3268
3280
|
const line = text.split("\n")[0]?.trim();
|
|
3269
|
-
if (!line && error) return
|
|
3270
|
-
|
|
3281
|
+
if (!line && error) return resolve3(void 0);
|
|
3282
|
+
resolve3(line || void 0);
|
|
3271
3283
|
}
|
|
3272
3284
|
);
|
|
3273
3285
|
});
|
|
@@ -3312,12 +3324,12 @@ function defaultPreviewCacheDir() {
|
|
|
3312
3324
|
}
|
|
3313
3325
|
var CACHE_ENTRY_NAME = /^[a-f0-9]{64}\.(?:png|meta\.json)(?:\.tmp-\d+-\d+)?$/;
|
|
3314
3326
|
async function inspectPrivateCacheDir(cacheDir) {
|
|
3315
|
-
const
|
|
3316
|
-
if (!
|
|
3317
|
-
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()) {
|
|
3318
3330
|
return false;
|
|
3319
3331
|
}
|
|
3320
|
-
if (process.platform !== "win32" && (
|
|
3332
|
+
if (process.platform !== "win32" && (stat2.mode & 63) !== 0) {
|
|
3321
3333
|
try {
|
|
3322
3334
|
await fs6.chmod(cacheDir, 448);
|
|
3323
3335
|
} catch {
|
|
@@ -3347,9 +3359,9 @@ async function sweepPreviewCache(cacheDir, options = {}) {
|
|
|
3347
3359
|
for (const name of names) {
|
|
3348
3360
|
if (!CACHE_ENTRY_NAME.test(name)) continue;
|
|
3349
3361
|
const file = path5.join(cacheDir, name);
|
|
3350
|
-
const
|
|
3351
|
-
if (!
|
|
3352
|
-
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 });
|
|
3353
3365
|
}
|
|
3354
3366
|
const doomed = entries.filter((entry) => now - entry.mtimeMs > maxAgeMs);
|
|
3355
3367
|
const kept = entries.filter((entry) => now - entry.mtimeMs <= maxAgeMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
@@ -3436,7 +3448,7 @@ function countPdfPages(pdf) {
|
|
|
3436
3448
|
return matches ? matches.length : 0;
|
|
3437
3449
|
}
|
|
3438
3450
|
function exec(binary, args, timeoutMs, options = {}) {
|
|
3439
|
-
return new Promise((
|
|
3451
|
+
return new Promise((resolve3, reject) => {
|
|
3440
3452
|
execFile2(
|
|
3441
3453
|
binary,
|
|
3442
3454
|
args,
|
|
@@ -3447,7 +3459,7 @@ function exec(binary, args, timeoutMs, options = {}) {
|
|
|
3447
3459
|
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
3448
3460
|
...options.signal && { signal: options.signal }
|
|
3449
3461
|
},
|
|
3450
|
-
(error) => error ? reject(error) :
|
|
3462
|
+
(error) => error ? reject(error) : resolve3()
|
|
3451
3463
|
);
|
|
3452
3464
|
});
|
|
3453
3465
|
}
|
|
@@ -3541,7 +3553,7 @@ async function resolvePdfinfo(pdftoppmPath) {
|
|
|
3541
3553
|
return status.available ? status.path : void 0;
|
|
3542
3554
|
}
|
|
3543
3555
|
async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
|
|
3544
|
-
return new Promise((
|
|
3556
|
+
return new Promise((resolve3) => {
|
|
3545
3557
|
execFile2(
|
|
3546
3558
|
pdfinfo,
|
|
3547
3559
|
[pdfPath],
|
|
@@ -3552,9 +3564,9 @@ async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
|
|
|
3552
3564
|
...signal && { signal }
|
|
3553
3565
|
},
|
|
3554
3566
|
(error, stdout) => {
|
|
3555
|
-
if (error && !stdout) return
|
|
3567
|
+
if (error && !stdout) return resolve3(void 0);
|
|
3556
3568
|
const match = /^Pages:\s+(\d+)/m.exec(stdout ?? "");
|
|
3557
|
-
|
|
3569
|
+
resolve3(match ? Number(match[1]) : void 0);
|
|
3558
3570
|
}
|
|
3559
3571
|
);
|
|
3560
3572
|
});
|
|
@@ -4667,9 +4679,9 @@ function compilePatch(operations) {
|
|
|
4667
4679
|
}
|
|
4668
4680
|
);
|
|
4669
4681
|
}
|
|
4670
|
-
const
|
|
4671
|
-
if (!
|
|
4672
|
-
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, {
|
|
4673
4685
|
context: { op, pointer: raw.path },
|
|
4674
4686
|
suggestion: 'Pointers are RFC 6901: "/children/0/props/text", with "~0" for "~" and "~1" for "/".'
|
|
4675
4687
|
});
|
|
@@ -4677,7 +4689,7 @@ function compilePatch(operations) {
|
|
|
4677
4689
|
const entry = {
|
|
4678
4690
|
op,
|
|
4679
4691
|
path: raw.path,
|
|
4680
|
-
tokens:
|
|
4692
|
+
tokens: path7.tokens
|
|
4681
4693
|
};
|
|
4682
4694
|
if (VALUE_OPS.has(op)) {
|
|
4683
4695
|
if (raw.value === void 0) {
|
|
@@ -5027,12 +5039,338 @@ function jsonEqualTokens(left, right) {
|
|
|
5027
5039
|
}
|
|
5028
5040
|
|
|
5029
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";
|
|
5030
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
|
|
5031
5357
|
var WORKSPACE_ERROR_CODES = {
|
|
5032
5358
|
EVICTED: "E_WORKSPACE_EVICTED",
|
|
5033
5359
|
LIMIT: "E_WORKSPACE_LIMIT",
|
|
5034
5360
|
DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
|
|
5035
|
-
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"
|
|
5036
5374
|
};
|
|
5037
5375
|
var DEFAULT_WORKSPACE_LIMITS = {
|
|
5038
5376
|
maxWorkspaces: 16,
|
|
@@ -5052,6 +5390,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5052
5390
|
};
|
|
5053
5391
|
const now = options.now ?? Date.now;
|
|
5054
5392
|
const newHandle = options.newHandle ?? defaultHandle;
|
|
5393
|
+
const persistence = options.persistence;
|
|
5055
5394
|
const entries = /* @__PURE__ */ new Map();
|
|
5056
5395
|
const tombstones = /* @__PURE__ */ new Map();
|
|
5057
5396
|
function footprint(entry) {
|
|
@@ -5064,12 +5403,8 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5064
5403
|
for (const entry of entries.values()) total += footprint(entry);
|
|
5065
5404
|
return total;
|
|
5066
5405
|
}
|
|
5067
|
-
function tombstone(
|
|
5068
|
-
tombstones.set(
|
|
5069
|
-
reason,
|
|
5070
|
-
at: now(),
|
|
5071
|
-
revision: entry.revision
|
|
5072
|
-
});
|
|
5406
|
+
function tombstone(handle, reason, revision) {
|
|
5407
|
+
tombstones.set(handle, { reason, at: now(), revision });
|
|
5073
5408
|
while (tombstones.size > MAX_TOMBSTONES) {
|
|
5074
5409
|
const oldest = tombstones.keys().next();
|
|
5075
5410
|
if (oldest.done) break;
|
|
@@ -5079,7 +5414,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5079
5414
|
function evict(entry, reason) {
|
|
5080
5415
|
entries.delete(entry.handle);
|
|
5081
5416
|
tombstones.delete(entry.handle);
|
|
5082
|
-
tombstone(entry, reason);
|
|
5417
|
+
tombstone(entry.handle, reason, entry.revision);
|
|
5083
5418
|
}
|
|
5084
5419
|
function sweep() {
|
|
5085
5420
|
const at = now();
|
|
@@ -5087,6 +5422,108 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5087
5422
|
if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
|
|
5088
5423
|
}
|
|
5089
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
|
+
}
|
|
5090
5527
|
function missing(handle) {
|
|
5091
5528
|
const grave = tombstones.get(handle);
|
|
5092
5529
|
if (grave?.reason === "ttl") {
|
|
@@ -5122,12 +5559,29 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5122
5559
|
createdAt: new Date(entry.createdAt).toISOString(),
|
|
5123
5560
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
5124
5561
|
...entry.title !== void 0 && { title: entry.title },
|
|
5125
|
-
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
|
|
5126
5577
|
};
|
|
5127
5578
|
}
|
|
5128
5579
|
return {
|
|
5129
5580
|
available: true,
|
|
5130
5581
|
limits,
|
|
5582
|
+
...persistence && {
|
|
5583
|
+
persistence: { root: persistence.root, limits: persistence.limits }
|
|
5584
|
+
},
|
|
5131
5585
|
usage() {
|
|
5132
5586
|
sweep();
|
|
5133
5587
|
return { workspaces: entries.size, bytes: totalBytes() };
|
|
@@ -5168,15 +5622,18 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5168
5622
|
updatedAt: at,
|
|
5169
5623
|
touchedAt: at,
|
|
5170
5624
|
...input.title !== void 0 && { title: input.title },
|
|
5171
|
-
pins: /* @__PURE__ */ new Map()
|
|
5625
|
+
pins: /* @__PURE__ */ new Map(),
|
|
5626
|
+
persisted: false
|
|
5172
5627
|
};
|
|
5173
5628
|
entries.set(entry.handle, entry);
|
|
5174
|
-
|
|
5629
|
+
const warning = await persist(entry);
|
|
5630
|
+
return committed(toRecord(entry), warning);
|
|
5175
5631
|
},
|
|
5176
5632
|
async get(handle, readOptions) {
|
|
5177
5633
|
sweep();
|
|
5178
|
-
const
|
|
5179
|
-
if (!entry) return
|
|
5634
|
+
const found = await resolve3(handle);
|
|
5635
|
+
if (!("entry" in found)) return found;
|
|
5636
|
+
const entry = found.entry;
|
|
5180
5637
|
entry.touchedAt = now();
|
|
5181
5638
|
let text = entry.text;
|
|
5182
5639
|
let bytes = entry.bytes;
|
|
@@ -5201,15 +5658,16 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5201
5658
|
context: { handle, pointer }
|
|
5202
5659
|
});
|
|
5203
5660
|
}
|
|
5204
|
-
const
|
|
5205
|
-
if (
|
|
5661
|
+
const found2 = resolvePointer(document, parsed.tokens);
|
|
5662
|
+
if (found2.found) projection[pointer] = found2.value;
|
|
5206
5663
|
}
|
|
5207
5664
|
return { ok: true, record, document, projection };
|
|
5208
5665
|
},
|
|
5209
5666
|
async patch(input) {
|
|
5210
5667
|
sweep();
|
|
5211
|
-
const
|
|
5212
|
-
if (!entry) return
|
|
5668
|
+
const found = await resolve3(input.handle);
|
|
5669
|
+
if (!("entry" in found)) return found;
|
|
5670
|
+
const entry = found.entry;
|
|
5213
5671
|
entry.touchedAt = now();
|
|
5214
5672
|
if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
|
|
5215
5673
|
return stale(
|
|
@@ -5275,12 +5733,14 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5275
5733
|
entry.revision += 1;
|
|
5276
5734
|
entry.updatedAt = now();
|
|
5277
5735
|
entry.touchedAt = entry.updatedAt;
|
|
5278
|
-
|
|
5736
|
+
const warning = await persist(entry);
|
|
5737
|
+
return committed(toRecord(entry), warning);
|
|
5279
5738
|
},
|
|
5280
5739
|
async snapshot(handle) {
|
|
5281
5740
|
sweep();
|
|
5282
|
-
const
|
|
5283
|
-
if (!entry) return
|
|
5741
|
+
const found = await resolve3(handle);
|
|
5742
|
+
if (!("entry" in found)) return found;
|
|
5743
|
+
const entry = found.entry;
|
|
5284
5744
|
entry.touchedAt = now();
|
|
5285
5745
|
const revision = entry.revision;
|
|
5286
5746
|
const document = JSON.parse(entry.text);
|
|
@@ -5289,24 +5749,70 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5289
5749
|
entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
|
|
5290
5750
|
}
|
|
5291
5751
|
}
|
|
5292
|
-
|
|
5293
|
-
},
|
|
5294
|
-
async list() {
|
|
5295
|
-
sweep();
|
|
5752
|
+
const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
|
|
5296
5753
|
return {
|
|
5297
5754
|
ok: true,
|
|
5298
|
-
|
|
5755
|
+
record: toRecord(entry),
|
|
5756
|
+
document,
|
|
5757
|
+
...warning && { warnings: [warning] }
|
|
5299
5758
|
};
|
|
5300
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
|
+
},
|
|
5301
5777
|
async close(handle) {
|
|
5302
5778
|
sweep();
|
|
5303
5779
|
const entry = entries.get(handle);
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
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 };
|
|
5307
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
|
+
*/
|
|
5308
5807
|
async closeAll() {
|
|
5309
|
-
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
|
+
}
|
|
5310
5816
|
}
|
|
5311
5817
|
};
|
|
5312
5818
|
function stale(entry, wanted, pinned, mutation = false) {
|
|
@@ -5326,7 +5832,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5326
5832
|
}
|
|
5327
5833
|
}
|
|
5328
5834
|
function defaultHandle() {
|
|
5329
|
-
return `ws_${
|
|
5835
|
+
return `ws_${randomBytes3(9).toString("base64url")}`;
|
|
5330
5836
|
}
|
|
5331
5837
|
function serialize(document) {
|
|
5332
5838
|
let text;
|
|
@@ -5379,7 +5885,7 @@ var workspaceSchema = {
|
|
|
5379
5885
|
properties: {
|
|
5380
5886
|
handle: {
|
|
5381
5887
|
type: "string",
|
|
5382
|
-
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."
|
|
5383
5889
|
},
|
|
5384
5890
|
format: { type: "string", enum: [...FORMAT_NAMES] },
|
|
5385
5891
|
revision: {
|
|
@@ -5397,6 +5903,10 @@ var workspaceSchema = {
|
|
|
5397
5903
|
type: "array",
|
|
5398
5904
|
items: { type: "integer" },
|
|
5399
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."
|
|
5400
5910
|
}
|
|
5401
5911
|
},
|
|
5402
5912
|
required: [
|
|
@@ -5410,6 +5920,26 @@ var workspaceSchema = {
|
|
|
5410
5920
|
],
|
|
5411
5921
|
additionalProperties: false
|
|
5412
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
|
+
};
|
|
5413
5943
|
var limitsSchema = {
|
|
5414
5944
|
type: "object",
|
|
5415
5945
|
description: "What this connection allows. Bounded on purpose; see the codes above.",
|
|
@@ -5441,7 +5971,9 @@ function isMemoryStore(store) {
|
|
|
5441
5971
|
}
|
|
5442
5972
|
function ensureStore(deps) {
|
|
5443
5973
|
if (deps.workspaces().available || hasWorkspaceStore()) return;
|
|
5444
|
-
const owned = createMemoryWorkspaceStore(
|
|
5974
|
+
const owned = createMemoryWorkspaceStore(
|
|
5975
|
+
deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
|
|
5976
|
+
);
|
|
5445
5977
|
deps.workspaces = () => owned;
|
|
5446
5978
|
}
|
|
5447
5979
|
function register8(server, deps) {
|
|
@@ -5484,9 +6016,9 @@ function register8(server, deps) {
|
|
|
5484
6016
|
...args.title !== void 0 && { title: args.title }
|
|
5485
6017
|
});
|
|
5486
6018
|
if (!created.ok) return created;
|
|
5487
|
-
return success(
|
|
5488
|
-
|
|
5489
|
-
seeded ? [
|
|
6019
|
+
return success({ workspace: created.record }, [
|
|
6020
|
+
...created.warnings ?? [],
|
|
6021
|
+
...seeded ? [
|
|
5490
6022
|
diagnostic(
|
|
5491
6023
|
"W_BLANK_DOCUMENT",
|
|
5492
6024
|
`Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
|
|
@@ -5496,7 +6028,7 @@ function register8(server, deps) {
|
|
|
5496
6028
|
}
|
|
5497
6029
|
)
|
|
5498
6030
|
] : []
|
|
5499
|
-
);
|
|
6031
|
+
]);
|
|
5500
6032
|
})
|
|
5501
6033
|
)
|
|
5502
6034
|
);
|
|
@@ -5562,17 +6094,20 @@ function register8(server, deps) {
|
|
|
5562
6094
|
const missingPaths = projecting ? args.paths.filter(
|
|
5563
6095
|
(pointer) => !(pointer in projection)
|
|
5564
6096
|
) : [];
|
|
5565
|
-
const diagnostics =
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
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
|
+
)
|
|
5574
6109
|
)
|
|
5575
|
-
|
|
6110
|
+
];
|
|
5576
6111
|
return success(
|
|
5577
6112
|
{
|
|
5578
6113
|
workspace: read.record,
|
|
@@ -5641,7 +6176,7 @@ function register8(server, deps) {
|
|
|
5641
6176
|
}
|
|
5642
6177
|
});
|
|
5643
6178
|
if (!patched.ok) return patched;
|
|
5644
|
-
return success({ workspace: patched.record });
|
|
6179
|
+
return success({ workspace: patched.record }, patched.warnings ?? []);
|
|
5645
6180
|
})
|
|
5646
6181
|
)
|
|
5647
6182
|
);
|
|
@@ -5687,7 +6222,7 @@ function register8(server, deps) {
|
|
|
5687
6222
|
await guarded(async () => {
|
|
5688
6223
|
const snapshot = await deps.workspaces().snapshot(args.handle);
|
|
5689
6224
|
if (!snapshot.ok) return snapshot;
|
|
5690
|
-
const diagnostics = [];
|
|
6225
|
+
const diagnostics = [...snapshot.warnings ?? []];
|
|
5691
6226
|
if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
|
|
5692
6227
|
diagnostics.push(
|
|
5693
6228
|
diagnostic(
|
|
@@ -5730,7 +6265,7 @@ function register8(server, deps) {
|
|
|
5730
6265
|
"jto_workspace_list",
|
|
5731
6266
|
{
|
|
5732
6267
|
title: "List open workspaces",
|
|
5733
|
-
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.",
|
|
5734
6269
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
5735
6270
|
inputSchema: S({
|
|
5736
6271
|
type: "object",
|
|
@@ -5745,6 +6280,7 @@ function register8(server, deps) {
|
|
|
5745
6280
|
description: "False when this connection has workspaces switched off."
|
|
5746
6281
|
},
|
|
5747
6282
|
limits: limitsSchema,
|
|
6283
|
+
persistence: persistenceSchema,
|
|
5748
6284
|
usage: {
|
|
5749
6285
|
type: "object",
|
|
5750
6286
|
properties: {
|
|
@@ -5762,12 +6298,24 @@ function register8(server, deps) {
|
|
|
5762
6298
|
const store = deps.workspaces();
|
|
5763
6299
|
const listed = await store.list();
|
|
5764
6300
|
if (!listed.ok) return listed;
|
|
5765
|
-
const budget = isMemoryStore(store) ? {
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
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
|
+
);
|
|
5771
6319
|
})
|
|
5772
6320
|
)
|
|
5773
6321
|
);
|
|
@@ -5775,7 +6323,7 @@ function register8(server, deps) {
|
|
|
5775
6323
|
"jto_workspace_close",
|
|
5776
6324
|
{
|
|
5777
6325
|
title: "Close a workspace",
|
|
5778
|
-
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.",
|
|
5779
6327
|
annotations: {
|
|
5780
6328
|
readOnlyHint: false,
|
|
5781
6329
|
destructiveHint: true,
|
|
@@ -5992,6 +6540,12 @@ function createServerFactory(deps) {
|
|
|
5992
6540
|
|
|
5993
6541
|
// src/lib/deps.ts
|
|
5994
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
|
+
});
|
|
5995
6549
|
return {
|
|
5996
6550
|
serverVersion: options.serverVersion ?? SERVER_VERSION,
|
|
5997
6551
|
outputRoot: options.outputRoot ?? createOutputRoot({
|
|
@@ -6000,23 +6554,45 @@ function createToolDeps(options = {}) {
|
|
|
6000
6554
|
}),
|
|
6001
6555
|
getAdapter: options.getAdapter ?? getAdapter,
|
|
6002
6556
|
workspaces: options.workspaces ?? getWorkspaceStore,
|
|
6557
|
+
...workspacePersistence !== void 0 && { workspacePersistence },
|
|
6003
6558
|
maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
|
|
6004
6559
|
};
|
|
6005
6560
|
}
|
|
6006
6561
|
|
|
6007
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
|
+
}
|
|
6008
6570
|
function parseArgs(argv) {
|
|
6009
|
-
const parsed = {
|
|
6571
|
+
const parsed = {
|
|
6572
|
+
version: false,
|
|
6573
|
+
help: false,
|
|
6574
|
+
unknown: [],
|
|
6575
|
+
missingValue: []
|
|
6576
|
+
};
|
|
6010
6577
|
for (let index = 0; index < argv.length; index += 1) {
|
|
6011
6578
|
const arg = argv[index];
|
|
6012
6579
|
if (arg === "--version" || arg === "-v") {
|
|
6013
6580
|
parsed.version = true;
|
|
6014
6581
|
} else if (arg === "--help" || arg === "-h") {
|
|
6015
6582
|
parsed.help = true;
|
|
6016
|
-
} else if (arg === "--output-dir") {
|
|
6017
|
-
|
|
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;
|
|
6018
6592
|
} else if (arg.startsWith("--output-dir=")) {
|
|
6019
6593
|
parsed.outputDir = arg.slice("--output-dir=".length);
|
|
6594
|
+
} else if (arg.startsWith("--workspace-dir=")) {
|
|
6595
|
+
parsed.workspaceDir = arg.slice("--workspace-dir=".length);
|
|
6020
6596
|
} else {
|
|
6021
6597
|
parsed.unknown.push(arg);
|
|
6022
6598
|
}
|
|
@@ -6034,11 +6610,16 @@ Options:
|
|
|
6034
6610
|
--output-dir <path> Directory generated files are written to. Overrides
|
|
6035
6611
|
${OUTPUT_DIR_ENV}. Defaults to a per-connection
|
|
6036
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.
|
|
6037
6617
|
-v, --version Print the version and exit.
|
|
6038
6618
|
-h, --help Print this help and exit.
|
|
6039
6619
|
|
|
6040
6620
|
Environment:
|
|
6041
6621
|
${OUTPUT_DIR_ENV} Output root, when --output-dir is absent.
|
|
6622
|
+
${WORKSPACE_DIR_ENV} Workspace root, when --workspace-dir is absent.
|
|
6042
6623
|
LIBREOFFICE_PATH LibreOffice binary, for preview.
|
|
6043
6624
|
PDFTOPPM_PATH poppler pdftoppm binary, for preview.
|
|
6044
6625
|
|
|
@@ -6061,13 +6642,23 @@ function main(argv) {
|
|
|
6061
6642
|
process.stderr.write(
|
|
6062
6643
|
`jto-mcp: unknown argument ${args.unknown[0]}
|
|
6063
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.
|
|
6064
6654
|
`
|
|
6065
6655
|
);
|
|
6066
6656
|
process.exitCode = 1;
|
|
6067
6657
|
return;
|
|
6068
6658
|
}
|
|
6069
6659
|
const deps = createToolDeps({
|
|
6070
|
-
...args.outputDir !== void 0 && { outputDir: args.outputDir }
|
|
6660
|
+
...args.outputDir !== void 0 && { outputDir: args.outputDir },
|
|
6661
|
+
...args.workspaceDir !== void 0 && { workspaceDir: args.workspaceDir }
|
|
6071
6662
|
});
|
|
6072
6663
|
const handle = serveStdio(createServerFactory(deps), {
|
|
6073
6664
|
legacy: "serve",
|