@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/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
3
3
|
|
|
4
4
|
// src/lib/version.ts
|
|
5
|
-
var SERVER_VERSION = true ? "1.
|
|
5
|
+
var SERVER_VERSION = true ? "1.8.0" : "dev-mode";
|
|
6
6
|
var SERVER_NAME = "json-to-office";
|
|
7
7
|
var PACKAGE_NAME = "@json-to-office/mcp-server";
|
|
8
8
|
|
|
@@ -415,9 +415,9 @@ var ROOT_SENTINELS = /* @__PURE__ */ new Set(["", "/", "#", "root"]);
|
|
|
415
415
|
function escapePointerSegment(segment) {
|
|
416
416
|
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
417
417
|
}
|
|
418
|
-
function toJsonPointer(
|
|
419
|
-
if (
|
|
420
|
-
const trimmed =
|
|
418
|
+
function toJsonPointer(path7) {
|
|
419
|
+
if (path7 === void 0) return void 0;
|
|
420
|
+
const trimmed = path7.trim();
|
|
421
421
|
if (ROOT_SENTINELS.has(trimmed)) return "";
|
|
422
422
|
const segments = trimmed.startsWith("/") ? trimmed.slice(1).split("/") : trimmed.replace(/\[(\d+)\]/g, ".$1").split(".").filter((segment) => segment !== "");
|
|
423
423
|
if (segments.length === 0) return "";
|
|
@@ -618,11 +618,11 @@ async function probeService(rawUrl, envVar) {
|
|
|
618
618
|
}
|
|
619
619
|
const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
|
|
620
620
|
const net = await import("net");
|
|
621
|
-
return new Promise((
|
|
621
|
+
return new Promise((resolve3) => {
|
|
622
622
|
const socket = net.connect({ host: target.hostname, port });
|
|
623
623
|
const settle = (detail) => {
|
|
624
624
|
socket.destroy();
|
|
625
|
-
|
|
625
|
+
resolve3({
|
|
626
626
|
available: detail === void 0,
|
|
627
627
|
url: target.origin,
|
|
628
628
|
envVar,
|
|
@@ -662,7 +662,7 @@ function register(server, deps) {
|
|
|
662
662
|
"jto_info",
|
|
663
663
|
{
|
|
664
664
|
title: "Server info",
|
|
665
|
-
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.",
|
|
665
|
+
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.",
|
|
666
666
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
667
667
|
inputSchema: S({
|
|
668
668
|
type: "object",
|
|
@@ -747,9 +747,17 @@ function register(server, deps) {
|
|
|
747
747
|
type: "object",
|
|
748
748
|
properties: {
|
|
749
749
|
available: { type: "boolean" },
|
|
750
|
-
open: { type: "integer" }
|
|
750
|
+
open: { type: "integer" },
|
|
751
|
+
persistent: {
|
|
752
|
+
type: "boolean",
|
|
753
|
+
description: "True when revisions are mirrored to disk and survive a lost connection; false when handles are memory-only, which is the default."
|
|
754
|
+
},
|
|
755
|
+
root: {
|
|
756
|
+
type: "string",
|
|
757
|
+
description: "Directory the revisions are mirrored to, when persistent."
|
|
758
|
+
}
|
|
751
759
|
},
|
|
752
|
-
required: ["available", "open"],
|
|
760
|
+
required: ["available", "open", "persistent"],
|
|
753
761
|
additionalProperties: false
|
|
754
762
|
},
|
|
755
763
|
output: {
|
|
@@ -910,7 +918,11 @@ function register(server, deps) {
|
|
|
910
918
|
formats,
|
|
911
919
|
workspaces: {
|
|
912
920
|
available: store.available,
|
|
913
|
-
open: listed.ok ? listed.records.length : 0
|
|
921
|
+
open: listed.ok ? listed.records.length : 0,
|
|
922
|
+
persistent: deps.workspacePersistence !== void 0,
|
|
923
|
+
...deps.workspacePersistence && {
|
|
924
|
+
root: deps.workspacePersistence.root
|
|
925
|
+
}
|
|
914
926
|
},
|
|
915
927
|
output: {
|
|
916
928
|
root: deps.outputRoot.path,
|
|
@@ -2025,14 +2037,14 @@ function sourceSummary(resolved) {
|
|
|
2025
2037
|
revision: resolved.revision
|
|
2026
2038
|
} : { origin: "inline" };
|
|
2027
2039
|
}
|
|
2028
|
-
function parseDocumentJson(text,
|
|
2040
|
+
function parseDocumentJson(text, path7) {
|
|
2029
2041
|
try {
|
|
2030
2042
|
return { ok: true, document: JSON.parse(text) };
|
|
2031
2043
|
} catch (error) {
|
|
2032
2044
|
return failure(
|
|
2033
2045
|
ERROR_CODES.INVALID_JSON,
|
|
2034
2046
|
error instanceof Error ? error.message : String(error),
|
|
2035
|
-
{ ...
|
|
2047
|
+
{ ...path7 !== void 0 && { path: path7 } }
|
|
2036
2048
|
);
|
|
2037
2049
|
}
|
|
2038
2050
|
}
|
|
@@ -3148,9 +3160,9 @@ async function digestAssets(document, baseDir) {
|
|
|
3148
3160
|
[...references].map(async (reference) => {
|
|
3149
3161
|
const resolved = path4.resolve(root, reference);
|
|
3150
3162
|
try {
|
|
3151
|
-
const
|
|
3152
|
-
if (!
|
|
3153
|
-
entries.push(`${resolved}|${
|
|
3163
|
+
const stat2 = await fs4.stat(resolved);
|
|
3164
|
+
if (!stat2.isFile()) return;
|
|
3165
|
+
entries.push(`${resolved}|${stat2.size}|${stat2.mtimeMs}`);
|
|
3154
3166
|
} catch {
|
|
3155
3167
|
}
|
|
3156
3168
|
})
|
|
@@ -3180,8 +3192,8 @@ function collectAssetReferences(value, into) {
|
|
|
3180
3192
|
async function digestThemeFile(themePath) {
|
|
3181
3193
|
if (!themePath) return "none";
|
|
3182
3194
|
try {
|
|
3183
|
-
const
|
|
3184
|
-
return `${path4.resolve(themePath)}|${
|
|
3195
|
+
const stat2 = await fs4.stat(themePath);
|
|
3196
|
+
return `${path4.resolve(themePath)}|${stat2.size}|${stat2.mtimeMs}`;
|
|
3185
3197
|
} catch {
|
|
3186
3198
|
return `${path4.resolve(themePath)}|missing`;
|
|
3187
3199
|
}
|
|
@@ -3253,18 +3265,18 @@ function missingDependencyFailure(dependencies) {
|
|
|
3253
3265
|
);
|
|
3254
3266
|
}
|
|
3255
3267
|
async function binaryIdentity(binary, args, signal) {
|
|
3256
|
-
const [version,
|
|
3268
|
+
const [version, stat2] = await Promise.all([
|
|
3257
3269
|
readVersion(binary, args, signal),
|
|
3258
3270
|
fs5.stat(binary).catch(() => void 0)
|
|
3259
3271
|
]);
|
|
3260
3272
|
const identity = [
|
|
3261
3273
|
version ?? "unknown",
|
|
3262
|
-
|
|
3274
|
+
stat2 ? `${stat2.size}:${stat2.mtimeMs}` : "nostat"
|
|
3263
3275
|
].join("|");
|
|
3264
3276
|
return { identity, ...version !== void 0 && { version } };
|
|
3265
3277
|
}
|
|
3266
3278
|
function readVersion(binary, args, signal) {
|
|
3267
|
-
return new Promise((
|
|
3279
|
+
return new Promise((resolve3) => {
|
|
3268
3280
|
execFile(
|
|
3269
3281
|
binary,
|
|
3270
3282
|
args,
|
|
@@ -3278,8 +3290,8 @@ function readVersion(binary, args, signal) {
|
|
|
3278
3290
|
const text = `${stdout ?? ""}
|
|
3279
3291
|
${stderr ?? ""}`.trim();
|
|
3280
3292
|
const line = text.split("\n")[0]?.trim();
|
|
3281
|
-
if (!line && error) return
|
|
3282
|
-
|
|
3293
|
+
if (!line && error) return resolve3(void 0);
|
|
3294
|
+
resolve3(line || void 0);
|
|
3283
3295
|
}
|
|
3284
3296
|
);
|
|
3285
3297
|
});
|
|
@@ -3324,12 +3336,12 @@ function defaultPreviewCacheDir() {
|
|
|
3324
3336
|
}
|
|
3325
3337
|
var CACHE_ENTRY_NAME = /^[a-f0-9]{64}\.(?:png|meta\.json)(?:\.tmp-\d+-\d+)?$/;
|
|
3326
3338
|
async function inspectPrivateCacheDir(cacheDir) {
|
|
3327
|
-
const
|
|
3328
|
-
if (!
|
|
3329
|
-
if (typeof process.getuid === "function" &&
|
|
3339
|
+
const stat2 = await fs6.lstat(cacheDir).catch(() => void 0);
|
|
3340
|
+
if (!stat2 || stat2.isSymbolicLink() || !stat2.isDirectory()) return false;
|
|
3341
|
+
if (typeof process.getuid === "function" && stat2.uid !== process.getuid()) {
|
|
3330
3342
|
return false;
|
|
3331
3343
|
}
|
|
3332
|
-
if (process.platform !== "win32" && (
|
|
3344
|
+
if (process.platform !== "win32" && (stat2.mode & 63) !== 0) {
|
|
3333
3345
|
try {
|
|
3334
3346
|
await fs6.chmod(cacheDir, 448);
|
|
3335
3347
|
} catch {
|
|
@@ -3359,9 +3371,9 @@ async function sweepPreviewCache(cacheDir, options = {}) {
|
|
|
3359
3371
|
for (const name of names) {
|
|
3360
3372
|
if (!CACHE_ENTRY_NAME.test(name)) continue;
|
|
3361
3373
|
const file = path5.join(cacheDir, name);
|
|
3362
|
-
const
|
|
3363
|
-
if (!
|
|
3364
|
-
entries.push({ file, mtimeMs:
|
|
3374
|
+
const stat2 = await fs6.lstat(file).catch(() => void 0);
|
|
3375
|
+
if (!stat2?.isFile()) continue;
|
|
3376
|
+
entries.push({ file, mtimeMs: stat2.mtimeMs, size: stat2.size });
|
|
3365
3377
|
}
|
|
3366
3378
|
const doomed = entries.filter((entry) => now - entry.mtimeMs > maxAgeMs);
|
|
3367
3379
|
const kept = entries.filter((entry) => now - entry.mtimeMs <= maxAgeMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
@@ -3448,7 +3460,7 @@ function countPdfPages(pdf) {
|
|
|
3448
3460
|
return matches ? matches.length : 0;
|
|
3449
3461
|
}
|
|
3450
3462
|
function exec(binary, args, timeoutMs, options = {}) {
|
|
3451
|
-
return new Promise((
|
|
3463
|
+
return new Promise((resolve3, reject) => {
|
|
3452
3464
|
execFile2(
|
|
3453
3465
|
binary,
|
|
3454
3466
|
args,
|
|
@@ -3459,7 +3471,7 @@ function exec(binary, args, timeoutMs, options = {}) {
|
|
|
3459
3471
|
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
3460
3472
|
...options.signal && { signal: options.signal }
|
|
3461
3473
|
},
|
|
3462
|
-
(error) => error ? reject(error) :
|
|
3474
|
+
(error) => error ? reject(error) : resolve3()
|
|
3463
3475
|
);
|
|
3464
3476
|
});
|
|
3465
3477
|
}
|
|
@@ -3553,7 +3565,7 @@ async function resolvePdfinfo(pdftoppmPath) {
|
|
|
3553
3565
|
return status.available ? status.path : void 0;
|
|
3554
3566
|
}
|
|
3555
3567
|
async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
|
|
3556
|
-
return new Promise((
|
|
3568
|
+
return new Promise((resolve3) => {
|
|
3557
3569
|
execFile2(
|
|
3558
3570
|
pdfinfo,
|
|
3559
3571
|
[pdfPath],
|
|
@@ -3564,9 +3576,9 @@ async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
|
|
|
3564
3576
|
...signal && { signal }
|
|
3565
3577
|
},
|
|
3566
3578
|
(error, stdout) => {
|
|
3567
|
-
if (error && !stdout) return
|
|
3579
|
+
if (error && !stdout) return resolve3(void 0);
|
|
3568
3580
|
const match = /^Pages:\s+(\d+)/m.exec(stdout ?? "");
|
|
3569
|
-
|
|
3581
|
+
resolve3(match ? Number(match[1]) : void 0);
|
|
3570
3582
|
}
|
|
3571
3583
|
);
|
|
3572
3584
|
});
|
|
@@ -4679,9 +4691,9 @@ function compilePatch(operations) {
|
|
|
4679
4691
|
}
|
|
4680
4692
|
);
|
|
4681
4693
|
}
|
|
4682
|
-
const
|
|
4683
|
-
if (!
|
|
4684
|
-
return problem(PATCH_ERROR_CODES.INVALID_POINTER,
|
|
4694
|
+
const path7 = parsePointer(raw.path);
|
|
4695
|
+
if (!path7.ok) {
|
|
4696
|
+
return problem(PATCH_ERROR_CODES.INVALID_POINTER, path7.message, index, {
|
|
4685
4697
|
context: { op, pointer: raw.path },
|
|
4686
4698
|
suggestion: 'Pointers are RFC 6901: "/children/0/props/text", with "~0" for "~" and "~1" for "/".'
|
|
4687
4699
|
});
|
|
@@ -4689,7 +4701,7 @@ function compilePatch(operations) {
|
|
|
4689
4701
|
const entry = {
|
|
4690
4702
|
op,
|
|
4691
4703
|
path: raw.path,
|
|
4692
|
-
tokens:
|
|
4704
|
+
tokens: path7.tokens
|
|
4693
4705
|
};
|
|
4694
4706
|
if (VALUE_OPS.has(op)) {
|
|
4695
4707
|
if (raw.value === void 0) {
|
|
@@ -5039,12 +5051,338 @@ function jsonEqualTokens(left, right) {
|
|
|
5039
5051
|
}
|
|
5040
5052
|
|
|
5041
5053
|
// src/workspace/store.ts
|
|
5054
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
5055
|
+
|
|
5056
|
+
// src/workspace/persistence.ts
|
|
5057
|
+
import * as fs7 from "fs/promises";
|
|
5058
|
+
import * as path6 from "path";
|
|
5042
5059
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
5060
|
+
var WORKSPACE_DIR_ENV = "JTO_MCP_WORKSPACE_DIR";
|
|
5061
|
+
var SCHEMA_VERSION = 1;
|
|
5062
|
+
var META_FILE = "meta.json";
|
|
5063
|
+
var REVISION_PREFIX = "rev-";
|
|
5064
|
+
var REVISION_SUFFIX = ".json";
|
|
5065
|
+
var TEMP_SUFFIX = ".tmp";
|
|
5066
|
+
var STALE_TEMP_MS = 60 * 60 * 1e3;
|
|
5067
|
+
var SAFE_HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5068
|
+
function isPersistableHandle(handle) {
|
|
5069
|
+
return SAFE_HANDLE.test(handle);
|
|
5070
|
+
}
|
|
5071
|
+
var DEFAULT_PERSISTENCE_LIMITS = {
|
|
5072
|
+
maxWorkspaces: 32,
|
|
5073
|
+
maxRevisionsPerWorkspace: 9,
|
|
5074
|
+
maxEntryBytes: 16 * 1024 * 1024
|
|
5075
|
+
};
|
|
5076
|
+
function createWorkspacePersistence(options = {}) {
|
|
5077
|
+
const env = options.env ?? process.env;
|
|
5078
|
+
const configured = options.flagDir?.trim() || env[WORKSPACE_DIR_ENV]?.trim();
|
|
5079
|
+
if (!configured) return void 0;
|
|
5080
|
+
return createWorkspacePersistenceAt(path6.resolve(configured), options);
|
|
5081
|
+
}
|
|
5082
|
+
function createWorkspacePersistenceAt(root, limits = {}) {
|
|
5083
|
+
const bounds = {
|
|
5084
|
+
maxWorkspaces: limits.maxWorkspaces ?? DEFAULT_PERSISTENCE_LIMITS.maxWorkspaces,
|
|
5085
|
+
maxRevisionsPerWorkspace: limits.maxRevisionsPerWorkspace ?? DEFAULT_PERSISTENCE_LIMITS.maxRevisionsPerWorkspace,
|
|
5086
|
+
maxEntryBytes: limits.maxEntryBytes ?? DEFAULT_PERSISTENCE_LIMITS.maxEntryBytes
|
|
5087
|
+
};
|
|
5088
|
+
let ensured;
|
|
5089
|
+
const authored = /* @__PURE__ */ new Map();
|
|
5090
|
+
function claim(handle, revision) {
|
|
5091
|
+
const owned = authored.get(handle) ?? /* @__PURE__ */ new Set();
|
|
5092
|
+
owned.add(revision);
|
|
5093
|
+
authored.set(handle, owned);
|
|
5094
|
+
}
|
|
5095
|
+
async function ensureRoot() {
|
|
5096
|
+
if (ensured === void 0) {
|
|
5097
|
+
ensured = fs7.mkdir(root, { recursive: true, mode: 448 }).then(
|
|
5098
|
+
() => void 0,
|
|
5099
|
+
(error) => {
|
|
5100
|
+
ensured = void 0;
|
|
5101
|
+
throw error;
|
|
5102
|
+
}
|
|
5103
|
+
);
|
|
5104
|
+
}
|
|
5105
|
+
return ensured;
|
|
5106
|
+
}
|
|
5107
|
+
function dirFor(handle) {
|
|
5108
|
+
return path6.join(root, handle);
|
|
5109
|
+
}
|
|
5110
|
+
async function writeAtomic2(target, contents) {
|
|
5111
|
+
const temp = `${target}.${randomBytes2(6).toString("hex")}${TEMP_SUFFIX}`;
|
|
5112
|
+
try {
|
|
5113
|
+
await fs7.writeFile(temp, contents, { encoding: "utf8", mode: 384 });
|
|
5114
|
+
await fs7.rename(temp, target);
|
|
5115
|
+
} catch (error) {
|
|
5116
|
+
await fs7.rm(temp, { force: true }).catch(() => void 0);
|
|
5117
|
+
throw error;
|
|
5118
|
+
}
|
|
5119
|
+
}
|
|
5120
|
+
function revisionFile(handle, revision) {
|
|
5121
|
+
return path6.join(
|
|
5122
|
+
dirFor(handle),
|
|
5123
|
+
`${REVISION_PREFIX}${revision}${REVISION_SUFFIX}`
|
|
5124
|
+
);
|
|
5125
|
+
}
|
|
5126
|
+
async function readMeta(handle) {
|
|
5127
|
+
let raw;
|
|
5128
|
+
try {
|
|
5129
|
+
raw = await fs7.readFile(path6.join(dirFor(handle), META_FILE), "utf8");
|
|
5130
|
+
} catch {
|
|
5131
|
+
return void 0;
|
|
5132
|
+
}
|
|
5133
|
+
return parseMeta(raw, handle);
|
|
5134
|
+
}
|
|
5135
|
+
async function readRevision(handle, revision) {
|
|
5136
|
+
let text;
|
|
5137
|
+
try {
|
|
5138
|
+
text = await fs7.readFile(revisionFile(handle, revision), "utf8");
|
|
5139
|
+
} catch {
|
|
5140
|
+
return void 0;
|
|
5141
|
+
}
|
|
5142
|
+
try {
|
|
5143
|
+
const parsed = JSON.parse(text);
|
|
5144
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
5145
|
+
return void 0;
|
|
5146
|
+
} catch {
|
|
5147
|
+
return void 0;
|
|
5148
|
+
}
|
|
5149
|
+
return { revision, text, bytes: Buffer.byteLength(text, "utf8") };
|
|
5150
|
+
}
|
|
5151
|
+
async function handleDirs() {
|
|
5152
|
+
try {
|
|
5153
|
+
const entries = await fs7.readdir(root, { withFileTypes: true });
|
|
5154
|
+
return entries.filter(
|
|
5155
|
+
(entry) => entry.isDirectory() && isPersistableHandle(entry.name)
|
|
5156
|
+
).map((entry) => entry.name);
|
|
5157
|
+
} catch {
|
|
5158
|
+
return [];
|
|
5159
|
+
}
|
|
5160
|
+
}
|
|
5161
|
+
async function pruneWorkspaces(incoming) {
|
|
5162
|
+
const handles = (await handleDirs()).filter(
|
|
5163
|
+
(handle) => handle !== incoming
|
|
5164
|
+
);
|
|
5165
|
+
const excess = handles.length + 1 - bounds.maxWorkspaces;
|
|
5166
|
+
if (excess <= 0) return;
|
|
5167
|
+
const metas = await Promise.all(
|
|
5168
|
+
handles.map(async (handle) => ({
|
|
5169
|
+
handle,
|
|
5170
|
+
// An unreadable entry sorts first: it is the one worth losing.
|
|
5171
|
+
updatedAt: (await readMeta(handle))?.updatedAt ?? 0
|
|
5172
|
+
}))
|
|
5173
|
+
);
|
|
5174
|
+
metas.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
5175
|
+
for (const victim of metas.slice(0, excess)) {
|
|
5176
|
+
await fs7.rm(dirFor(victim.handle), { recursive: true, force: true }).catch(() => void 0);
|
|
5177
|
+
authored.delete(victim.handle);
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
async function pruneRevisions(handle, retained) {
|
|
5181
|
+
let names;
|
|
5182
|
+
try {
|
|
5183
|
+
names = await fs7.readdir(dirFor(handle));
|
|
5184
|
+
} catch {
|
|
5185
|
+
return;
|
|
5186
|
+
}
|
|
5187
|
+
const owned = authored.get(handle);
|
|
5188
|
+
for (const name of names) {
|
|
5189
|
+
const target = path6.join(dirFor(handle), name);
|
|
5190
|
+
if (name.endsWith(TEMP_SUFFIX)) {
|
|
5191
|
+
if (await isStale(target)) {
|
|
5192
|
+
await fs7.rm(target, { force: true }).catch(() => void 0);
|
|
5193
|
+
}
|
|
5194
|
+
continue;
|
|
5195
|
+
}
|
|
5196
|
+
const revision = revisionOf(name);
|
|
5197
|
+
if (revision === void 0 || retained.has(revision)) continue;
|
|
5198
|
+
await fs7.rm(target, { force: true }).catch(() => void 0);
|
|
5199
|
+
owned?.delete(revision);
|
|
5200
|
+
}
|
|
5201
|
+
}
|
|
5202
|
+
return {
|
|
5203
|
+
root,
|
|
5204
|
+
limits: bounds,
|
|
5205
|
+
async save(snapshot) {
|
|
5206
|
+
if (!isPersistableHandle(snapshot.handle)) {
|
|
5207
|
+
throw new Error(
|
|
5208
|
+
`Workspace handle ${JSON.stringify(
|
|
5209
|
+
snapshot.handle
|
|
5210
|
+
)} cannot be used as a directory name.`
|
|
5211
|
+
);
|
|
5212
|
+
}
|
|
5213
|
+
if (snapshot.head.bytes > bounds.maxEntryBytes) {
|
|
5214
|
+
throw new Error(
|
|
5215
|
+
`Revision ${snapshot.head.revision} is ${snapshot.head.bytes} bytes, over the ${bounds.maxEntryBytes}-byte persistence limit.`
|
|
5216
|
+
);
|
|
5217
|
+
}
|
|
5218
|
+
await ensureRoot();
|
|
5219
|
+
const dir = dirFor(snapshot.handle);
|
|
5220
|
+
const fresh = !await exists(dir);
|
|
5221
|
+
if (fresh) await pruneWorkspaces(snapshot.handle);
|
|
5222
|
+
await fs7.mkdir(dir, { recursive: true, mode: 448 });
|
|
5223
|
+
const files = [snapshot.head];
|
|
5224
|
+
const retained = /* @__PURE__ */ new Set([snapshot.head.revision]);
|
|
5225
|
+
const pinned = [];
|
|
5226
|
+
for (const pin of [...snapshot.pins].sort(
|
|
5227
|
+
(a, b) => b.revision - a.revision
|
|
5228
|
+
)) {
|
|
5229
|
+
if (!retained.has(pin.revision)) {
|
|
5230
|
+
if (files.length >= bounds.maxRevisionsPerWorkspace) continue;
|
|
5231
|
+
files.push(pin);
|
|
5232
|
+
retained.add(pin.revision);
|
|
5233
|
+
}
|
|
5234
|
+
pinned.push(pin.revision);
|
|
5235
|
+
}
|
|
5236
|
+
const owned = authored.get(snapshot.handle);
|
|
5237
|
+
for (const document of files) {
|
|
5238
|
+
if (owned?.has(document.revision)) continue;
|
|
5239
|
+
await writeAtomic2(
|
|
5240
|
+
revisionFile(snapshot.handle, document.revision),
|
|
5241
|
+
document.text
|
|
5242
|
+
);
|
|
5243
|
+
claim(snapshot.handle, document.revision);
|
|
5244
|
+
}
|
|
5245
|
+
const meta = {
|
|
5246
|
+
schema: SCHEMA_VERSION,
|
|
5247
|
+
handle: snapshot.handle,
|
|
5248
|
+
format: snapshot.format,
|
|
5249
|
+
revision: snapshot.head.revision,
|
|
5250
|
+
bytes: snapshot.head.bytes,
|
|
5251
|
+
createdAt: snapshot.createdAt,
|
|
5252
|
+
updatedAt: snapshot.updatedAt,
|
|
5253
|
+
...snapshot.title !== void 0 && { title: snapshot.title },
|
|
5254
|
+
pinnedRevisions: pinned.sort((a, b) => a - b)
|
|
5255
|
+
};
|
|
5256
|
+
await writeAtomic2(path6.join(dir, META_FILE), JSON.stringify(meta));
|
|
5257
|
+
await pruneRevisions(snapshot.handle, retained);
|
|
5258
|
+
},
|
|
5259
|
+
async list() {
|
|
5260
|
+
const handles = await handleDirs();
|
|
5261
|
+
const metas = await Promise.all(
|
|
5262
|
+
handles.map((handle) => readMeta(handle))
|
|
5263
|
+
);
|
|
5264
|
+
return metas.filter((meta) => meta !== void 0).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
5265
|
+
},
|
|
5266
|
+
async load(handle) {
|
|
5267
|
+
if (!isPersistableHandle(handle)) return void 0;
|
|
5268
|
+
const meta = await readMeta(handle);
|
|
5269
|
+
if (!meta) return void 0;
|
|
5270
|
+
const head = await readRevision(handle, meta.revision);
|
|
5271
|
+
if (!head) {
|
|
5272
|
+
await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
|
|
5273
|
+
return void 0;
|
|
5274
|
+
}
|
|
5275
|
+
const pins = [];
|
|
5276
|
+
for (const revision of meta.pinnedRevisions) {
|
|
5277
|
+
const pin = await readRevision(handle, revision);
|
|
5278
|
+
if (pin) pins.push(pin);
|
|
5279
|
+
}
|
|
5280
|
+
return {
|
|
5281
|
+
...meta,
|
|
5282
|
+
// A pin whose file went missing is dropped rather than advertised: the
|
|
5283
|
+
// record has to describe what can actually be read back.
|
|
5284
|
+
pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
|
|
5285
|
+
head,
|
|
5286
|
+
pins
|
|
5287
|
+
};
|
|
5288
|
+
},
|
|
5289
|
+
async remove(handle) {
|
|
5290
|
+
if (!isPersistableHandle(handle)) return false;
|
|
5291
|
+
const dir = dirFor(handle);
|
|
5292
|
+
if (!await exists(dir)) {
|
|
5293
|
+
authored.delete(handle);
|
|
5294
|
+
return false;
|
|
5295
|
+
}
|
|
5296
|
+
await fs7.rm(dir, { recursive: true, force: true });
|
|
5297
|
+
authored.delete(handle);
|
|
5298
|
+
return true;
|
|
5299
|
+
}
|
|
5300
|
+
};
|
|
5301
|
+
}
|
|
5302
|
+
async function exists(target) {
|
|
5303
|
+
try {
|
|
5304
|
+
await fs7.stat(target);
|
|
5305
|
+
return true;
|
|
5306
|
+
} catch {
|
|
5307
|
+
return false;
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5310
|
+
async function isStale(target) {
|
|
5311
|
+
try {
|
|
5312
|
+
const { mtimeMs } = await fs7.stat(target);
|
|
5313
|
+
return Date.now() - mtimeMs > STALE_TEMP_MS;
|
|
5314
|
+
} catch {
|
|
5315
|
+
return false;
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
function revisionOf(name) {
|
|
5319
|
+
if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
|
|
5320
|
+
return void 0;
|
|
5321
|
+
}
|
|
5322
|
+
const digits = name.slice(
|
|
5323
|
+
REVISION_PREFIX.length,
|
|
5324
|
+
name.length - REVISION_SUFFIX.length
|
|
5325
|
+
);
|
|
5326
|
+
if (!/^[0-9]+$/.test(digits)) return void 0;
|
|
5327
|
+
return Number(digits);
|
|
5328
|
+
}
|
|
5329
|
+
function parseMeta(raw, handle) {
|
|
5330
|
+
let parsed;
|
|
5331
|
+
try {
|
|
5332
|
+
parsed = JSON.parse(raw);
|
|
5333
|
+
} catch {
|
|
5334
|
+
return void 0;
|
|
5335
|
+
}
|
|
5336
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5337
|
+
return void 0;
|
|
5338
|
+
}
|
|
5339
|
+
const meta = parsed;
|
|
5340
|
+
if (meta.schema !== SCHEMA_VERSION) return void 0;
|
|
5341
|
+
if (meta.handle !== handle) return void 0;
|
|
5342
|
+
if (!FORMAT_NAMES.includes(meta.format)) return void 0;
|
|
5343
|
+
if (!isPositiveInteger(meta.revision)) return void 0;
|
|
5344
|
+
if (!isNonNegativeInteger(meta.bytes)) return void 0;
|
|
5345
|
+
if (!isNonNegativeInteger(meta.createdAt)) return void 0;
|
|
5346
|
+
if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
|
|
5347
|
+
if (meta.title !== void 0 && typeof meta.title !== "string")
|
|
5348
|
+
return void 0;
|
|
5349
|
+
const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
|
|
5350
|
+
return {
|
|
5351
|
+
handle,
|
|
5352
|
+
format: meta.format,
|
|
5353
|
+
revision: meta.revision,
|
|
5354
|
+
bytes: meta.bytes,
|
|
5355
|
+
createdAt: meta.createdAt,
|
|
5356
|
+
updatedAt: meta.updatedAt,
|
|
5357
|
+
...typeof meta.title === "string" && { title: meta.title },
|
|
5358
|
+
pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
|
|
5359
|
+
};
|
|
5360
|
+
}
|
|
5361
|
+
function isPositiveInteger(value) {
|
|
5362
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
5363
|
+
}
|
|
5364
|
+
function isNonNegativeInteger(value) {
|
|
5365
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
5366
|
+
}
|
|
5367
|
+
|
|
5368
|
+
// src/workspace/store.ts
|
|
5043
5369
|
var WORKSPACE_ERROR_CODES = {
|
|
5044
5370
|
EVICTED: "E_WORKSPACE_EVICTED",
|
|
5045
5371
|
LIMIT: "E_WORKSPACE_LIMIT",
|
|
5046
5372
|
DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
|
|
5047
|
-
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
|
|
5373
|
+
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
|
|
5374
|
+
/**
|
|
5375
|
+
* A revision could not be mirrored to disk (#290). A warning, never a
|
|
5376
|
+
* failure: the edit landed and the workspace is usable, but this connection
|
|
5377
|
+
* has stopped being survivable and saying so is the whole point.
|
|
5378
|
+
*/
|
|
5379
|
+
NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
|
|
5380
|
+
/**
|
|
5381
|
+
* A close could not delete the durable copy, so nothing was released (#290).
|
|
5382
|
+
* An error rather than a warning: the agent asked for the document to be
|
|
5383
|
+
* destroyed, and it is still there to be read by the next connection.
|
|
5384
|
+
*/
|
|
5385
|
+
NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
|
|
5048
5386
|
};
|
|
5049
5387
|
var DEFAULT_WORKSPACE_LIMITS = {
|
|
5050
5388
|
maxWorkspaces: 16,
|
|
@@ -5064,6 +5402,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5064
5402
|
};
|
|
5065
5403
|
const now = options.now ?? Date.now;
|
|
5066
5404
|
const newHandle = options.newHandle ?? defaultHandle;
|
|
5405
|
+
const persistence = options.persistence;
|
|
5067
5406
|
const entries = /* @__PURE__ */ new Map();
|
|
5068
5407
|
const tombstones = /* @__PURE__ */ new Map();
|
|
5069
5408
|
function footprint(entry) {
|
|
@@ -5076,12 +5415,8 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5076
5415
|
for (const entry of entries.values()) total += footprint(entry);
|
|
5077
5416
|
return total;
|
|
5078
5417
|
}
|
|
5079
|
-
function tombstone(
|
|
5080
|
-
tombstones.set(
|
|
5081
|
-
reason,
|
|
5082
|
-
at: now(),
|
|
5083
|
-
revision: entry.revision
|
|
5084
|
-
});
|
|
5418
|
+
function tombstone(handle, reason, revision) {
|
|
5419
|
+
tombstones.set(handle, { reason, at: now(), revision });
|
|
5085
5420
|
while (tombstones.size > MAX_TOMBSTONES) {
|
|
5086
5421
|
const oldest = tombstones.keys().next();
|
|
5087
5422
|
if (oldest.done) break;
|
|
@@ -5091,7 +5426,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5091
5426
|
function evict(entry, reason) {
|
|
5092
5427
|
entries.delete(entry.handle);
|
|
5093
5428
|
tombstones.delete(entry.handle);
|
|
5094
|
-
tombstone(entry, reason);
|
|
5429
|
+
tombstone(entry.handle, reason, entry.revision);
|
|
5095
5430
|
}
|
|
5096
5431
|
function sweep() {
|
|
5097
5432
|
const at = now();
|
|
@@ -5099,6 +5434,108 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5099
5434
|
if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
|
|
5100
5435
|
}
|
|
5101
5436
|
}
|
|
5437
|
+
async function persist(entry) {
|
|
5438
|
+
if (!persistence) return void 0;
|
|
5439
|
+
try {
|
|
5440
|
+
await persistence.save({
|
|
5441
|
+
handle: entry.handle,
|
|
5442
|
+
format: entry.format,
|
|
5443
|
+
createdAt: entry.createdAt,
|
|
5444
|
+
updatedAt: entry.updatedAt,
|
|
5445
|
+
...entry.title !== void 0 && { title: entry.title },
|
|
5446
|
+
head: {
|
|
5447
|
+
revision: entry.revision,
|
|
5448
|
+
text: entry.text,
|
|
5449
|
+
bytes: entry.bytes
|
|
5450
|
+
},
|
|
5451
|
+
pins: [...entry.pins.entries()].map(([revision, pin]) => ({
|
|
5452
|
+
revision,
|
|
5453
|
+
text: pin.text,
|
|
5454
|
+
bytes: pin.bytes
|
|
5455
|
+
}))
|
|
5456
|
+
});
|
|
5457
|
+
entry.persisted = true;
|
|
5458
|
+
return void 0;
|
|
5459
|
+
} catch (error) {
|
|
5460
|
+
entry.persisted = false;
|
|
5461
|
+
return diagnostic(
|
|
5462
|
+
WORKSPACE_ERROR_CODES.NOT_PERSISTED,
|
|
5463
|
+
`Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${persistence.root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
5464
|
+
{
|
|
5465
|
+
severity: "warning",
|
|
5466
|
+
suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
|
|
5467
|
+
context: {
|
|
5468
|
+
handle: entry.handle,
|
|
5469
|
+
revision: entry.revision,
|
|
5470
|
+
workspaceRoot: persistence.root
|
|
5471
|
+
}
|
|
5472
|
+
}
|
|
5473
|
+
);
|
|
5474
|
+
}
|
|
5475
|
+
}
|
|
5476
|
+
function committed(record, warning) {
|
|
5477
|
+
return { ok: true, record, ...warning && { warnings: [warning] } };
|
|
5478
|
+
}
|
|
5479
|
+
async function rehydrate(handle) {
|
|
5480
|
+
if (!persistence || !isPersistableHandle(handle)) return void 0;
|
|
5481
|
+
let loaded;
|
|
5482
|
+
try {
|
|
5483
|
+
loaded = await persistence.load(handle);
|
|
5484
|
+
} catch {
|
|
5485
|
+
return void 0;
|
|
5486
|
+
}
|
|
5487
|
+
if (!loaded) return void 0;
|
|
5488
|
+
const restored = loaded;
|
|
5489
|
+
const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
|
|
5490
|
+
const needed = pins.reduce(
|
|
5491
|
+
(total, pin) => total + pin.bytes,
|
|
5492
|
+
restored.head.bytes
|
|
5493
|
+
);
|
|
5494
|
+
if (restored.head.bytes > limits.maxDocumentBytes) {
|
|
5495
|
+
return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
|
|
5496
|
+
}
|
|
5497
|
+
if (!hasRoom(needed, true)) {
|
|
5498
|
+
return failure(
|
|
5499
|
+
WORKSPACE_ERROR_CODES.LIMIT,
|
|
5500
|
+
`Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
|
|
5501
|
+
{
|
|
5502
|
+
suggestion: "Close a workspace you have finished with, then use this handle again.",
|
|
5503
|
+
context: {
|
|
5504
|
+
handle,
|
|
5505
|
+
bytes: needed,
|
|
5506
|
+
maxTotalBytes: limits.maxTotalBytes,
|
|
5507
|
+
maxWorkspaces: limits.maxWorkspaces
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
);
|
|
5511
|
+
}
|
|
5512
|
+
const entry = {
|
|
5513
|
+
handle,
|
|
5514
|
+
format: restored.format,
|
|
5515
|
+
revision: restored.head.revision,
|
|
5516
|
+
text: restored.head.text,
|
|
5517
|
+
bytes: restored.head.bytes,
|
|
5518
|
+
createdAt: restored.createdAt,
|
|
5519
|
+
updatedAt: restored.updatedAt,
|
|
5520
|
+
touchedAt: now(),
|
|
5521
|
+
...restored.title !== void 0 && { title: restored.title },
|
|
5522
|
+
pins: new Map(
|
|
5523
|
+
pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
|
|
5524
|
+
),
|
|
5525
|
+
persisted: true
|
|
5526
|
+
};
|
|
5527
|
+
entries.set(handle, entry);
|
|
5528
|
+
tombstones.delete(handle);
|
|
5529
|
+
return { ok: true, entry };
|
|
5530
|
+
}
|
|
5531
|
+
async function resolve3(handle) {
|
|
5532
|
+
const entry = entries.get(handle);
|
|
5533
|
+
if (entry) return { entry };
|
|
5534
|
+
const restored = await rehydrate(handle);
|
|
5535
|
+
if (restored === void 0) return missing(handle);
|
|
5536
|
+
if (!restored.ok) return restored;
|
|
5537
|
+
return { entry: restored.entry };
|
|
5538
|
+
}
|
|
5102
5539
|
function missing(handle) {
|
|
5103
5540
|
const grave = tombstones.get(handle);
|
|
5104
5541
|
if (grave?.reason === "ttl") {
|
|
@@ -5134,12 +5571,29 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5134
5571
|
createdAt: new Date(entry.createdAt).toISOString(),
|
|
5135
5572
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
5136
5573
|
...entry.title !== void 0 && { title: entry.title },
|
|
5137
|
-
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
|
|
5574
|
+
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
|
|
5575
|
+
...persistence && { persisted: entry.persisted }
|
|
5576
|
+
};
|
|
5577
|
+
}
|
|
5578
|
+
function fromMeta(meta) {
|
|
5579
|
+
return {
|
|
5580
|
+
handle: meta.handle,
|
|
5581
|
+
format: meta.format,
|
|
5582
|
+
revision: meta.revision,
|
|
5583
|
+
bytes: meta.bytes,
|
|
5584
|
+
createdAt: new Date(meta.createdAt).toISOString(),
|
|
5585
|
+
updatedAt: new Date(meta.updatedAt).toISOString(),
|
|
5586
|
+
...meta.title !== void 0 && { title: meta.title },
|
|
5587
|
+
pinnedRevisions: [...meta.pinnedRevisions],
|
|
5588
|
+
persisted: true
|
|
5138
5589
|
};
|
|
5139
5590
|
}
|
|
5140
5591
|
return {
|
|
5141
5592
|
available: true,
|
|
5142
5593
|
limits,
|
|
5594
|
+
...persistence && {
|
|
5595
|
+
persistence: { root: persistence.root, limits: persistence.limits }
|
|
5596
|
+
},
|
|
5143
5597
|
usage() {
|
|
5144
5598
|
sweep();
|
|
5145
5599
|
return { workspaces: entries.size, bytes: totalBytes() };
|
|
@@ -5180,15 +5634,18 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5180
5634
|
updatedAt: at,
|
|
5181
5635
|
touchedAt: at,
|
|
5182
5636
|
...input.title !== void 0 && { title: input.title },
|
|
5183
|
-
pins: /* @__PURE__ */ new Map()
|
|
5637
|
+
pins: /* @__PURE__ */ new Map(),
|
|
5638
|
+
persisted: false
|
|
5184
5639
|
};
|
|
5185
5640
|
entries.set(entry.handle, entry);
|
|
5186
|
-
|
|
5641
|
+
const warning = await persist(entry);
|
|
5642
|
+
return committed(toRecord(entry), warning);
|
|
5187
5643
|
},
|
|
5188
5644
|
async get(handle, readOptions) {
|
|
5189
5645
|
sweep();
|
|
5190
|
-
const
|
|
5191
|
-
if (!entry) return
|
|
5646
|
+
const found = await resolve3(handle);
|
|
5647
|
+
if (!("entry" in found)) return found;
|
|
5648
|
+
const entry = found.entry;
|
|
5192
5649
|
entry.touchedAt = now();
|
|
5193
5650
|
let text = entry.text;
|
|
5194
5651
|
let bytes = entry.bytes;
|
|
@@ -5213,15 +5670,16 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5213
5670
|
context: { handle, pointer }
|
|
5214
5671
|
});
|
|
5215
5672
|
}
|
|
5216
|
-
const
|
|
5217
|
-
if (
|
|
5673
|
+
const found2 = resolvePointer(document, parsed.tokens);
|
|
5674
|
+
if (found2.found) projection[pointer] = found2.value;
|
|
5218
5675
|
}
|
|
5219
5676
|
return { ok: true, record, document, projection };
|
|
5220
5677
|
},
|
|
5221
5678
|
async patch(input) {
|
|
5222
5679
|
sweep();
|
|
5223
|
-
const
|
|
5224
|
-
if (!entry) return
|
|
5680
|
+
const found = await resolve3(input.handle);
|
|
5681
|
+
if (!("entry" in found)) return found;
|
|
5682
|
+
const entry = found.entry;
|
|
5225
5683
|
entry.touchedAt = now();
|
|
5226
5684
|
if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
|
|
5227
5685
|
return stale(
|
|
@@ -5287,12 +5745,14 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5287
5745
|
entry.revision += 1;
|
|
5288
5746
|
entry.updatedAt = now();
|
|
5289
5747
|
entry.touchedAt = entry.updatedAt;
|
|
5290
|
-
|
|
5748
|
+
const warning = await persist(entry);
|
|
5749
|
+
return committed(toRecord(entry), warning);
|
|
5291
5750
|
},
|
|
5292
5751
|
async snapshot(handle) {
|
|
5293
5752
|
sweep();
|
|
5294
|
-
const
|
|
5295
|
-
if (!entry) return
|
|
5753
|
+
const found = await resolve3(handle);
|
|
5754
|
+
if (!("entry" in found)) return found;
|
|
5755
|
+
const entry = found.entry;
|
|
5296
5756
|
entry.touchedAt = now();
|
|
5297
5757
|
const revision = entry.revision;
|
|
5298
5758
|
const document = JSON.parse(entry.text);
|
|
@@ -5301,24 +5761,70 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5301
5761
|
entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
|
|
5302
5762
|
}
|
|
5303
5763
|
}
|
|
5304
|
-
|
|
5305
|
-
},
|
|
5306
|
-
async list() {
|
|
5307
|
-
sweep();
|
|
5764
|
+
const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
|
|
5308
5765
|
return {
|
|
5309
5766
|
ok: true,
|
|
5310
|
-
|
|
5767
|
+
record: toRecord(entry),
|
|
5768
|
+
document,
|
|
5769
|
+
...warning && { warnings: [warning] }
|
|
5311
5770
|
};
|
|
5312
5771
|
},
|
|
5772
|
+
async list() {
|
|
5773
|
+
sweep();
|
|
5774
|
+
const records = [...entries.values()].map((entry) => toRecord(entry));
|
|
5775
|
+
if (!persistence) return { ok: true, records };
|
|
5776
|
+
const resident = new Set(records.map((record) => record.handle));
|
|
5777
|
+
let stored = [];
|
|
5778
|
+
try {
|
|
5779
|
+
stored = await persistence.list();
|
|
5780
|
+
} catch {
|
|
5781
|
+
}
|
|
5782
|
+
for (const meta of stored) {
|
|
5783
|
+
if (resident.has(meta.handle)) continue;
|
|
5784
|
+
if (tombstones.get(meta.handle)?.reason === "closed") continue;
|
|
5785
|
+
records.push(fromMeta(meta));
|
|
5786
|
+
}
|
|
5787
|
+
return { ok: true, records };
|
|
5788
|
+
},
|
|
5313
5789
|
async close(handle) {
|
|
5314
5790
|
sweep();
|
|
5315
5791
|
const entry = entries.get(handle);
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5792
|
+
let durable = false;
|
|
5793
|
+
if (persistence) {
|
|
5794
|
+
try {
|
|
5795
|
+
durable = await persistence.remove(handle);
|
|
5796
|
+
} catch (error) {
|
|
5797
|
+
return failure(
|
|
5798
|
+
WORKSPACE_ERROR_CODES.NOT_CLOSED,
|
|
5799
|
+
`Workspace ${handle} could not be removed from ${persistence.root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
|
|
5800
|
+
{
|
|
5801
|
+
suggestion: "Check the workspace directory is writable, then close again.",
|
|
5802
|
+
context: { handle, workspaceRoot: persistence.root }
|
|
5803
|
+
}
|
|
5804
|
+
);
|
|
5805
|
+
}
|
|
5806
|
+
}
|
|
5807
|
+
if (entry) evict(entry, "closed");
|
|
5808
|
+
else if (durable) tombstone(handle, "closed", 0);
|
|
5809
|
+
return { ok: true, handle, closed: entry !== void 0 || durable };
|
|
5319
5810
|
},
|
|
5811
|
+
/**
|
|
5812
|
+
* Release memory, keep the disk.
|
|
5813
|
+
*
|
|
5814
|
+
* The asymmetry with `close` is the point: this exists for a host
|
|
5815
|
+
* reclaiming memory at a moment of its own choosing, and #290 is precisely
|
|
5816
|
+
* about an event that ends a connection not being allowed to destroy
|
|
5817
|
+
* revisions. The handles come back from disk on next use.
|
|
5818
|
+
*/
|
|
5320
5819
|
async closeAll() {
|
|
5321
|
-
for (const entry of [...entries.values()])
|
|
5820
|
+
for (const entry of [...entries.values()]) {
|
|
5821
|
+
if (!persistence) {
|
|
5822
|
+
evict(entry, "closed");
|
|
5823
|
+
continue;
|
|
5824
|
+
}
|
|
5825
|
+
entries.delete(entry.handle);
|
|
5826
|
+
tombstones.delete(entry.handle);
|
|
5827
|
+
}
|
|
5322
5828
|
}
|
|
5323
5829
|
};
|
|
5324
5830
|
function stale(entry, wanted, pinned, mutation = false) {
|
|
@@ -5338,7 +5844,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5338
5844
|
}
|
|
5339
5845
|
}
|
|
5340
5846
|
function defaultHandle() {
|
|
5341
|
-
return `ws_${
|
|
5847
|
+
return `ws_${randomBytes3(9).toString("base64url")}`;
|
|
5342
5848
|
}
|
|
5343
5849
|
function serialize(document) {
|
|
5344
5850
|
let text;
|
|
@@ -5391,7 +5897,7 @@ var workspaceSchema = {
|
|
|
5391
5897
|
properties: {
|
|
5392
5898
|
handle: {
|
|
5393
5899
|
type: "string",
|
|
5394
|
-
description: "Opaque
|
|
5900
|
+
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."
|
|
5395
5901
|
},
|
|
5396
5902
|
format: { type: "string", enum: [...FORMAT_NAMES] },
|
|
5397
5903
|
revision: {
|
|
@@ -5409,6 +5915,10 @@ var workspaceSchema = {
|
|
|
5409
5915
|
type: "array",
|
|
5410
5916
|
items: { type: "integer" },
|
|
5411
5917
|
description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
|
|
5918
|
+
},
|
|
5919
|
+
persisted: {
|
|
5920
|
+
type: "boolean",
|
|
5921
|
+
description: "True when this revision is mirrored to disk and so survives a lost connection. Absent when this connection has no workspace directory configured."
|
|
5412
5922
|
}
|
|
5413
5923
|
},
|
|
5414
5924
|
required: [
|
|
@@ -5422,6 +5932,26 @@ var workspaceSchema = {
|
|
|
5422
5932
|
],
|
|
5423
5933
|
additionalProperties: false
|
|
5424
5934
|
};
|
|
5935
|
+
var persistenceSchema = {
|
|
5936
|
+
type: "object",
|
|
5937
|
+
description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
|
|
5938
|
+
properties: {
|
|
5939
|
+
root: { type: "string" },
|
|
5940
|
+
maxWorkspaces: { type: "integer" },
|
|
5941
|
+
maxRevisionsPerWorkspace: {
|
|
5942
|
+
type: "integer",
|
|
5943
|
+
description: "Revision files kept per handle: the head plus its pins."
|
|
5944
|
+
},
|
|
5945
|
+
maxEntryBytes: { type: "integer" }
|
|
5946
|
+
},
|
|
5947
|
+
required: [
|
|
5948
|
+
"root",
|
|
5949
|
+
"maxWorkspaces",
|
|
5950
|
+
"maxRevisionsPerWorkspace",
|
|
5951
|
+
"maxEntryBytes"
|
|
5952
|
+
],
|
|
5953
|
+
additionalProperties: false
|
|
5954
|
+
};
|
|
5425
5955
|
var limitsSchema = {
|
|
5426
5956
|
type: "object",
|
|
5427
5957
|
description: "What this connection allows. Bounded on purpose; see the codes above.",
|
|
@@ -5453,7 +5983,9 @@ function isMemoryStore(store) {
|
|
|
5453
5983
|
}
|
|
5454
5984
|
function ensureStore(deps) {
|
|
5455
5985
|
if (deps.workspaces().available || hasWorkspaceStore()) return;
|
|
5456
|
-
const owned = createMemoryWorkspaceStore(
|
|
5986
|
+
const owned = createMemoryWorkspaceStore(
|
|
5987
|
+
deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
|
|
5988
|
+
);
|
|
5457
5989
|
deps.workspaces = () => owned;
|
|
5458
5990
|
}
|
|
5459
5991
|
function register8(server, deps) {
|
|
@@ -5496,9 +6028,9 @@ function register8(server, deps) {
|
|
|
5496
6028
|
...args.title !== void 0 && { title: args.title }
|
|
5497
6029
|
});
|
|
5498
6030
|
if (!created.ok) return created;
|
|
5499
|
-
return success(
|
|
5500
|
-
|
|
5501
|
-
seeded ? [
|
|
6031
|
+
return success({ workspace: created.record }, [
|
|
6032
|
+
...created.warnings ?? [],
|
|
6033
|
+
...seeded ? [
|
|
5502
6034
|
diagnostic(
|
|
5503
6035
|
"W_BLANK_DOCUMENT",
|
|
5504
6036
|
`Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
|
|
@@ -5508,7 +6040,7 @@ function register8(server, deps) {
|
|
|
5508
6040
|
}
|
|
5509
6041
|
)
|
|
5510
6042
|
] : []
|
|
5511
|
-
);
|
|
6043
|
+
]);
|
|
5512
6044
|
})
|
|
5513
6045
|
)
|
|
5514
6046
|
);
|
|
@@ -5574,17 +6106,20 @@ function register8(server, deps) {
|
|
|
5574
6106
|
const missingPaths = projecting ? args.paths.filter(
|
|
5575
6107
|
(pointer) => !(pointer in projection)
|
|
5576
6108
|
) : [];
|
|
5577
|
-
const diagnostics =
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
6109
|
+
const diagnostics = [
|
|
6110
|
+
...read.warnings ?? [],
|
|
6111
|
+
...missingPaths.map(
|
|
6112
|
+
(pointer) => diagnostic(
|
|
6113
|
+
"W_PATH_NOT_FOUND",
|
|
6114
|
+
`${pointer} does not resolve in revision ${read.record.revision}.`,
|
|
6115
|
+
{
|
|
6116
|
+
severity: "warning",
|
|
6117
|
+
path: pointer,
|
|
6118
|
+
suggestion: "Read a shorter prefix of the pointer to see what is actually there."
|
|
6119
|
+
}
|
|
6120
|
+
)
|
|
5586
6121
|
)
|
|
5587
|
-
|
|
6122
|
+
];
|
|
5588
6123
|
return success(
|
|
5589
6124
|
{
|
|
5590
6125
|
workspace: read.record,
|
|
@@ -5653,7 +6188,7 @@ function register8(server, deps) {
|
|
|
5653
6188
|
}
|
|
5654
6189
|
});
|
|
5655
6190
|
if (!patched.ok) return patched;
|
|
5656
|
-
return success({ workspace: patched.record });
|
|
6191
|
+
return success({ workspace: patched.record }, patched.warnings ?? []);
|
|
5657
6192
|
})
|
|
5658
6193
|
)
|
|
5659
6194
|
);
|
|
@@ -5699,7 +6234,7 @@ function register8(server, deps) {
|
|
|
5699
6234
|
await guarded(async () => {
|
|
5700
6235
|
const snapshot = await deps.workspaces().snapshot(args.handle);
|
|
5701
6236
|
if (!snapshot.ok) return snapshot;
|
|
5702
|
-
const diagnostics = [];
|
|
6237
|
+
const diagnostics = [...snapshot.warnings ?? []];
|
|
5703
6238
|
if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
|
|
5704
6239
|
diagnostics.push(
|
|
5705
6240
|
diagnostic(
|
|
@@ -5742,7 +6277,7 @@ function register8(server, deps) {
|
|
|
5742
6277
|
"jto_workspace_list",
|
|
5743
6278
|
{
|
|
5744
6279
|
title: "List open workspaces",
|
|
5745
|
-
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.",
|
|
6280
|
+
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.",
|
|
5746
6281
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
5747
6282
|
inputSchema: S({
|
|
5748
6283
|
type: "object",
|
|
@@ -5757,6 +6292,7 @@ function register8(server, deps) {
|
|
|
5757
6292
|
description: "False when this connection has workspaces switched off."
|
|
5758
6293
|
},
|
|
5759
6294
|
limits: limitsSchema,
|
|
6295
|
+
persistence: persistenceSchema,
|
|
5760
6296
|
usage: {
|
|
5761
6297
|
type: "object",
|
|
5762
6298
|
properties: {
|
|
@@ -5774,12 +6310,24 @@ function register8(server, deps) {
|
|
|
5774
6310
|
const store = deps.workspaces();
|
|
5775
6311
|
const listed = await store.list();
|
|
5776
6312
|
if (!listed.ok) return listed;
|
|
5777
|
-
const budget = isMemoryStore(store) ? {
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
6313
|
+
const budget = isMemoryStore(store) ? {
|
|
6314
|
+
limits: store.limits,
|
|
6315
|
+
usage: store.usage(),
|
|
6316
|
+
...store.persistence && {
|
|
6317
|
+
persistence: {
|
|
6318
|
+
root: store.persistence.root,
|
|
6319
|
+
...store.persistence.limits
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6322
|
+
} : {};
|
|
6323
|
+
return success(
|
|
6324
|
+
{
|
|
6325
|
+
workspaces: listed.records,
|
|
6326
|
+
available: store.available,
|
|
6327
|
+
...budget
|
|
6328
|
+
},
|
|
6329
|
+
listed.warnings ?? []
|
|
6330
|
+
);
|
|
5783
6331
|
})
|
|
5784
6332
|
)
|
|
5785
6333
|
);
|
|
@@ -5787,7 +6335,7 @@ function register8(server, deps) {
|
|
|
5787
6335
|
"jto_workspace_close",
|
|
5788
6336
|
{
|
|
5789
6337
|
title: "Close a workspace",
|
|
5790
|
-
description: "Release a handle and
|
|
6338
|
+
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.",
|
|
5791
6339
|
annotations: {
|
|
5792
6340
|
readOnlyHint: false,
|
|
5793
6341
|
destructiveHint: true,
|
|
@@ -6004,6 +6552,12 @@ function createServerFactory(deps) {
|
|
|
6004
6552
|
|
|
6005
6553
|
// src/lib/deps.ts
|
|
6006
6554
|
function createToolDeps(options = {}) {
|
|
6555
|
+
const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
|
|
6556
|
+
...options.workspaceDir !== void 0 && {
|
|
6557
|
+
flagDir: options.workspaceDir
|
|
6558
|
+
},
|
|
6559
|
+
...options.env !== void 0 && { env: options.env }
|
|
6560
|
+
});
|
|
6007
6561
|
return {
|
|
6008
6562
|
serverVersion: options.serverVersion ?? SERVER_VERSION,
|
|
6009
6563
|
outputRoot: options.outputRoot ?? createOutputRoot({
|
|
@@ -6012,10 +6566,13 @@ function createToolDeps(options = {}) {
|
|
|
6012
6566
|
}),
|
|
6013
6567
|
getAdapter: options.getAdapter ?? getAdapter,
|
|
6014
6568
|
workspaces: options.workspaces ?? getWorkspaceStore,
|
|
6569
|
+
...workspacePersistence !== void 0 && { workspacePersistence },
|
|
6015
6570
|
maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
|
|
6016
6571
|
};
|
|
6017
6572
|
}
|
|
6018
6573
|
export {
|
|
6574
|
+
DEFAULT_PERSISTENCE_LIMITS,
|
|
6575
|
+
DEFAULT_WORKSPACE_LIMITS,
|
|
6019
6576
|
DOCUMENT_SOURCE_RULE,
|
|
6020
6577
|
ERROR_CODES,
|
|
6021
6578
|
FORMAT_NAMES,
|
|
@@ -6028,15 +6585,20 @@ export {
|
|
|
6028
6585
|
SERVER_INSTRUCTIONS,
|
|
6029
6586
|
SERVER_NAME,
|
|
6030
6587
|
SERVER_VERSION,
|
|
6588
|
+
WORKSPACE_DIR_ENV,
|
|
6589
|
+
WORKSPACE_ERROR_CODES,
|
|
6031
6590
|
artifactOutputProperties,
|
|
6032
6591
|
artifactSchema,
|
|
6033
6592
|
checkOutputName,
|
|
6034
6593
|
checkRenderer,
|
|
6035
6594
|
countDiagnostics,
|
|
6595
|
+
createMemoryWorkspaceStore,
|
|
6036
6596
|
createOutputRoot,
|
|
6037
6597
|
createServer,
|
|
6038
6598
|
createServerFactory,
|
|
6039
6599
|
createToolDeps,
|
|
6600
|
+
createWorkspacePersistence,
|
|
6601
|
+
createWorkspacePersistenceAt,
|
|
6040
6602
|
deliverArtifact,
|
|
6041
6603
|
diagnostic,
|
|
6042
6604
|
diagnosticSchema,
|