@json-to-office/mcp-server 1.5.0 → 1.8.1
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 +725 -96
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +281 -2
- package/dist/index.js +694 -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.1" : "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,345 @@ 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
|
+
let meta = await readMeta(handle);
|
|
5257
|
+
if (!meta) return void 0;
|
|
5258
|
+
let head = await readRevision(handle, meta.revision);
|
|
5259
|
+
if (!head) {
|
|
5260
|
+
const second = await readMeta(handle);
|
|
5261
|
+
if (second) {
|
|
5262
|
+
meta = second;
|
|
5263
|
+
head = await readRevision(handle, second.revision);
|
|
5264
|
+
}
|
|
5265
|
+
}
|
|
5266
|
+
if (!head) {
|
|
5267
|
+
await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
|
|
5268
|
+
return void 0;
|
|
5269
|
+
}
|
|
5270
|
+
const pins = [];
|
|
5271
|
+
for (const revision of meta.pinnedRevisions) {
|
|
5272
|
+
const pin = await readRevision(handle, revision);
|
|
5273
|
+
if (pin) pins.push(pin);
|
|
5274
|
+
}
|
|
5275
|
+
return {
|
|
5276
|
+
...meta,
|
|
5277
|
+
// A pin whose file went missing is dropped rather than advertised: the
|
|
5278
|
+
// record has to describe what can actually be read back.
|
|
5279
|
+
pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
|
|
5280
|
+
head,
|
|
5281
|
+
pins
|
|
5282
|
+
};
|
|
5283
|
+
},
|
|
5284
|
+
async remove(handle) {
|
|
5285
|
+
if (!isPersistableHandle(handle)) return false;
|
|
5286
|
+
const dir = dirFor(handle);
|
|
5287
|
+
if (!await exists(dir)) {
|
|
5288
|
+
authored.delete(handle);
|
|
5289
|
+
return false;
|
|
5290
|
+
}
|
|
5291
|
+
await fs7.rm(dir, { recursive: true, force: true });
|
|
5292
|
+
authored.delete(handle);
|
|
5293
|
+
return true;
|
|
5294
|
+
}
|
|
5295
|
+
};
|
|
5296
|
+
}
|
|
5297
|
+
async function exists(target) {
|
|
5298
|
+
try {
|
|
5299
|
+
await fs7.stat(target);
|
|
5300
|
+
return true;
|
|
5301
|
+
} catch {
|
|
5302
|
+
return false;
|
|
5303
|
+
}
|
|
5304
|
+
}
|
|
5305
|
+
async function isStale(target) {
|
|
5306
|
+
try {
|
|
5307
|
+
const { mtimeMs } = await fs7.stat(target);
|
|
5308
|
+
return Date.now() - mtimeMs > STALE_TEMP_MS;
|
|
5309
|
+
} catch {
|
|
5310
|
+
return false;
|
|
5311
|
+
}
|
|
5312
|
+
}
|
|
5313
|
+
function revisionOf(name) {
|
|
5314
|
+
if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
|
|
5315
|
+
return void 0;
|
|
5316
|
+
}
|
|
5317
|
+
const digits = name.slice(
|
|
5318
|
+
REVISION_PREFIX.length,
|
|
5319
|
+
name.length - REVISION_SUFFIX.length
|
|
5320
|
+
);
|
|
5321
|
+
if (!/^[0-9]+$/.test(digits)) return void 0;
|
|
5322
|
+
return Number(digits);
|
|
5323
|
+
}
|
|
5324
|
+
function parseMeta(raw, handle) {
|
|
5325
|
+
let parsed;
|
|
5326
|
+
try {
|
|
5327
|
+
parsed = JSON.parse(raw);
|
|
5328
|
+
} catch {
|
|
5329
|
+
return void 0;
|
|
5330
|
+
}
|
|
5331
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5332
|
+
return void 0;
|
|
5333
|
+
}
|
|
5334
|
+
const meta = parsed;
|
|
5335
|
+
if (meta.schema !== SCHEMA_VERSION) return void 0;
|
|
5336
|
+
if (meta.handle !== handle) return void 0;
|
|
5337
|
+
if (!FORMAT_NAMES.includes(meta.format)) return void 0;
|
|
5338
|
+
if (!isPositiveInteger(meta.revision)) return void 0;
|
|
5339
|
+
if (!isNonNegativeInteger(meta.bytes)) return void 0;
|
|
5340
|
+
if (!isNonNegativeInteger(meta.createdAt)) return void 0;
|
|
5341
|
+
if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
|
|
5342
|
+
if (meta.title !== void 0 && typeof meta.title !== "string")
|
|
5343
|
+
return void 0;
|
|
5344
|
+
const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
|
|
5345
|
+
return {
|
|
5346
|
+
handle,
|
|
5347
|
+
format: meta.format,
|
|
5348
|
+
revision: meta.revision,
|
|
5349
|
+
bytes: meta.bytes,
|
|
5350
|
+
createdAt: meta.createdAt,
|
|
5351
|
+
updatedAt: meta.updatedAt,
|
|
5352
|
+
...typeof meta.title === "string" && { title: meta.title },
|
|
5353
|
+
pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
|
|
5354
|
+
};
|
|
5355
|
+
}
|
|
5356
|
+
function isPositiveInteger(value) {
|
|
5357
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
5358
|
+
}
|
|
5359
|
+
function isNonNegativeInteger(value) {
|
|
5360
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
5361
|
+
}
|
|
5362
|
+
|
|
5363
|
+
// src/workspace/store.ts
|
|
5031
5364
|
var WORKSPACE_ERROR_CODES = {
|
|
5032
5365
|
EVICTED: "E_WORKSPACE_EVICTED",
|
|
5033
5366
|
LIMIT: "E_WORKSPACE_LIMIT",
|
|
5034
5367
|
DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
|
|
5035
|
-
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
|
|
5368
|
+
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
|
|
5369
|
+
/**
|
|
5370
|
+
* A revision could not be mirrored to disk (#290). A warning, never a
|
|
5371
|
+
* failure: the edit landed and the workspace is usable, but this connection
|
|
5372
|
+
* has stopped being survivable and saying so is the whole point.
|
|
5373
|
+
*/
|
|
5374
|
+
NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
|
|
5375
|
+
/**
|
|
5376
|
+
* A close could not delete the durable copy, so nothing was released (#290).
|
|
5377
|
+
* An error rather than a warning: the agent asked for the document to be
|
|
5378
|
+
* destroyed, and it is still there to be read by the next connection.
|
|
5379
|
+
*/
|
|
5380
|
+
NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
|
|
5036
5381
|
};
|
|
5037
5382
|
var DEFAULT_WORKSPACE_LIMITS = {
|
|
5038
5383
|
maxWorkspaces: 16,
|
|
@@ -5052,6 +5397,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5052
5397
|
};
|
|
5053
5398
|
const now = options.now ?? Date.now;
|
|
5054
5399
|
const newHandle = options.newHandle ?? defaultHandle;
|
|
5400
|
+
const persistence = options.persistence;
|
|
5055
5401
|
const entries = /* @__PURE__ */ new Map();
|
|
5056
5402
|
const tombstones = /* @__PURE__ */ new Map();
|
|
5057
5403
|
function footprint(entry) {
|
|
@@ -5064,12 +5410,8 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5064
5410
|
for (const entry of entries.values()) total += footprint(entry);
|
|
5065
5411
|
return total;
|
|
5066
5412
|
}
|
|
5067
|
-
function tombstone(
|
|
5068
|
-
tombstones.set(
|
|
5069
|
-
reason,
|
|
5070
|
-
at: now(),
|
|
5071
|
-
revision: entry.revision
|
|
5072
|
-
});
|
|
5413
|
+
function tombstone(handle, reason, revision) {
|
|
5414
|
+
tombstones.set(handle, { reason, at: now(), revision });
|
|
5073
5415
|
while (tombstones.size > MAX_TOMBSTONES) {
|
|
5074
5416
|
const oldest = tombstones.keys().next();
|
|
5075
5417
|
if (oldest.done) break;
|
|
@@ -5079,7 +5421,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5079
5421
|
function evict(entry, reason) {
|
|
5080
5422
|
entries.delete(entry.handle);
|
|
5081
5423
|
tombstones.delete(entry.handle);
|
|
5082
|
-
tombstone(entry, reason);
|
|
5424
|
+
tombstone(entry.handle, reason, entry.revision);
|
|
5083
5425
|
}
|
|
5084
5426
|
function sweep() {
|
|
5085
5427
|
const at = now();
|
|
@@ -5087,6 +5429,130 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5087
5429
|
if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
|
|
5088
5430
|
}
|
|
5089
5431
|
}
|
|
5432
|
+
let durable = Promise.resolve();
|
|
5433
|
+
function queued(work) {
|
|
5434
|
+
const running = durable.then(work);
|
|
5435
|
+
durable = running.catch(() => void 0);
|
|
5436
|
+
return running;
|
|
5437
|
+
}
|
|
5438
|
+
async function persist(entry) {
|
|
5439
|
+
if (!persistence) return void 0;
|
|
5440
|
+
try {
|
|
5441
|
+
const written = await queued(async () => {
|
|
5442
|
+
if (entries.get(entry.handle) !== entry) return false;
|
|
5443
|
+
await saveEntry(entry);
|
|
5444
|
+
return true;
|
|
5445
|
+
});
|
|
5446
|
+
if (!written) {
|
|
5447
|
+
entry.persisted = false;
|
|
5448
|
+
return void 0;
|
|
5449
|
+
}
|
|
5450
|
+
entry.persisted = true;
|
|
5451
|
+
return void 0;
|
|
5452
|
+
} catch (error) {
|
|
5453
|
+
entry.persisted = false;
|
|
5454
|
+
return notPersisted(entry, error);
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
async function saveEntry(entry) {
|
|
5458
|
+
if (!persistence) return;
|
|
5459
|
+
await persistence.save({
|
|
5460
|
+
handle: entry.handle,
|
|
5461
|
+
format: entry.format,
|
|
5462
|
+
createdAt: entry.createdAt,
|
|
5463
|
+
updatedAt: entry.updatedAt,
|
|
5464
|
+
...entry.title !== void 0 && { title: entry.title },
|
|
5465
|
+
head: {
|
|
5466
|
+
revision: entry.revision,
|
|
5467
|
+
text: entry.text,
|
|
5468
|
+
bytes: entry.bytes
|
|
5469
|
+
},
|
|
5470
|
+
pins: [...entry.pins.entries()].map(([revision, pin]) => ({
|
|
5471
|
+
revision,
|
|
5472
|
+
text: pin.text,
|
|
5473
|
+
bytes: pin.bytes
|
|
5474
|
+
}))
|
|
5475
|
+
});
|
|
5476
|
+
}
|
|
5477
|
+
function notPersisted(entry, error) {
|
|
5478
|
+
const root = persistence?.root ?? "";
|
|
5479
|
+
return diagnostic(
|
|
5480
|
+
WORKSPACE_ERROR_CODES.NOT_PERSISTED,
|
|
5481
|
+
`Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
5482
|
+
{
|
|
5483
|
+
severity: "warning",
|
|
5484
|
+
suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
|
|
5485
|
+
context: {
|
|
5486
|
+
handle: entry.handle,
|
|
5487
|
+
revision: entry.revision,
|
|
5488
|
+
workspaceRoot: root
|
|
5489
|
+
}
|
|
5490
|
+
}
|
|
5491
|
+
);
|
|
5492
|
+
}
|
|
5493
|
+
function committed(record, warning) {
|
|
5494
|
+
return { ok: true, record, ...warning && { warnings: [warning] } };
|
|
5495
|
+
}
|
|
5496
|
+
async function rehydrate(handle) {
|
|
5497
|
+
if (!persistence || !isPersistableHandle(handle)) return void 0;
|
|
5498
|
+
let loaded;
|
|
5499
|
+
try {
|
|
5500
|
+
loaded = await persistence.load(handle);
|
|
5501
|
+
} catch {
|
|
5502
|
+
return void 0;
|
|
5503
|
+
}
|
|
5504
|
+
if (!loaded) return void 0;
|
|
5505
|
+
const restored = loaded;
|
|
5506
|
+
const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
|
|
5507
|
+
const needed = pins.reduce(
|
|
5508
|
+
(total, pin) => total + pin.bytes,
|
|
5509
|
+
restored.head.bytes
|
|
5510
|
+
);
|
|
5511
|
+
if (restored.head.bytes > limits.maxDocumentBytes) {
|
|
5512
|
+
return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
|
|
5513
|
+
}
|
|
5514
|
+
if (!hasRoom(needed, true)) {
|
|
5515
|
+
return failure(
|
|
5516
|
+
WORKSPACE_ERROR_CODES.LIMIT,
|
|
5517
|
+
`Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
|
|
5518
|
+
{
|
|
5519
|
+
suggestion: "Close a workspace you have finished with, then use this handle again.",
|
|
5520
|
+
context: {
|
|
5521
|
+
handle,
|
|
5522
|
+
bytes: needed,
|
|
5523
|
+
maxTotalBytes: limits.maxTotalBytes,
|
|
5524
|
+
maxWorkspaces: limits.maxWorkspaces
|
|
5525
|
+
}
|
|
5526
|
+
}
|
|
5527
|
+
);
|
|
5528
|
+
}
|
|
5529
|
+
const entry = {
|
|
5530
|
+
handle,
|
|
5531
|
+
format: restored.format,
|
|
5532
|
+
revision: restored.head.revision,
|
|
5533
|
+
text: restored.head.text,
|
|
5534
|
+
bytes: restored.head.bytes,
|
|
5535
|
+
createdAt: restored.createdAt,
|
|
5536
|
+
updatedAt: restored.updatedAt,
|
|
5537
|
+
touchedAt: now(),
|
|
5538
|
+
...restored.title !== void 0 && { title: restored.title },
|
|
5539
|
+
pins: new Map(
|
|
5540
|
+
pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
|
|
5541
|
+
),
|
|
5542
|
+
persisted: true
|
|
5543
|
+
};
|
|
5544
|
+
entries.set(handle, entry);
|
|
5545
|
+
tombstones.delete(handle);
|
|
5546
|
+
return { ok: true, entry };
|
|
5547
|
+
}
|
|
5548
|
+
async function resolve3(handle) {
|
|
5549
|
+
const entry = entries.get(handle);
|
|
5550
|
+
if (entry) return { entry };
|
|
5551
|
+
const restored = await rehydrate(handle);
|
|
5552
|
+
if (restored === void 0) return missing(handle);
|
|
5553
|
+
if (!restored.ok) return restored;
|
|
5554
|
+
return { entry: restored.entry };
|
|
5555
|
+
}
|
|
5090
5556
|
function missing(handle) {
|
|
5091
5557
|
const grave = tombstones.get(handle);
|
|
5092
5558
|
if (grave?.reason === "ttl") {
|
|
@@ -5122,12 +5588,29 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5122
5588
|
createdAt: new Date(entry.createdAt).toISOString(),
|
|
5123
5589
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
5124
5590
|
...entry.title !== void 0 && { title: entry.title },
|
|
5125
|
-
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
|
|
5591
|
+
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
|
|
5592
|
+
...persistence && { persisted: entry.persisted }
|
|
5593
|
+
};
|
|
5594
|
+
}
|
|
5595
|
+
function fromMeta(meta) {
|
|
5596
|
+
return {
|
|
5597
|
+
handle: meta.handle,
|
|
5598
|
+
format: meta.format,
|
|
5599
|
+
revision: meta.revision,
|
|
5600
|
+
bytes: meta.bytes,
|
|
5601
|
+
createdAt: new Date(meta.createdAt).toISOString(),
|
|
5602
|
+
updatedAt: new Date(meta.updatedAt).toISOString(),
|
|
5603
|
+
...meta.title !== void 0 && { title: meta.title },
|
|
5604
|
+
pinnedRevisions: [...meta.pinnedRevisions],
|
|
5605
|
+
persisted: true
|
|
5126
5606
|
};
|
|
5127
5607
|
}
|
|
5128
5608
|
return {
|
|
5129
5609
|
available: true,
|
|
5130
5610
|
limits,
|
|
5611
|
+
...persistence && {
|
|
5612
|
+
persistence: { root: persistence.root, limits: persistence.limits }
|
|
5613
|
+
},
|
|
5131
5614
|
usage() {
|
|
5132
5615
|
sweep();
|
|
5133
5616
|
return { workspaces: entries.size, bytes: totalBytes() };
|
|
@@ -5168,15 +5651,18 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5168
5651
|
updatedAt: at,
|
|
5169
5652
|
touchedAt: at,
|
|
5170
5653
|
...input.title !== void 0 && { title: input.title },
|
|
5171
|
-
pins: /* @__PURE__ */ new Map()
|
|
5654
|
+
pins: /* @__PURE__ */ new Map(),
|
|
5655
|
+
persisted: false
|
|
5172
5656
|
};
|
|
5173
5657
|
entries.set(entry.handle, entry);
|
|
5174
|
-
|
|
5658
|
+
const warning = await persist(entry);
|
|
5659
|
+
return committed(toRecord(entry), warning);
|
|
5175
5660
|
},
|
|
5176
5661
|
async get(handle, readOptions) {
|
|
5177
5662
|
sweep();
|
|
5178
|
-
const
|
|
5179
|
-
if (!entry) return
|
|
5663
|
+
const found = await resolve3(handle);
|
|
5664
|
+
if (!("entry" in found)) return found;
|
|
5665
|
+
const entry = found.entry;
|
|
5180
5666
|
entry.touchedAt = now();
|
|
5181
5667
|
let text = entry.text;
|
|
5182
5668
|
let bytes = entry.bytes;
|
|
@@ -5201,15 +5687,16 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5201
5687
|
context: { handle, pointer }
|
|
5202
5688
|
});
|
|
5203
5689
|
}
|
|
5204
|
-
const
|
|
5205
|
-
if (
|
|
5690
|
+
const found2 = resolvePointer(document, parsed.tokens);
|
|
5691
|
+
if (found2.found) projection[pointer] = found2.value;
|
|
5206
5692
|
}
|
|
5207
5693
|
return { ok: true, record, document, projection };
|
|
5208
5694
|
},
|
|
5209
5695
|
async patch(input) {
|
|
5210
5696
|
sweep();
|
|
5211
|
-
const
|
|
5212
|
-
if (!entry) return
|
|
5697
|
+
const found = await resolve3(input.handle);
|
|
5698
|
+
if (!("entry" in found)) return found;
|
|
5699
|
+
const entry = found.entry;
|
|
5213
5700
|
entry.touchedAt = now();
|
|
5214
5701
|
if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
|
|
5215
5702
|
return stale(
|
|
@@ -5275,12 +5762,14 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5275
5762
|
entry.revision += 1;
|
|
5276
5763
|
entry.updatedAt = now();
|
|
5277
5764
|
entry.touchedAt = entry.updatedAt;
|
|
5278
|
-
|
|
5765
|
+
const warning = await persist(entry);
|
|
5766
|
+
return committed(toRecord(entry), warning);
|
|
5279
5767
|
},
|
|
5280
5768
|
async snapshot(handle) {
|
|
5281
5769
|
sweep();
|
|
5282
|
-
const
|
|
5283
|
-
if (!entry) return
|
|
5770
|
+
const found = await resolve3(handle);
|
|
5771
|
+
if (!("entry" in found)) return found;
|
|
5772
|
+
const entry = found.entry;
|
|
5284
5773
|
entry.touchedAt = now();
|
|
5285
5774
|
const revision = entry.revision;
|
|
5286
5775
|
const document = JSON.parse(entry.text);
|
|
@@ -5289,24 +5778,79 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5289
5778
|
entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
|
|
5290
5779
|
}
|
|
5291
5780
|
}
|
|
5292
|
-
|
|
5293
|
-
},
|
|
5294
|
-
async list() {
|
|
5295
|
-
sweep();
|
|
5781
|
+
const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
|
|
5296
5782
|
return {
|
|
5297
5783
|
ok: true,
|
|
5298
|
-
|
|
5784
|
+
record: toRecord(entry),
|
|
5785
|
+
document,
|
|
5786
|
+
...warning && { warnings: [warning] }
|
|
5299
5787
|
};
|
|
5300
5788
|
},
|
|
5789
|
+
async list() {
|
|
5790
|
+
sweep();
|
|
5791
|
+
const records = [...entries.values()].map((entry) => toRecord(entry));
|
|
5792
|
+
if (!persistence) return { ok: true, records };
|
|
5793
|
+
const resident = new Set(records.map((record) => record.handle));
|
|
5794
|
+
let stored = [];
|
|
5795
|
+
try {
|
|
5796
|
+
stored = await persistence.list();
|
|
5797
|
+
} catch {
|
|
5798
|
+
}
|
|
5799
|
+
for (const meta of stored) {
|
|
5800
|
+
if (resident.has(meta.handle)) continue;
|
|
5801
|
+
if (tombstones.get(meta.handle)?.reason === "closed") continue;
|
|
5802
|
+
records.push(fromMeta(meta));
|
|
5803
|
+
}
|
|
5804
|
+
return { ok: true, records };
|
|
5805
|
+
},
|
|
5301
5806
|
async close(handle) {
|
|
5302
5807
|
sweep();
|
|
5303
5808
|
const entry = entries.get(handle);
|
|
5304
|
-
if (!
|
|
5305
|
-
|
|
5306
|
-
|
|
5809
|
+
if (!persistence) {
|
|
5810
|
+
if (entry) evict(entry, "closed");
|
|
5811
|
+
return { ok: true, handle, closed: entry !== void 0 };
|
|
5812
|
+
}
|
|
5813
|
+
const root = persistence.root;
|
|
5814
|
+
try {
|
|
5815
|
+
return await queued(async () => {
|
|
5816
|
+
const removed = await persistence.remove(handle);
|
|
5817
|
+
const resident = entries.get(handle);
|
|
5818
|
+
if (resident) evict(resident, "closed");
|
|
5819
|
+
else if (removed) tombstone(handle, "closed", 0);
|
|
5820
|
+
return {
|
|
5821
|
+
ok: true,
|
|
5822
|
+
handle,
|
|
5823
|
+
closed: resident !== void 0 || removed
|
|
5824
|
+
};
|
|
5825
|
+
});
|
|
5826
|
+
} catch (error) {
|
|
5827
|
+
return failure(
|
|
5828
|
+
WORKSPACE_ERROR_CODES.NOT_CLOSED,
|
|
5829
|
+
`Workspace ${handle} could not be removed from ${root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
|
|
5830
|
+
{
|
|
5831
|
+
suggestion: "Check the workspace directory is writable, then close again.",
|
|
5832
|
+
context: { handle, workspaceRoot: root }
|
|
5833
|
+
}
|
|
5834
|
+
);
|
|
5835
|
+
}
|
|
5307
5836
|
},
|
|
5837
|
+
/**
|
|
5838
|
+
* Release memory, keep the disk.
|
|
5839
|
+
*
|
|
5840
|
+
* The asymmetry with `close` is the point: this exists for a host
|
|
5841
|
+
* reclaiming memory at a moment of its own choosing, and #290 is precisely
|
|
5842
|
+
* about an event that ends a connection not being allowed to destroy
|
|
5843
|
+
* revisions. The handles come back from disk on next use.
|
|
5844
|
+
*/
|
|
5308
5845
|
async closeAll() {
|
|
5309
|
-
for (const entry of [...entries.values()])
|
|
5846
|
+
for (const entry of [...entries.values()]) {
|
|
5847
|
+
if (!persistence) {
|
|
5848
|
+
evict(entry, "closed");
|
|
5849
|
+
continue;
|
|
5850
|
+
}
|
|
5851
|
+
entries.delete(entry.handle);
|
|
5852
|
+
tombstones.delete(entry.handle);
|
|
5853
|
+
}
|
|
5310
5854
|
}
|
|
5311
5855
|
};
|
|
5312
5856
|
function stale(entry, wanted, pinned, mutation = false) {
|
|
@@ -5326,7 +5870,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5326
5870
|
}
|
|
5327
5871
|
}
|
|
5328
5872
|
function defaultHandle() {
|
|
5329
|
-
return `ws_${
|
|
5873
|
+
return `ws_${randomBytes3(9).toString("base64url")}`;
|
|
5330
5874
|
}
|
|
5331
5875
|
function serialize(document) {
|
|
5332
5876
|
let text;
|
|
@@ -5379,7 +5923,7 @@ var workspaceSchema = {
|
|
|
5379
5923
|
properties: {
|
|
5380
5924
|
handle: {
|
|
5381
5925
|
type: "string",
|
|
5382
|
-
description: "Opaque
|
|
5926
|
+
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
5927
|
},
|
|
5384
5928
|
format: { type: "string", enum: [...FORMAT_NAMES] },
|
|
5385
5929
|
revision: {
|
|
@@ -5397,6 +5941,10 @@ var workspaceSchema = {
|
|
|
5397
5941
|
type: "array",
|
|
5398
5942
|
items: { type: "integer" },
|
|
5399
5943
|
description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
|
|
5944
|
+
},
|
|
5945
|
+
persisted: {
|
|
5946
|
+
type: "boolean",
|
|
5947
|
+
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
5948
|
}
|
|
5401
5949
|
},
|
|
5402
5950
|
required: [
|
|
@@ -5410,6 +5958,26 @@ var workspaceSchema = {
|
|
|
5410
5958
|
],
|
|
5411
5959
|
additionalProperties: false
|
|
5412
5960
|
};
|
|
5961
|
+
var persistenceSchema = {
|
|
5962
|
+
type: "object",
|
|
5963
|
+
description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
|
|
5964
|
+
properties: {
|
|
5965
|
+
root: { type: "string" },
|
|
5966
|
+
maxWorkspaces: { type: "integer" },
|
|
5967
|
+
maxRevisionsPerWorkspace: {
|
|
5968
|
+
type: "integer",
|
|
5969
|
+
description: "Revision files kept per handle: the head plus its pins."
|
|
5970
|
+
},
|
|
5971
|
+
maxEntryBytes: { type: "integer" }
|
|
5972
|
+
},
|
|
5973
|
+
required: [
|
|
5974
|
+
"root",
|
|
5975
|
+
"maxWorkspaces",
|
|
5976
|
+
"maxRevisionsPerWorkspace",
|
|
5977
|
+
"maxEntryBytes"
|
|
5978
|
+
],
|
|
5979
|
+
additionalProperties: false
|
|
5980
|
+
};
|
|
5413
5981
|
var limitsSchema = {
|
|
5414
5982
|
type: "object",
|
|
5415
5983
|
description: "What this connection allows. Bounded on purpose; see the codes above.",
|
|
@@ -5441,7 +6009,9 @@ function isMemoryStore(store) {
|
|
|
5441
6009
|
}
|
|
5442
6010
|
function ensureStore(deps) {
|
|
5443
6011
|
if (deps.workspaces().available || hasWorkspaceStore()) return;
|
|
5444
|
-
const owned = createMemoryWorkspaceStore(
|
|
6012
|
+
const owned = createMemoryWorkspaceStore(
|
|
6013
|
+
deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
|
|
6014
|
+
);
|
|
5445
6015
|
deps.workspaces = () => owned;
|
|
5446
6016
|
}
|
|
5447
6017
|
function register8(server, deps) {
|
|
@@ -5484,9 +6054,9 @@ function register8(server, deps) {
|
|
|
5484
6054
|
...args.title !== void 0 && { title: args.title }
|
|
5485
6055
|
});
|
|
5486
6056
|
if (!created.ok) return created;
|
|
5487
|
-
return success(
|
|
5488
|
-
|
|
5489
|
-
seeded ? [
|
|
6057
|
+
return success({ workspace: created.record }, [
|
|
6058
|
+
...created.warnings ?? [],
|
|
6059
|
+
...seeded ? [
|
|
5490
6060
|
diagnostic(
|
|
5491
6061
|
"W_BLANK_DOCUMENT",
|
|
5492
6062
|
`Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
|
|
@@ -5496,7 +6066,7 @@ function register8(server, deps) {
|
|
|
5496
6066
|
}
|
|
5497
6067
|
)
|
|
5498
6068
|
] : []
|
|
5499
|
-
);
|
|
6069
|
+
]);
|
|
5500
6070
|
})
|
|
5501
6071
|
)
|
|
5502
6072
|
);
|
|
@@ -5562,17 +6132,20 @@ function register8(server, deps) {
|
|
|
5562
6132
|
const missingPaths = projecting ? args.paths.filter(
|
|
5563
6133
|
(pointer) => !(pointer in projection)
|
|
5564
6134
|
) : [];
|
|
5565
|
-
const diagnostics =
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
6135
|
+
const diagnostics = [
|
|
6136
|
+
...read.warnings ?? [],
|
|
6137
|
+
...missingPaths.map(
|
|
6138
|
+
(pointer) => diagnostic(
|
|
6139
|
+
"W_PATH_NOT_FOUND",
|
|
6140
|
+
`${pointer} does not resolve in revision ${read.record.revision}.`,
|
|
6141
|
+
{
|
|
6142
|
+
severity: "warning",
|
|
6143
|
+
path: pointer,
|
|
6144
|
+
suggestion: "Read a shorter prefix of the pointer to see what is actually there."
|
|
6145
|
+
}
|
|
6146
|
+
)
|
|
5574
6147
|
)
|
|
5575
|
-
|
|
6148
|
+
];
|
|
5576
6149
|
return success(
|
|
5577
6150
|
{
|
|
5578
6151
|
workspace: read.record,
|
|
@@ -5641,7 +6214,7 @@ function register8(server, deps) {
|
|
|
5641
6214
|
}
|
|
5642
6215
|
});
|
|
5643
6216
|
if (!patched.ok) return patched;
|
|
5644
|
-
return success({ workspace: patched.record });
|
|
6217
|
+
return success({ workspace: patched.record }, patched.warnings ?? []);
|
|
5645
6218
|
})
|
|
5646
6219
|
)
|
|
5647
6220
|
);
|
|
@@ -5687,7 +6260,7 @@ function register8(server, deps) {
|
|
|
5687
6260
|
await guarded(async () => {
|
|
5688
6261
|
const snapshot = await deps.workspaces().snapshot(args.handle);
|
|
5689
6262
|
if (!snapshot.ok) return snapshot;
|
|
5690
|
-
const diagnostics = [];
|
|
6263
|
+
const diagnostics = [...snapshot.warnings ?? []];
|
|
5691
6264
|
if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
|
|
5692
6265
|
diagnostics.push(
|
|
5693
6266
|
diagnostic(
|
|
@@ -5730,7 +6303,7 @@ function register8(server, deps) {
|
|
|
5730
6303
|
"jto_workspace_list",
|
|
5731
6304
|
{
|
|
5732
6305
|
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.",
|
|
6306
|
+
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
6307
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
5735
6308
|
inputSchema: S({
|
|
5736
6309
|
type: "object",
|
|
@@ -5745,6 +6318,7 @@ function register8(server, deps) {
|
|
|
5745
6318
|
description: "False when this connection has workspaces switched off."
|
|
5746
6319
|
},
|
|
5747
6320
|
limits: limitsSchema,
|
|
6321
|
+
persistence: persistenceSchema,
|
|
5748
6322
|
usage: {
|
|
5749
6323
|
type: "object",
|
|
5750
6324
|
properties: {
|
|
@@ -5762,12 +6336,24 @@ function register8(server, deps) {
|
|
|
5762
6336
|
const store = deps.workspaces();
|
|
5763
6337
|
const listed = await store.list();
|
|
5764
6338
|
if (!listed.ok) return listed;
|
|
5765
|
-
const budget = isMemoryStore(store) ? {
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
6339
|
+
const budget = isMemoryStore(store) ? {
|
|
6340
|
+
limits: store.limits,
|
|
6341
|
+
usage: store.usage(),
|
|
6342
|
+
...store.persistence && {
|
|
6343
|
+
persistence: {
|
|
6344
|
+
root: store.persistence.root,
|
|
6345
|
+
...store.persistence.limits
|
|
6346
|
+
}
|
|
6347
|
+
}
|
|
6348
|
+
} : {};
|
|
6349
|
+
return success(
|
|
6350
|
+
{
|
|
6351
|
+
workspaces: listed.records,
|
|
6352
|
+
available: store.available,
|
|
6353
|
+
...budget
|
|
6354
|
+
},
|
|
6355
|
+
listed.warnings ?? []
|
|
6356
|
+
);
|
|
5771
6357
|
})
|
|
5772
6358
|
)
|
|
5773
6359
|
);
|
|
@@ -5775,7 +6361,7 @@ function register8(server, deps) {
|
|
|
5775
6361
|
"jto_workspace_close",
|
|
5776
6362
|
{
|
|
5777
6363
|
title: "Close a workspace",
|
|
5778
|
-
description: "Release a handle and
|
|
6364
|
+
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
6365
|
annotations: {
|
|
5780
6366
|
readOnlyHint: false,
|
|
5781
6367
|
destructiveHint: true,
|
|
@@ -5992,6 +6578,12 @@ function createServerFactory(deps) {
|
|
|
5992
6578
|
|
|
5993
6579
|
// src/lib/deps.ts
|
|
5994
6580
|
function createToolDeps(options = {}) {
|
|
6581
|
+
const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
|
|
6582
|
+
...options.workspaceDir !== void 0 && {
|
|
6583
|
+
flagDir: options.workspaceDir
|
|
6584
|
+
},
|
|
6585
|
+
...options.env !== void 0 && { env: options.env }
|
|
6586
|
+
});
|
|
5995
6587
|
return {
|
|
5996
6588
|
serverVersion: options.serverVersion ?? SERVER_VERSION,
|
|
5997
6589
|
outputRoot: options.outputRoot ?? createOutputRoot({
|
|
@@ -6000,23 +6592,45 @@ function createToolDeps(options = {}) {
|
|
|
6000
6592
|
}),
|
|
6001
6593
|
getAdapter: options.getAdapter ?? getAdapter,
|
|
6002
6594
|
workspaces: options.workspaces ?? getWorkspaceStore,
|
|
6595
|
+
...workspacePersistence !== void 0 && { workspacePersistence },
|
|
6003
6596
|
maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
|
|
6004
6597
|
};
|
|
6005
6598
|
}
|
|
6006
6599
|
|
|
6007
6600
|
// src/cli.ts
|
|
6601
|
+
function takeValue(argv, index) {
|
|
6602
|
+
const next = argv[index];
|
|
6603
|
+
if (next === void 0 || next.startsWith("-") || next.trim() === "") {
|
|
6604
|
+
return void 0;
|
|
6605
|
+
}
|
|
6606
|
+
return { value: next };
|
|
6607
|
+
}
|
|
6008
6608
|
function parseArgs(argv) {
|
|
6009
|
-
const parsed = {
|
|
6609
|
+
const parsed = {
|
|
6610
|
+
version: false,
|
|
6611
|
+
help: false,
|
|
6612
|
+
unknown: [],
|
|
6613
|
+
missingValue: []
|
|
6614
|
+
};
|
|
6010
6615
|
for (let index = 0; index < argv.length; index += 1) {
|
|
6011
6616
|
const arg = argv[index];
|
|
6012
6617
|
if (arg === "--version" || arg === "-v") {
|
|
6013
6618
|
parsed.version = true;
|
|
6014
6619
|
} else if (arg === "--help" || arg === "-h") {
|
|
6015
6620
|
parsed.help = true;
|
|
6016
|
-
} else if (arg === "--output-dir") {
|
|
6017
|
-
|
|
6621
|
+
} else if (arg === "--output-dir" || arg === "--workspace-dir") {
|
|
6622
|
+
const taken = takeValue(argv, index + 1);
|
|
6623
|
+
if (!taken) {
|
|
6624
|
+
parsed.missingValue.push(arg);
|
|
6625
|
+
continue;
|
|
6626
|
+
}
|
|
6627
|
+
index += 1;
|
|
6628
|
+
if (arg === "--output-dir") parsed.outputDir = taken.value;
|
|
6629
|
+
else parsed.workspaceDir = taken.value;
|
|
6018
6630
|
} else if (arg.startsWith("--output-dir=")) {
|
|
6019
6631
|
parsed.outputDir = arg.slice("--output-dir=".length);
|
|
6632
|
+
} else if (arg.startsWith("--workspace-dir=")) {
|
|
6633
|
+
parsed.workspaceDir = arg.slice("--workspace-dir=".length);
|
|
6020
6634
|
} else {
|
|
6021
6635
|
parsed.unknown.push(arg);
|
|
6022
6636
|
}
|
|
@@ -6034,11 +6648,16 @@ Options:
|
|
|
6034
6648
|
--output-dir <path> Directory generated files are written to. Overrides
|
|
6035
6649
|
${OUTPUT_DIR_ENV}. Defaults to a per-connection
|
|
6036
6650
|
directory under the system temp dir.
|
|
6651
|
+
--workspace-dir <p> Directory workspace revisions are mirrored to, so a lost
|
|
6652
|
+
client session does not destroy them. Overrides
|
|
6653
|
+
${WORKSPACE_DIR_ENV}. Off by default:
|
|
6654
|
+
without it, handles live only in memory.
|
|
6037
6655
|
-v, --version Print the version and exit.
|
|
6038
6656
|
-h, --help Print this help and exit.
|
|
6039
6657
|
|
|
6040
6658
|
Environment:
|
|
6041
6659
|
${OUTPUT_DIR_ENV} Output root, when --output-dir is absent.
|
|
6660
|
+
${WORKSPACE_DIR_ENV} Workspace root, when --workspace-dir is absent.
|
|
6042
6661
|
LIBREOFFICE_PATH LibreOffice binary, for preview.
|
|
6043
6662
|
PDFTOPPM_PATH poppler pdftoppm binary, for preview.
|
|
6044
6663
|
|
|
@@ -6061,13 +6680,23 @@ function main(argv) {
|
|
|
6061
6680
|
process.stderr.write(
|
|
6062
6681
|
`jto-mcp: unknown argument ${args.unknown[0]}
|
|
6063
6682
|
Run jto-mcp --help.
|
|
6683
|
+
`
|
|
6684
|
+
);
|
|
6685
|
+
process.exitCode = 1;
|
|
6686
|
+
return;
|
|
6687
|
+
}
|
|
6688
|
+
if (args.missingValue.length > 0) {
|
|
6689
|
+
process.stderr.write(
|
|
6690
|
+
`jto-mcp: ${args.missingValue[0]} needs a directory path.
|
|
6691
|
+
Run jto-mcp --help.
|
|
6064
6692
|
`
|
|
6065
6693
|
);
|
|
6066
6694
|
process.exitCode = 1;
|
|
6067
6695
|
return;
|
|
6068
6696
|
}
|
|
6069
6697
|
const deps = createToolDeps({
|
|
6070
|
-
...args.outputDir !== void 0 && { outputDir: args.outputDir }
|
|
6698
|
+
...args.outputDir !== void 0 && { outputDir: args.outputDir },
|
|
6699
|
+
...args.workspaceDir !== void 0 && { workspaceDir: args.workspaceDir }
|
|
6071
6700
|
});
|
|
6072
6701
|
const handle = serveStdio(createServerFactory(deps), {
|
|
6073
6702
|
legacy: "serve",
|