@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/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.1" : "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,345 @@ 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
|
+
let meta = await readMeta(handle);
|
|
5269
|
+
if (!meta) return void 0;
|
|
5270
|
+
let head = await readRevision(handle, meta.revision);
|
|
5271
|
+
if (!head) {
|
|
5272
|
+
const second = await readMeta(handle);
|
|
5273
|
+
if (second) {
|
|
5274
|
+
meta = second;
|
|
5275
|
+
head = await readRevision(handle, second.revision);
|
|
5276
|
+
}
|
|
5277
|
+
}
|
|
5278
|
+
if (!head) {
|
|
5279
|
+
await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
|
|
5280
|
+
return void 0;
|
|
5281
|
+
}
|
|
5282
|
+
const pins = [];
|
|
5283
|
+
for (const revision of meta.pinnedRevisions) {
|
|
5284
|
+
const pin = await readRevision(handle, revision);
|
|
5285
|
+
if (pin) pins.push(pin);
|
|
5286
|
+
}
|
|
5287
|
+
return {
|
|
5288
|
+
...meta,
|
|
5289
|
+
// A pin whose file went missing is dropped rather than advertised: the
|
|
5290
|
+
// record has to describe what can actually be read back.
|
|
5291
|
+
pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
|
|
5292
|
+
head,
|
|
5293
|
+
pins
|
|
5294
|
+
};
|
|
5295
|
+
},
|
|
5296
|
+
async remove(handle) {
|
|
5297
|
+
if (!isPersistableHandle(handle)) return false;
|
|
5298
|
+
const dir = dirFor(handle);
|
|
5299
|
+
if (!await exists(dir)) {
|
|
5300
|
+
authored.delete(handle);
|
|
5301
|
+
return false;
|
|
5302
|
+
}
|
|
5303
|
+
await fs7.rm(dir, { recursive: true, force: true });
|
|
5304
|
+
authored.delete(handle);
|
|
5305
|
+
return true;
|
|
5306
|
+
}
|
|
5307
|
+
};
|
|
5308
|
+
}
|
|
5309
|
+
async function exists(target) {
|
|
5310
|
+
try {
|
|
5311
|
+
await fs7.stat(target);
|
|
5312
|
+
return true;
|
|
5313
|
+
} catch {
|
|
5314
|
+
return false;
|
|
5315
|
+
}
|
|
5316
|
+
}
|
|
5317
|
+
async function isStale(target) {
|
|
5318
|
+
try {
|
|
5319
|
+
const { mtimeMs } = await fs7.stat(target);
|
|
5320
|
+
return Date.now() - mtimeMs > STALE_TEMP_MS;
|
|
5321
|
+
} catch {
|
|
5322
|
+
return false;
|
|
5323
|
+
}
|
|
5324
|
+
}
|
|
5325
|
+
function revisionOf(name) {
|
|
5326
|
+
if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
|
|
5327
|
+
return void 0;
|
|
5328
|
+
}
|
|
5329
|
+
const digits = name.slice(
|
|
5330
|
+
REVISION_PREFIX.length,
|
|
5331
|
+
name.length - REVISION_SUFFIX.length
|
|
5332
|
+
);
|
|
5333
|
+
if (!/^[0-9]+$/.test(digits)) return void 0;
|
|
5334
|
+
return Number(digits);
|
|
5335
|
+
}
|
|
5336
|
+
function parseMeta(raw, handle) {
|
|
5337
|
+
let parsed;
|
|
5338
|
+
try {
|
|
5339
|
+
parsed = JSON.parse(raw);
|
|
5340
|
+
} catch {
|
|
5341
|
+
return void 0;
|
|
5342
|
+
}
|
|
5343
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5344
|
+
return void 0;
|
|
5345
|
+
}
|
|
5346
|
+
const meta = parsed;
|
|
5347
|
+
if (meta.schema !== SCHEMA_VERSION) return void 0;
|
|
5348
|
+
if (meta.handle !== handle) return void 0;
|
|
5349
|
+
if (!FORMAT_NAMES.includes(meta.format)) return void 0;
|
|
5350
|
+
if (!isPositiveInteger(meta.revision)) return void 0;
|
|
5351
|
+
if (!isNonNegativeInteger(meta.bytes)) return void 0;
|
|
5352
|
+
if (!isNonNegativeInteger(meta.createdAt)) return void 0;
|
|
5353
|
+
if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
|
|
5354
|
+
if (meta.title !== void 0 && typeof meta.title !== "string")
|
|
5355
|
+
return void 0;
|
|
5356
|
+
const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
|
|
5357
|
+
return {
|
|
5358
|
+
handle,
|
|
5359
|
+
format: meta.format,
|
|
5360
|
+
revision: meta.revision,
|
|
5361
|
+
bytes: meta.bytes,
|
|
5362
|
+
createdAt: meta.createdAt,
|
|
5363
|
+
updatedAt: meta.updatedAt,
|
|
5364
|
+
...typeof meta.title === "string" && { title: meta.title },
|
|
5365
|
+
pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
|
|
5366
|
+
};
|
|
5367
|
+
}
|
|
5368
|
+
function isPositiveInteger(value) {
|
|
5369
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
5370
|
+
}
|
|
5371
|
+
function isNonNegativeInteger(value) {
|
|
5372
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
5373
|
+
}
|
|
5374
|
+
|
|
5375
|
+
// src/workspace/store.ts
|
|
5043
5376
|
var WORKSPACE_ERROR_CODES = {
|
|
5044
5377
|
EVICTED: "E_WORKSPACE_EVICTED",
|
|
5045
5378
|
LIMIT: "E_WORKSPACE_LIMIT",
|
|
5046
5379
|
DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
|
|
5047
|
-
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
|
|
5380
|
+
INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
|
|
5381
|
+
/**
|
|
5382
|
+
* A revision could not be mirrored to disk (#290). A warning, never a
|
|
5383
|
+
* failure: the edit landed and the workspace is usable, but this connection
|
|
5384
|
+
* has stopped being survivable and saying so is the whole point.
|
|
5385
|
+
*/
|
|
5386
|
+
NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
|
|
5387
|
+
/**
|
|
5388
|
+
* A close could not delete the durable copy, so nothing was released (#290).
|
|
5389
|
+
* An error rather than a warning: the agent asked for the document to be
|
|
5390
|
+
* destroyed, and it is still there to be read by the next connection.
|
|
5391
|
+
*/
|
|
5392
|
+
NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
|
|
5048
5393
|
};
|
|
5049
5394
|
var DEFAULT_WORKSPACE_LIMITS = {
|
|
5050
5395
|
maxWorkspaces: 16,
|
|
@@ -5064,6 +5409,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5064
5409
|
};
|
|
5065
5410
|
const now = options.now ?? Date.now;
|
|
5066
5411
|
const newHandle = options.newHandle ?? defaultHandle;
|
|
5412
|
+
const persistence = options.persistence;
|
|
5067
5413
|
const entries = /* @__PURE__ */ new Map();
|
|
5068
5414
|
const tombstones = /* @__PURE__ */ new Map();
|
|
5069
5415
|
function footprint(entry) {
|
|
@@ -5076,12 +5422,8 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5076
5422
|
for (const entry of entries.values()) total += footprint(entry);
|
|
5077
5423
|
return total;
|
|
5078
5424
|
}
|
|
5079
|
-
function tombstone(
|
|
5080
|
-
tombstones.set(
|
|
5081
|
-
reason,
|
|
5082
|
-
at: now(),
|
|
5083
|
-
revision: entry.revision
|
|
5084
|
-
});
|
|
5425
|
+
function tombstone(handle, reason, revision) {
|
|
5426
|
+
tombstones.set(handle, { reason, at: now(), revision });
|
|
5085
5427
|
while (tombstones.size > MAX_TOMBSTONES) {
|
|
5086
5428
|
const oldest = tombstones.keys().next();
|
|
5087
5429
|
if (oldest.done) break;
|
|
@@ -5091,7 +5433,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5091
5433
|
function evict(entry, reason) {
|
|
5092
5434
|
entries.delete(entry.handle);
|
|
5093
5435
|
tombstones.delete(entry.handle);
|
|
5094
|
-
tombstone(entry, reason);
|
|
5436
|
+
tombstone(entry.handle, reason, entry.revision);
|
|
5095
5437
|
}
|
|
5096
5438
|
function sweep() {
|
|
5097
5439
|
const at = now();
|
|
@@ -5099,6 +5441,130 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5099
5441
|
if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
|
|
5100
5442
|
}
|
|
5101
5443
|
}
|
|
5444
|
+
let durable = Promise.resolve();
|
|
5445
|
+
function queued(work) {
|
|
5446
|
+
const running = durable.then(work);
|
|
5447
|
+
durable = running.catch(() => void 0);
|
|
5448
|
+
return running;
|
|
5449
|
+
}
|
|
5450
|
+
async function persist(entry) {
|
|
5451
|
+
if (!persistence) return void 0;
|
|
5452
|
+
try {
|
|
5453
|
+
const written = await queued(async () => {
|
|
5454
|
+
if (entries.get(entry.handle) !== entry) return false;
|
|
5455
|
+
await saveEntry(entry);
|
|
5456
|
+
return true;
|
|
5457
|
+
});
|
|
5458
|
+
if (!written) {
|
|
5459
|
+
entry.persisted = false;
|
|
5460
|
+
return void 0;
|
|
5461
|
+
}
|
|
5462
|
+
entry.persisted = true;
|
|
5463
|
+
return void 0;
|
|
5464
|
+
} catch (error) {
|
|
5465
|
+
entry.persisted = false;
|
|
5466
|
+
return notPersisted(entry, error);
|
|
5467
|
+
}
|
|
5468
|
+
}
|
|
5469
|
+
async function saveEntry(entry) {
|
|
5470
|
+
if (!persistence) return;
|
|
5471
|
+
await persistence.save({
|
|
5472
|
+
handle: entry.handle,
|
|
5473
|
+
format: entry.format,
|
|
5474
|
+
createdAt: entry.createdAt,
|
|
5475
|
+
updatedAt: entry.updatedAt,
|
|
5476
|
+
...entry.title !== void 0 && { title: entry.title },
|
|
5477
|
+
head: {
|
|
5478
|
+
revision: entry.revision,
|
|
5479
|
+
text: entry.text,
|
|
5480
|
+
bytes: entry.bytes
|
|
5481
|
+
},
|
|
5482
|
+
pins: [...entry.pins.entries()].map(([revision, pin]) => ({
|
|
5483
|
+
revision,
|
|
5484
|
+
text: pin.text,
|
|
5485
|
+
bytes: pin.bytes
|
|
5486
|
+
}))
|
|
5487
|
+
});
|
|
5488
|
+
}
|
|
5489
|
+
function notPersisted(entry, error) {
|
|
5490
|
+
const root = persistence?.root ?? "";
|
|
5491
|
+
return diagnostic(
|
|
5492
|
+
WORKSPACE_ERROR_CODES.NOT_PERSISTED,
|
|
5493
|
+
`Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
5494
|
+
{
|
|
5495
|
+
severity: "warning",
|
|
5496
|
+
suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
|
|
5497
|
+
context: {
|
|
5498
|
+
handle: entry.handle,
|
|
5499
|
+
revision: entry.revision,
|
|
5500
|
+
workspaceRoot: root
|
|
5501
|
+
}
|
|
5502
|
+
}
|
|
5503
|
+
);
|
|
5504
|
+
}
|
|
5505
|
+
function committed(record, warning) {
|
|
5506
|
+
return { ok: true, record, ...warning && { warnings: [warning] } };
|
|
5507
|
+
}
|
|
5508
|
+
async function rehydrate(handle) {
|
|
5509
|
+
if (!persistence || !isPersistableHandle(handle)) return void 0;
|
|
5510
|
+
let loaded;
|
|
5511
|
+
try {
|
|
5512
|
+
loaded = await persistence.load(handle);
|
|
5513
|
+
} catch {
|
|
5514
|
+
return void 0;
|
|
5515
|
+
}
|
|
5516
|
+
if (!loaded) return void 0;
|
|
5517
|
+
const restored = loaded;
|
|
5518
|
+
const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
|
|
5519
|
+
const needed = pins.reduce(
|
|
5520
|
+
(total, pin) => total + pin.bytes,
|
|
5521
|
+
restored.head.bytes
|
|
5522
|
+
);
|
|
5523
|
+
if (restored.head.bytes > limits.maxDocumentBytes) {
|
|
5524
|
+
return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
|
|
5525
|
+
}
|
|
5526
|
+
if (!hasRoom(needed, true)) {
|
|
5527
|
+
return failure(
|
|
5528
|
+
WORKSPACE_ERROR_CODES.LIMIT,
|
|
5529
|
+
`Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
|
|
5530
|
+
{
|
|
5531
|
+
suggestion: "Close a workspace you have finished with, then use this handle again.",
|
|
5532
|
+
context: {
|
|
5533
|
+
handle,
|
|
5534
|
+
bytes: needed,
|
|
5535
|
+
maxTotalBytes: limits.maxTotalBytes,
|
|
5536
|
+
maxWorkspaces: limits.maxWorkspaces
|
|
5537
|
+
}
|
|
5538
|
+
}
|
|
5539
|
+
);
|
|
5540
|
+
}
|
|
5541
|
+
const entry = {
|
|
5542
|
+
handle,
|
|
5543
|
+
format: restored.format,
|
|
5544
|
+
revision: restored.head.revision,
|
|
5545
|
+
text: restored.head.text,
|
|
5546
|
+
bytes: restored.head.bytes,
|
|
5547
|
+
createdAt: restored.createdAt,
|
|
5548
|
+
updatedAt: restored.updatedAt,
|
|
5549
|
+
touchedAt: now(),
|
|
5550
|
+
...restored.title !== void 0 && { title: restored.title },
|
|
5551
|
+
pins: new Map(
|
|
5552
|
+
pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
|
|
5553
|
+
),
|
|
5554
|
+
persisted: true
|
|
5555
|
+
};
|
|
5556
|
+
entries.set(handle, entry);
|
|
5557
|
+
tombstones.delete(handle);
|
|
5558
|
+
return { ok: true, entry };
|
|
5559
|
+
}
|
|
5560
|
+
async function resolve3(handle) {
|
|
5561
|
+
const entry = entries.get(handle);
|
|
5562
|
+
if (entry) return { entry };
|
|
5563
|
+
const restored = await rehydrate(handle);
|
|
5564
|
+
if (restored === void 0) return missing(handle);
|
|
5565
|
+
if (!restored.ok) return restored;
|
|
5566
|
+
return { entry: restored.entry };
|
|
5567
|
+
}
|
|
5102
5568
|
function missing(handle) {
|
|
5103
5569
|
const grave = tombstones.get(handle);
|
|
5104
5570
|
if (grave?.reason === "ttl") {
|
|
@@ -5134,12 +5600,29 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5134
5600
|
createdAt: new Date(entry.createdAt).toISOString(),
|
|
5135
5601
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
5136
5602
|
...entry.title !== void 0 && { title: entry.title },
|
|
5137
|
-
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
|
|
5603
|
+
pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
|
|
5604
|
+
...persistence && { persisted: entry.persisted }
|
|
5605
|
+
};
|
|
5606
|
+
}
|
|
5607
|
+
function fromMeta(meta) {
|
|
5608
|
+
return {
|
|
5609
|
+
handle: meta.handle,
|
|
5610
|
+
format: meta.format,
|
|
5611
|
+
revision: meta.revision,
|
|
5612
|
+
bytes: meta.bytes,
|
|
5613
|
+
createdAt: new Date(meta.createdAt).toISOString(),
|
|
5614
|
+
updatedAt: new Date(meta.updatedAt).toISOString(),
|
|
5615
|
+
...meta.title !== void 0 && { title: meta.title },
|
|
5616
|
+
pinnedRevisions: [...meta.pinnedRevisions],
|
|
5617
|
+
persisted: true
|
|
5138
5618
|
};
|
|
5139
5619
|
}
|
|
5140
5620
|
return {
|
|
5141
5621
|
available: true,
|
|
5142
5622
|
limits,
|
|
5623
|
+
...persistence && {
|
|
5624
|
+
persistence: { root: persistence.root, limits: persistence.limits }
|
|
5625
|
+
},
|
|
5143
5626
|
usage() {
|
|
5144
5627
|
sweep();
|
|
5145
5628
|
return { workspaces: entries.size, bytes: totalBytes() };
|
|
@@ -5180,15 +5663,18 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5180
5663
|
updatedAt: at,
|
|
5181
5664
|
touchedAt: at,
|
|
5182
5665
|
...input.title !== void 0 && { title: input.title },
|
|
5183
|
-
pins: /* @__PURE__ */ new Map()
|
|
5666
|
+
pins: /* @__PURE__ */ new Map(),
|
|
5667
|
+
persisted: false
|
|
5184
5668
|
};
|
|
5185
5669
|
entries.set(entry.handle, entry);
|
|
5186
|
-
|
|
5670
|
+
const warning = await persist(entry);
|
|
5671
|
+
return committed(toRecord(entry), warning);
|
|
5187
5672
|
},
|
|
5188
5673
|
async get(handle, readOptions) {
|
|
5189
5674
|
sweep();
|
|
5190
|
-
const
|
|
5191
|
-
if (!entry) return
|
|
5675
|
+
const found = await resolve3(handle);
|
|
5676
|
+
if (!("entry" in found)) return found;
|
|
5677
|
+
const entry = found.entry;
|
|
5192
5678
|
entry.touchedAt = now();
|
|
5193
5679
|
let text = entry.text;
|
|
5194
5680
|
let bytes = entry.bytes;
|
|
@@ -5213,15 +5699,16 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5213
5699
|
context: { handle, pointer }
|
|
5214
5700
|
});
|
|
5215
5701
|
}
|
|
5216
|
-
const
|
|
5217
|
-
if (
|
|
5702
|
+
const found2 = resolvePointer(document, parsed.tokens);
|
|
5703
|
+
if (found2.found) projection[pointer] = found2.value;
|
|
5218
5704
|
}
|
|
5219
5705
|
return { ok: true, record, document, projection };
|
|
5220
5706
|
},
|
|
5221
5707
|
async patch(input) {
|
|
5222
5708
|
sweep();
|
|
5223
|
-
const
|
|
5224
|
-
if (!entry) return
|
|
5709
|
+
const found = await resolve3(input.handle);
|
|
5710
|
+
if (!("entry" in found)) return found;
|
|
5711
|
+
const entry = found.entry;
|
|
5225
5712
|
entry.touchedAt = now();
|
|
5226
5713
|
if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
|
|
5227
5714
|
return stale(
|
|
@@ -5287,12 +5774,14 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5287
5774
|
entry.revision += 1;
|
|
5288
5775
|
entry.updatedAt = now();
|
|
5289
5776
|
entry.touchedAt = entry.updatedAt;
|
|
5290
|
-
|
|
5777
|
+
const warning = await persist(entry);
|
|
5778
|
+
return committed(toRecord(entry), warning);
|
|
5291
5779
|
},
|
|
5292
5780
|
async snapshot(handle) {
|
|
5293
5781
|
sweep();
|
|
5294
|
-
const
|
|
5295
|
-
if (!entry) return
|
|
5782
|
+
const found = await resolve3(handle);
|
|
5783
|
+
if (!("entry" in found)) return found;
|
|
5784
|
+
const entry = found.entry;
|
|
5296
5785
|
entry.touchedAt = now();
|
|
5297
5786
|
const revision = entry.revision;
|
|
5298
5787
|
const document = JSON.parse(entry.text);
|
|
@@ -5301,24 +5790,79 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5301
5790
|
entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
|
|
5302
5791
|
}
|
|
5303
5792
|
}
|
|
5304
|
-
|
|
5305
|
-
},
|
|
5306
|
-
async list() {
|
|
5307
|
-
sweep();
|
|
5793
|
+
const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
|
|
5308
5794
|
return {
|
|
5309
5795
|
ok: true,
|
|
5310
|
-
|
|
5796
|
+
record: toRecord(entry),
|
|
5797
|
+
document,
|
|
5798
|
+
...warning && { warnings: [warning] }
|
|
5311
5799
|
};
|
|
5312
5800
|
},
|
|
5801
|
+
async list() {
|
|
5802
|
+
sweep();
|
|
5803
|
+
const records = [...entries.values()].map((entry) => toRecord(entry));
|
|
5804
|
+
if (!persistence) return { ok: true, records };
|
|
5805
|
+
const resident = new Set(records.map((record) => record.handle));
|
|
5806
|
+
let stored = [];
|
|
5807
|
+
try {
|
|
5808
|
+
stored = await persistence.list();
|
|
5809
|
+
} catch {
|
|
5810
|
+
}
|
|
5811
|
+
for (const meta of stored) {
|
|
5812
|
+
if (resident.has(meta.handle)) continue;
|
|
5813
|
+
if (tombstones.get(meta.handle)?.reason === "closed") continue;
|
|
5814
|
+
records.push(fromMeta(meta));
|
|
5815
|
+
}
|
|
5816
|
+
return { ok: true, records };
|
|
5817
|
+
},
|
|
5313
5818
|
async close(handle) {
|
|
5314
5819
|
sweep();
|
|
5315
5820
|
const entry = entries.get(handle);
|
|
5316
|
-
if (!
|
|
5317
|
-
|
|
5318
|
-
|
|
5821
|
+
if (!persistence) {
|
|
5822
|
+
if (entry) evict(entry, "closed");
|
|
5823
|
+
return { ok: true, handle, closed: entry !== void 0 };
|
|
5824
|
+
}
|
|
5825
|
+
const root = persistence.root;
|
|
5826
|
+
try {
|
|
5827
|
+
return await queued(async () => {
|
|
5828
|
+
const removed = await persistence.remove(handle);
|
|
5829
|
+
const resident = entries.get(handle);
|
|
5830
|
+
if (resident) evict(resident, "closed");
|
|
5831
|
+
else if (removed) tombstone(handle, "closed", 0);
|
|
5832
|
+
return {
|
|
5833
|
+
ok: true,
|
|
5834
|
+
handle,
|
|
5835
|
+
closed: resident !== void 0 || removed
|
|
5836
|
+
};
|
|
5837
|
+
});
|
|
5838
|
+
} catch (error) {
|
|
5839
|
+
return failure(
|
|
5840
|
+
WORKSPACE_ERROR_CODES.NOT_CLOSED,
|
|
5841
|
+
`Workspace ${handle} could not be removed from ${root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
|
|
5842
|
+
{
|
|
5843
|
+
suggestion: "Check the workspace directory is writable, then close again.",
|
|
5844
|
+
context: { handle, workspaceRoot: root }
|
|
5845
|
+
}
|
|
5846
|
+
);
|
|
5847
|
+
}
|
|
5319
5848
|
},
|
|
5849
|
+
/**
|
|
5850
|
+
* Release memory, keep the disk.
|
|
5851
|
+
*
|
|
5852
|
+
* The asymmetry with `close` is the point: this exists for a host
|
|
5853
|
+
* reclaiming memory at a moment of its own choosing, and #290 is precisely
|
|
5854
|
+
* about an event that ends a connection not being allowed to destroy
|
|
5855
|
+
* revisions. The handles come back from disk on next use.
|
|
5856
|
+
*/
|
|
5320
5857
|
async closeAll() {
|
|
5321
|
-
for (const entry of [...entries.values()])
|
|
5858
|
+
for (const entry of [...entries.values()]) {
|
|
5859
|
+
if (!persistence) {
|
|
5860
|
+
evict(entry, "closed");
|
|
5861
|
+
continue;
|
|
5862
|
+
}
|
|
5863
|
+
entries.delete(entry.handle);
|
|
5864
|
+
tombstones.delete(entry.handle);
|
|
5865
|
+
}
|
|
5322
5866
|
}
|
|
5323
5867
|
};
|
|
5324
5868
|
function stale(entry, wanted, pinned, mutation = false) {
|
|
@@ -5338,7 +5882,7 @@ function createMemoryWorkspaceStore(options = {}) {
|
|
|
5338
5882
|
}
|
|
5339
5883
|
}
|
|
5340
5884
|
function defaultHandle() {
|
|
5341
|
-
return `ws_${
|
|
5885
|
+
return `ws_${randomBytes3(9).toString("base64url")}`;
|
|
5342
5886
|
}
|
|
5343
5887
|
function serialize(document) {
|
|
5344
5888
|
let text;
|
|
@@ -5391,7 +5935,7 @@ var workspaceSchema = {
|
|
|
5391
5935
|
properties: {
|
|
5392
5936
|
handle: {
|
|
5393
5937
|
type: "string",
|
|
5394
|
-
description: "Opaque
|
|
5938
|
+
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
5939
|
},
|
|
5396
5940
|
format: { type: "string", enum: [...FORMAT_NAMES] },
|
|
5397
5941
|
revision: {
|
|
@@ -5409,6 +5953,10 @@ var workspaceSchema = {
|
|
|
5409
5953
|
type: "array",
|
|
5410
5954
|
items: { type: "integer" },
|
|
5411
5955
|
description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
|
|
5956
|
+
},
|
|
5957
|
+
persisted: {
|
|
5958
|
+
type: "boolean",
|
|
5959
|
+
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
5960
|
}
|
|
5413
5961
|
},
|
|
5414
5962
|
required: [
|
|
@@ -5422,6 +5970,26 @@ var workspaceSchema = {
|
|
|
5422
5970
|
],
|
|
5423
5971
|
additionalProperties: false
|
|
5424
5972
|
};
|
|
5973
|
+
var persistenceSchema = {
|
|
5974
|
+
type: "object",
|
|
5975
|
+
description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
|
|
5976
|
+
properties: {
|
|
5977
|
+
root: { type: "string" },
|
|
5978
|
+
maxWorkspaces: { type: "integer" },
|
|
5979
|
+
maxRevisionsPerWorkspace: {
|
|
5980
|
+
type: "integer",
|
|
5981
|
+
description: "Revision files kept per handle: the head plus its pins."
|
|
5982
|
+
},
|
|
5983
|
+
maxEntryBytes: { type: "integer" }
|
|
5984
|
+
},
|
|
5985
|
+
required: [
|
|
5986
|
+
"root",
|
|
5987
|
+
"maxWorkspaces",
|
|
5988
|
+
"maxRevisionsPerWorkspace",
|
|
5989
|
+
"maxEntryBytes"
|
|
5990
|
+
],
|
|
5991
|
+
additionalProperties: false
|
|
5992
|
+
};
|
|
5425
5993
|
var limitsSchema = {
|
|
5426
5994
|
type: "object",
|
|
5427
5995
|
description: "What this connection allows. Bounded on purpose; see the codes above.",
|
|
@@ -5453,7 +6021,9 @@ function isMemoryStore(store) {
|
|
|
5453
6021
|
}
|
|
5454
6022
|
function ensureStore(deps) {
|
|
5455
6023
|
if (deps.workspaces().available || hasWorkspaceStore()) return;
|
|
5456
|
-
const owned = createMemoryWorkspaceStore(
|
|
6024
|
+
const owned = createMemoryWorkspaceStore(
|
|
6025
|
+
deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
|
|
6026
|
+
);
|
|
5457
6027
|
deps.workspaces = () => owned;
|
|
5458
6028
|
}
|
|
5459
6029
|
function register8(server, deps) {
|
|
@@ -5496,9 +6066,9 @@ function register8(server, deps) {
|
|
|
5496
6066
|
...args.title !== void 0 && { title: args.title }
|
|
5497
6067
|
});
|
|
5498
6068
|
if (!created.ok) return created;
|
|
5499
|
-
return success(
|
|
5500
|
-
|
|
5501
|
-
seeded ? [
|
|
6069
|
+
return success({ workspace: created.record }, [
|
|
6070
|
+
...created.warnings ?? [],
|
|
6071
|
+
...seeded ? [
|
|
5502
6072
|
diagnostic(
|
|
5503
6073
|
"W_BLANK_DOCUMENT",
|
|
5504
6074
|
`Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
|
|
@@ -5508,7 +6078,7 @@ function register8(server, deps) {
|
|
|
5508
6078
|
}
|
|
5509
6079
|
)
|
|
5510
6080
|
] : []
|
|
5511
|
-
);
|
|
6081
|
+
]);
|
|
5512
6082
|
})
|
|
5513
6083
|
)
|
|
5514
6084
|
);
|
|
@@ -5574,17 +6144,20 @@ function register8(server, deps) {
|
|
|
5574
6144
|
const missingPaths = projecting ? args.paths.filter(
|
|
5575
6145
|
(pointer) => !(pointer in projection)
|
|
5576
6146
|
) : [];
|
|
5577
|
-
const diagnostics =
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
6147
|
+
const diagnostics = [
|
|
6148
|
+
...read.warnings ?? [],
|
|
6149
|
+
...missingPaths.map(
|
|
6150
|
+
(pointer) => diagnostic(
|
|
6151
|
+
"W_PATH_NOT_FOUND",
|
|
6152
|
+
`${pointer} does not resolve in revision ${read.record.revision}.`,
|
|
6153
|
+
{
|
|
6154
|
+
severity: "warning",
|
|
6155
|
+
path: pointer,
|
|
6156
|
+
suggestion: "Read a shorter prefix of the pointer to see what is actually there."
|
|
6157
|
+
}
|
|
6158
|
+
)
|
|
5586
6159
|
)
|
|
5587
|
-
|
|
6160
|
+
];
|
|
5588
6161
|
return success(
|
|
5589
6162
|
{
|
|
5590
6163
|
workspace: read.record,
|
|
@@ -5653,7 +6226,7 @@ function register8(server, deps) {
|
|
|
5653
6226
|
}
|
|
5654
6227
|
});
|
|
5655
6228
|
if (!patched.ok) return patched;
|
|
5656
|
-
return success({ workspace: patched.record });
|
|
6229
|
+
return success({ workspace: patched.record }, patched.warnings ?? []);
|
|
5657
6230
|
})
|
|
5658
6231
|
)
|
|
5659
6232
|
);
|
|
@@ -5699,7 +6272,7 @@ function register8(server, deps) {
|
|
|
5699
6272
|
await guarded(async () => {
|
|
5700
6273
|
const snapshot = await deps.workspaces().snapshot(args.handle);
|
|
5701
6274
|
if (!snapshot.ok) return snapshot;
|
|
5702
|
-
const diagnostics = [];
|
|
6275
|
+
const diagnostics = [...snapshot.warnings ?? []];
|
|
5703
6276
|
if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
|
|
5704
6277
|
diagnostics.push(
|
|
5705
6278
|
diagnostic(
|
|
@@ -5742,7 +6315,7 @@ function register8(server, deps) {
|
|
|
5742
6315
|
"jto_workspace_list",
|
|
5743
6316
|
{
|
|
5744
6317
|
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.",
|
|
6318
|
+
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
6319
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
5747
6320
|
inputSchema: S({
|
|
5748
6321
|
type: "object",
|
|
@@ -5757,6 +6330,7 @@ function register8(server, deps) {
|
|
|
5757
6330
|
description: "False when this connection has workspaces switched off."
|
|
5758
6331
|
},
|
|
5759
6332
|
limits: limitsSchema,
|
|
6333
|
+
persistence: persistenceSchema,
|
|
5760
6334
|
usage: {
|
|
5761
6335
|
type: "object",
|
|
5762
6336
|
properties: {
|
|
@@ -5774,12 +6348,24 @@ function register8(server, deps) {
|
|
|
5774
6348
|
const store = deps.workspaces();
|
|
5775
6349
|
const listed = await store.list();
|
|
5776
6350
|
if (!listed.ok) return listed;
|
|
5777
|
-
const budget = isMemoryStore(store) ? {
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
6351
|
+
const budget = isMemoryStore(store) ? {
|
|
6352
|
+
limits: store.limits,
|
|
6353
|
+
usage: store.usage(),
|
|
6354
|
+
...store.persistence && {
|
|
6355
|
+
persistence: {
|
|
6356
|
+
root: store.persistence.root,
|
|
6357
|
+
...store.persistence.limits
|
|
6358
|
+
}
|
|
6359
|
+
}
|
|
6360
|
+
} : {};
|
|
6361
|
+
return success(
|
|
6362
|
+
{
|
|
6363
|
+
workspaces: listed.records,
|
|
6364
|
+
available: store.available,
|
|
6365
|
+
...budget
|
|
6366
|
+
},
|
|
6367
|
+
listed.warnings ?? []
|
|
6368
|
+
);
|
|
5783
6369
|
})
|
|
5784
6370
|
)
|
|
5785
6371
|
);
|
|
@@ -5787,7 +6373,7 @@ function register8(server, deps) {
|
|
|
5787
6373
|
"jto_workspace_close",
|
|
5788
6374
|
{
|
|
5789
6375
|
title: "Close a workspace",
|
|
5790
|
-
description: "Release a handle and
|
|
6376
|
+
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
6377
|
annotations: {
|
|
5792
6378
|
readOnlyHint: false,
|
|
5793
6379
|
destructiveHint: true,
|
|
@@ -6004,6 +6590,12 @@ function createServerFactory(deps) {
|
|
|
6004
6590
|
|
|
6005
6591
|
// src/lib/deps.ts
|
|
6006
6592
|
function createToolDeps(options = {}) {
|
|
6593
|
+
const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
|
|
6594
|
+
...options.workspaceDir !== void 0 && {
|
|
6595
|
+
flagDir: options.workspaceDir
|
|
6596
|
+
},
|
|
6597
|
+
...options.env !== void 0 && { env: options.env }
|
|
6598
|
+
});
|
|
6007
6599
|
return {
|
|
6008
6600
|
serverVersion: options.serverVersion ?? SERVER_VERSION,
|
|
6009
6601
|
outputRoot: options.outputRoot ?? createOutputRoot({
|
|
@@ -6012,10 +6604,13 @@ function createToolDeps(options = {}) {
|
|
|
6012
6604
|
}),
|
|
6013
6605
|
getAdapter: options.getAdapter ?? getAdapter,
|
|
6014
6606
|
workspaces: options.workspaces ?? getWorkspaceStore,
|
|
6607
|
+
...workspacePersistence !== void 0 && { workspacePersistence },
|
|
6015
6608
|
maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
|
|
6016
6609
|
};
|
|
6017
6610
|
}
|
|
6018
6611
|
export {
|
|
6612
|
+
DEFAULT_PERSISTENCE_LIMITS,
|
|
6613
|
+
DEFAULT_WORKSPACE_LIMITS,
|
|
6019
6614
|
DOCUMENT_SOURCE_RULE,
|
|
6020
6615
|
ERROR_CODES,
|
|
6021
6616
|
FORMAT_NAMES,
|
|
@@ -6028,15 +6623,20 @@ export {
|
|
|
6028
6623
|
SERVER_INSTRUCTIONS,
|
|
6029
6624
|
SERVER_NAME,
|
|
6030
6625
|
SERVER_VERSION,
|
|
6626
|
+
WORKSPACE_DIR_ENV,
|
|
6627
|
+
WORKSPACE_ERROR_CODES,
|
|
6031
6628
|
artifactOutputProperties,
|
|
6032
6629
|
artifactSchema,
|
|
6033
6630
|
checkOutputName,
|
|
6034
6631
|
checkRenderer,
|
|
6035
6632
|
countDiagnostics,
|
|
6633
|
+
createMemoryWorkspaceStore,
|
|
6036
6634
|
createOutputRoot,
|
|
6037
6635
|
createServer,
|
|
6038
6636
|
createServerFactory,
|
|
6039
6637
|
createToolDeps,
|
|
6638
|
+
createWorkspacePersistence,
|
|
6639
|
+
createWorkspacePersistenceAt,
|
|
6040
6640
|
deliverArtifact,
|
|
6041
6641
|
diagnostic,
|
|
6042
6642
|
diagnosticSchema,
|