pi-studio 0.9.63 → 0.10.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/BUFFER-DESIGN.md +197 -0
- package/CHANGELOG.md +31 -0
- package/PRERELEASE-CHECKLIST.md +100 -0
- package/README.md +110 -193
- package/ROADMAP.md +63 -6
- package/WORKFLOW.md +32 -2
- package/assets/screenshots/dark-workspace.png +0 -0
- package/assets/screenshots/light-workspace.png +0 -0
- package/assets/screenshots/theme-zen-code.png +0 -0
- package/client/studio-client.js +6694 -942
- package/client/studio-editor-draft-helpers.js +162 -0
- package/client/studio-navigation-helpers.js +31 -5
- package/client/studio.css +460 -12
- package/index.ts +1178 -79
- package/package.json +5 -3
- package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
- package/shared/studio-buffer-client.js +663 -0
- package/shared/studio-buffer-decisions.js +129 -0
- package/shared/studio-buffer-lineage.js +48 -0
- package/shared/studio-buffer-recovery-panel.js +132 -0
- package/shared/studio-buffer-recovery.js +181 -0
- package/shared/studio-buffer-server.js +700 -0
- package/shared/studio-buffer-store.js +227 -0
- package/shared/studio-buffer-switching.js +53 -0
- package/shared/studio-disk-revisions.js +15 -0
- package/shared/studio-document-entrypoints.js +38 -0
- package/shared/studio-document-escrow.js +147 -0
- package/shared/studio-document-file-claims.js +215 -0
- package/shared/studio-document-host-client.js +268 -0
- package/shared/studio-document-hosting.js +319 -0
- package/shared/studio-document-launch-client.js +153 -0
- package/shared/studio-document-open-client.js +96 -0
- package/shared/studio-document-open-storage.js +21 -0
- package/shared/studio-document-paths.js +29 -0
- package/shared/studio-editor-mode.js +48 -0
- package/shared/studio-linked-reader-ui.js +232 -0
- package/shared/studio-linked-reader.js +110 -0
- package/shared/studio-metadata-write-order.js +35 -0
- package/shared/studio-read-only-media.js +24 -0
- package/shared/studio-resource-grants.js +3 -0
- package/shared/studio-run-lifecycle.js +47 -0
- package/shared/studio-system-theme.js +9 -0
- package/assets/screenshots/dark-code-mode.png +0 -0
- package/assets/screenshots/dark-critique.png +0 -0
- package/assets/screenshots/dark-fenced-code.png +0 -0
- package/assets/screenshots/dark-focus-mode.png +0 -0
- package/assets/screenshots/dark-julia-fenced.png +0 -0
- package/assets/screenshots/dark-math.png +0 -0
- package/assets/screenshots/dark-mermaid.png +0 -0
- package/assets/screenshots/dark-zen.png +0 -0
- package/assets/screenshots/light-code-mode.png +0 -0
- package/assets/screenshots/light-fenced-code.png +0 -0
- package/assets/screenshots/light-math.png +0 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { createStudioBuffer, STUDIO_BUFFER_LIMITS } from "./studio-buffer-store.js";
|
|
4
|
+
import { encodeStudioBufferRecovery, migrateStudioWorkspaceV1 } from "./studio-buffer-recovery.js";
|
|
5
|
+
import { createStudioDocumentHostingStore, createStudioDocumentCopy, isStudioPristineWorkspace, studioDocumentAuthorityIdentity, studioDocumentMetadataKey } from "./studio-document-hosting.js";
|
|
6
|
+
import { createStudioDocumentFileClaims } from "./studio-document-file-claims.js";
|
|
7
|
+
|
|
8
|
+
export const STUDIO_BUFFER_REQUEST_MAX_BYTES = 32_000_000;
|
|
9
|
+
const token = () => randomBytes(24).toString("hex");
|
|
10
|
+
const fail = (reason, message) => ({ ok: false, reason, message });
|
|
11
|
+
|
|
12
|
+
// Process-memory fallback only. Page capabilities bind the HTML launch mode and tab;
|
|
13
|
+
// a client-supplied body cannot promote an editor-only/watched view to a full workspace.
|
|
14
|
+
// The workspace a launch registers: the document as Prompt, beside an empty Document.
|
|
15
|
+
// Also checks, without any store, that a carried Prompt fits before a mode switch (Sol, candidate69).
|
|
16
|
+
export function buildStudioHostingBootstrapState(document, canonical = null, editorLanguage = "markdown", savedAt = Date.now()) {
|
|
17
|
+
let state;
|
|
18
|
+
try {
|
|
19
|
+
const prompt = createStudioBuffer({ id: "prompt_" + token(), role: "prompt", text: document.text,
|
|
20
|
+
baselineText: canonical ? document.text : "", diskRevision: canonical ? document.diskRevision : null,
|
|
21
|
+
sourceState: { source: document.source, label: document.label, path: canonical, ...(document.draftId && !canonical ? { draftId: document.draftId } : {}) },
|
|
22
|
+
resourceDir: document.resourceDir || "", view: { editorLanguage: editorLanguage || "markdown", followLatest: true } });
|
|
23
|
+
const placeholder = createStudioBuffer({ id: "document_" + token(), role: "document", sourceState: { label: "Document" }, resourceDir: document.resourceDir || "" });
|
|
24
|
+
state = { version: 2, workspaceId: "host_" + token(), mode: "full", revision: 1, savedAt,
|
|
25
|
+
selectedBufferId: prompt.id, activePromptId: prompt.id, order: [prompt.id, placeholder.id], buffers: [prompt, placeholder] };
|
|
26
|
+
} catch { return fail("invalid-state", "The initial document cannot be represented safely. Nothing was registered."); }
|
|
27
|
+
const encoded = encodeStudioBufferRecovery(state, state); if (!encoded.ok) return encoded;
|
|
28
|
+
return { ok: true, state, encoded };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createStudioBufferServerStore(options = {}) {
|
|
32
|
+
const maxEntries = options.maxEntries ?? 16;
|
|
33
|
+
const maxTotalTextChars = options.maxTotalTextChars ?? 12_000_000;
|
|
34
|
+
const maxCapabilities = options.maxCapabilities ?? 256;
|
|
35
|
+
const ttlMs = options.ttlMs ?? 24 * 60 * 60 * 1000;
|
|
36
|
+
const now = options.now ?? Date.now;
|
|
37
|
+
const legacyState = options.legacyState ?? (() => null);
|
|
38
|
+
const entries = new Map(), capabilities = new Map(), hostingViews = new Map(), metadataWrites = new Map(), seenHostingViews = new Set(), copyReceipts = new Map(), launchReceipts = new Map(), openReceipts = new Map();
|
|
39
|
+
// A departing owner's word that it held nothing beyond the stored checkpoint (Sol,
|
|
40
|
+
// candidate67). Single use, voided by any later activity, and consumed by the next lease.
|
|
41
|
+
const cleanLeaves = new Map(), releasedGenerations = new Map();
|
|
42
|
+
const operationIdentities = new WeakMap(); let nextOperationIdentity = 0;
|
|
43
|
+
const operationIdentity = value => { if (!operationIdentities.has(value)) operationIdentities.set(value, ++nextOperationIdentity); return operationIdentities.get(value); };
|
|
44
|
+
let nextViewGeneration = 0;
|
|
45
|
+
const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
46
|
+
const hosting = options.hosting ? createStudioDocumentHostingStore({ ...options.hosting, now,
|
|
47
|
+
makeId: options.hosting.makeId ?? token,
|
|
48
|
+
commitMove({ before, after, kind = "move", move }) {
|
|
49
|
+
// Validate the complete two-workspace transaction before touching either
|
|
50
|
+
// recovery record. No await or external I/O can split the commit.
|
|
51
|
+
const replacements = [];
|
|
52
|
+
for (let i = 0; i < before.length; i++) {
|
|
53
|
+
const old = before[i], binding = old || after[i], previous = entries.get(binding.workspaceId);
|
|
54
|
+
if (old) {
|
|
55
|
+
for (const buffer of old.buffers) if (!after[i].buffers.some(b => b.id === buffer.id)) {
|
|
56
|
+
const guarded = documentIdle(old.workspaceId, buffer.id); if (!guarded.ok) return guarded;
|
|
57
|
+
}
|
|
58
|
+
const expected = encodeStudioBufferRecovery(old, old);
|
|
59
|
+
if (!previous || !expected.ok || previous.raw !== expected.raw) return fail("conflict", "Recovery changed during the move. Both editors were kept.");
|
|
60
|
+
} else if (previous) return fail("conflict", "The new destination is already in use.");
|
|
61
|
+
const conflict = kind === "cancel" ? null : legacyConflict(binding, previous?.state || after[i]); if (conflict) return conflict;
|
|
62
|
+
const encoded = encodeStudioBufferRecovery(after[i], binding); if (!encoded.ok) return encoded;
|
|
63
|
+
const chars = encoded.state.buffers.reduce((sum, b) => sum + b.text.length + (b.baselineText?.length || 0), 0);
|
|
64
|
+
const revision = token();
|
|
65
|
+
const removed = old?.buffers.find(b => !encoded.state.buffers.some(n => n.id === b.id));
|
|
66
|
+
const added = encoded.state.buffers.find(b => !old?.buffers.some(n => n.id === b.id));
|
|
67
|
+
const receipt = { kind, move: { ...move, status: kind === "cancel" ? "cancelled" : "committed" }, fromRevision: previous?.revision ?? null, toRevision: revision,
|
|
68
|
+
fromWorkspaceRevision: old?.revision ?? 0, toWorkspaceRevision: encoded.state.revision,
|
|
69
|
+
fromDigest: digest(old), toDigest: digest(encoded.state),
|
|
70
|
+
beforeOrder: old?.order ?? [], beforeSelected: old?.selectedBufferId ?? null, beforePromptId: old?.activePromptId ?? null,
|
|
71
|
+
...(removed && added ? { slot: { beforeId: removed.id, beforeDigest: digest(removed), afterId: added.id },
|
|
72
|
+
unchanged: Object.fromEntries(old.buffers.filter(b => b.id !== removed.id).map(b => [b.id, digest(b)])) } : {}) };
|
|
73
|
+
replacements.push({ key: binding.workspaceId, value: { state: encoded.state, raw: encoded.raw, chars, revision,
|
|
74
|
+
lineage: [...(previous?.lineage || []), receipt].slice(-16), touchedAt: now() } });
|
|
75
|
+
}
|
|
76
|
+
const replacing = new Set(replacements.map(r => r.key));
|
|
77
|
+
for (const key of replacing) cleanLeaves.delete(key);
|
|
78
|
+
const total = [...entries].reduce((sum, [key, entry]) => sum + (replacing.has(key) ? 0 : entry.chars), 0)
|
|
79
|
+
+ replacements.reduce((sum, r) => sum + r.value.chars, 0);
|
|
80
|
+
if (total > maxTotalTextChars || entries.size + replacements.filter(r => !entries.has(r.key)).length > maxEntries) return fail("capacity", "Server recovery cannot hold the moved document. Nothing was evicted.");
|
|
81
|
+
for (const { key, value } of replacements) entries.set(key, value);
|
|
82
|
+
return { ok: true };
|
|
83
|
+
},
|
|
84
|
+
}) : null;
|
|
85
|
+
const fileClaims = hosting ? createStudioDocumentFileClaims({ now,
|
|
86
|
+
canonicalPath: options.hosting.canonicalPath ?? (path => path), sameFile: options.hosting.sameFile,
|
|
87
|
+
bind: fileBinding, authority: hosting.checkAuthority, checkWrite: hosting.checkWrite,
|
|
88
|
+
findFile: hosting.findFile, documents: hosting.documentAuthorities, advanceAuthority: hosting.advanceAuthority,
|
|
89
|
+
}) : null;
|
|
90
|
+
function fileBinding(capability, generation) {
|
|
91
|
+
const binding = scope(capability);
|
|
92
|
+
if (!hosting || !binding) return fail("forbidden", "The owning editing page is required.");
|
|
93
|
+
const error = hostingViewError(capability, binding, generation); if (error) return error;
|
|
94
|
+
const entry = entries.get(binding.workspaceId);
|
|
95
|
+
if (!entry || entry.state.mode !== binding.mode) return fail("not-ready", "A current Document checkpoint is required.");
|
|
96
|
+
const conflict = legacyConflict(binding, entry.state); if (conflict) return conflict;
|
|
97
|
+
return { ok: true, workspaceId: binding.workspaceId, entry };
|
|
98
|
+
}
|
|
99
|
+
// One predicate for recording a clean departure and for admitting the next page
|
|
100
|
+
// without the resume question. Completed copy/launch receipts don't block it.
|
|
101
|
+
function cleanContinuationEligible(workspaceId, entry) {
|
|
102
|
+
return Boolean(hosting && entry && entries.get(workspaceId) === entry && !hosting.hasPending(workspaceId) && !hosting.pendingMoves(workspaceId).length
|
|
103
|
+
&& !fileClaims.hasPending(workspaceId) && ![...metadataWrites.keys()].some(key => key.startsWith(workspaceId + ":"))
|
|
104
|
+
&& ![...openReceipts.values()].some(r => r.workspaceId === workspaceId && r.result.status !== "acknowledged"));
|
|
105
|
+
}
|
|
106
|
+
const voidCleanLeave = (...ids) => { for (const id of ids) if (typeof id === "string") cleanLeaves.delete(id); };
|
|
107
|
+
function documentIdle(workspaceId, bufferId) {
|
|
108
|
+
if (metadataWrites.get(workspaceId + ":" + bufferId)?.size) return fail("metadata-pending", "Wait for this Document's comments and scratchpad to finish saving.");
|
|
109
|
+
return fileClaims.guardMove(workspaceId, bufferId);
|
|
110
|
+
}
|
|
111
|
+
function cleanup() {
|
|
112
|
+
const time = now();
|
|
113
|
+
hosting?.sweep();
|
|
114
|
+
for (const [key, entry] of entries) if (time - entry.touchedAt > ttlMs) {
|
|
115
|
+
if (!hosting) entries.delete(key);
|
|
116
|
+
else reclaim(key, entry);
|
|
117
|
+
}
|
|
118
|
+
for (const [key, entry] of capabilities) if (time - entry.touchedAt > ttlMs) capabilities.delete(key);
|
|
119
|
+
}
|
|
120
|
+
function reclaim(key, entry) {
|
|
121
|
+
if (hostingViews.has(key) || entry.state.buffers.some(b => metadataWrites.get(key + ":" + b.id)?.size) || fileClaims?.hasPending(key) || !isStudioPristineWorkspace(entry.state) || !hosting.reclaimPristine(key)) return false;
|
|
122
|
+
entries.delete(key); seenHostingViews.delete(key); cleanLeaves.delete(key);
|
|
123
|
+
for (const [capability, value] of capabilities) if (value.workspaceId === key) capabilities.delete(capability);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
function scope(capability) {
|
|
127
|
+
cleanup();
|
|
128
|
+
const entry = capabilities.get(capability);
|
|
129
|
+
if (!entry) return null;
|
|
130
|
+
entry.touchedAt = now();
|
|
131
|
+
return entry;
|
|
132
|
+
}
|
|
133
|
+
function hostingViewError(capability, binding, generation) {
|
|
134
|
+
if (!options.hosting?.requireView) return null;
|
|
135
|
+
const view = hostingViews.get(binding.workspaceId);
|
|
136
|
+
if (!view || view.capability !== capability) return fail("view-not-ready", "Connect the owning editing view first.");
|
|
137
|
+
return view.generation === generation ? null : fail("lease-stale", "This request belongs to an obsolete editing connection.");
|
|
138
|
+
}
|
|
139
|
+
function legacyConflict(binding, baseline) {
|
|
140
|
+
const legacy = legacyState(binding.workspaceId);
|
|
141
|
+
if (!legacy) return null;
|
|
142
|
+
let id = 0;
|
|
143
|
+
const checked = migrateStudioWorkspaceV1(legacy, { ...binding, makeBufferId: () => "legacy-" + ++id });
|
|
144
|
+
if (!checked.ok) return fail("legacy-conflict", "Legacy recovery needs an explicit recovery decision.");
|
|
145
|
+
if (checked.state.savedAt > baseline.savedAt) return fail("legacy-newer", "Newer single-editor recovery exists; it was not replaced.");
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
// Trusted terminal bootstrap only; never exposed as an HTTP operation.
|
|
150
|
+
// Register before returning a URL, including when browser opening fails.
|
|
151
|
+
hostingBootstrap(request, loadDocument) {
|
|
152
|
+
cleanup();
|
|
153
|
+
if (!hosting || typeof loadDocument !== "function") return fail("forbidden", "A trusted Studio launch is required.");
|
|
154
|
+
let canonical = null;
|
|
155
|
+
try { if (request.path) canonical = options.hosting.canonicalPath(request.path); }
|
|
156
|
+
catch { return fail("source-changed", "The requested file location could not be checked."); }
|
|
157
|
+
const findOwner = () => canonical ? (() => { const saved = fileClaims.owner(canonical); return saved ? hosting.owner(saved.bufferId) : hosting.findFile(canonical); })() : null;
|
|
158
|
+
let owner = findOwner(); if (owner) return { ok: true, status: "reused", owner };
|
|
159
|
+
if (entries.size >= maxEntries) return fail("capacity", "Server recovery is full. Existing work was kept.");
|
|
160
|
+
let loaded;
|
|
161
|
+
try { loaded = loadDocument(canonical); } catch { return fail("read-failed", "The initial document could not be read. Nothing was registered."); }
|
|
162
|
+
if (loaded?.then) return fail("invalid-request", "Bootstrap requires a synchronous authorized snapshot.");
|
|
163
|
+
if (!loaded?.ok) return loaded || fail("read-failed", "No initial document was supplied.");
|
|
164
|
+
const document = loaded.document;
|
|
165
|
+
if (!document || document.watchFile || !["file", "blank", "last-response"].includes(document.source)) return fail("invalid-request", "Only an editable initial document can be registered here.");
|
|
166
|
+
try {
|
|
167
|
+
if (canonical ? document.source !== "file" || document.path !== canonical || options.hosting.canonicalPath(request.path) !== canonical
|
|
168
|
+
: document.source === "file" || document.path) return fail("source-changed", "The authorized file snapshot changed location.");
|
|
169
|
+
} catch { return fail("source-changed", "The authorized file location could not be checked again."); }
|
|
170
|
+
owner = findOwner(); if (owner) return { ok: true, status: "reused", owner };
|
|
171
|
+
const built = buildStudioHostingBootstrapState(document, canonical, loaded.editorLanguage, now());
|
|
172
|
+
if (!built.ok) return built;
|
|
173
|
+
const { state, encoded } = built;
|
|
174
|
+
const chars = encoded.state.buffers.reduce((n, b) => n + b.text.length + (b.baselineText?.length || 0), 0);
|
|
175
|
+
if (entries.size >= maxEntries || [...entries.values()].reduce((n, e) => n + e.chars, chars) > maxTotalTextChars) return fail("capacity", "Server recovery cannot hold this document. Existing work was kept.");
|
|
176
|
+
const checked = hosting.checkpoint(encoded.state); if (!checked.ok) return checked;
|
|
177
|
+
entries.set(state.workspaceId, { state: encoded.state, raw: encoded.raw, chars, revision: token(), lineage: [], touchedAt: now() });
|
|
178
|
+
return { ok: true, status: "created", owner: hosting.owner(state.activePromptId), document };
|
|
179
|
+
},
|
|
180
|
+
issue({ workspaceId, mode, watched = false }) {
|
|
181
|
+
cleanup();
|
|
182
|
+
if (watched || !["full", "editor-only"].includes(mode) || typeof workspaceId !== "string" || !/^[a-zA-Z0-9_-]{20,128}$/.test(workspaceId)) return fail("forbidden", "This view cannot own editable buffer recovery.");
|
|
183
|
+
if (capabilities.size >= maxCapabilities) return fail("capacity", "Too many recovery pages are open; existing recovery was retained.");
|
|
184
|
+
const capability = token();
|
|
185
|
+
capabilities.set(capability, { workspaceId, mode, touchedAt: now() });
|
|
186
|
+
return { ok: true, capability, workspaceId, mode };
|
|
187
|
+
},
|
|
188
|
+
read(capability) {
|
|
189
|
+
const binding = scope(capability);
|
|
190
|
+
if (!binding) return fail("forbidden", "Recovery page authorization expired. Reload this page before retrying.");
|
|
191
|
+
const entry = entries.get(binding.workspaceId);
|
|
192
|
+
if (!entry) return { ok: true, state: null, revision: null };
|
|
193
|
+
if (entry.state.mode !== binding.mode) return fail("wrong-workspace", "Recovery belongs to another view mode.");
|
|
194
|
+
const conflict = legacyConflict(binding, entry.state);
|
|
195
|
+
if (conflict) return conflict;
|
|
196
|
+
entry.touchedAt = now();
|
|
197
|
+
return { ok: true, state: entry.state, revision: entry.revision, expiresInMs: ttlMs, ...(hosting ? { lineage: entry.lineage || [], pendingMoves: hosting.pendingMoves(binding.workspaceId), pendingSaves: fileClaims.list(binding.workspaceId), documents: hosting.documentAuthorities(binding.workspaceId) } : {}) };
|
|
198
|
+
},
|
|
199
|
+
// Explicit read-only inspection can expose retained copies even when legacy
|
|
200
|
+
// conflict blocks normal adoption. This is NOT a persistence acknowledgement.
|
|
201
|
+
inspect(capability) {
|
|
202
|
+
const binding = scope(capability);
|
|
203
|
+
if (!binding) return fail("forbidden", "Recovery page authorization expired. Export browser copies before reloading.");
|
|
204
|
+
const entry = entries.get(binding.workspaceId);
|
|
205
|
+
if (entry && entry.state.mode !== binding.mode) return fail("wrong-workspace", "Recovery belongs to another view mode.");
|
|
206
|
+
const legacy = legacyState(binding.workspaceId);
|
|
207
|
+
if (entry) entry.touchedAt = now();
|
|
208
|
+
return { ok: true, workspaceId: binding.workspaceId, mode: binding.mode,
|
|
209
|
+
records: [{ kind: "v2", raw: entry?.raw ?? null }, { kind: "legacy", raw: legacy ? JSON.stringify(legacy) : null }] };
|
|
210
|
+
},
|
|
211
|
+
write(capability, expectedRevision, state, generation, documentEpochs) {
|
|
212
|
+
const binding = scope(capability);
|
|
213
|
+
if (!binding) return fail("forbidden", "Recovery page authorization expired.");
|
|
214
|
+
const viewError = hosting && hostingViewError(capability, binding, generation); if (viewError) return viewError;
|
|
215
|
+
if (expectedRevision !== null && (typeof expectedRevision !== "string" || !/^[a-f0-9]{48}$/.test(expectedRevision))) return fail("invalid-request", "Read recovery before writing it.");
|
|
216
|
+
const encoded = encodeStudioBufferRecovery(state, binding);
|
|
217
|
+
if (!encoded.ok) return encoded;
|
|
218
|
+
const previous = entries.get(binding.workspaceId);
|
|
219
|
+
if (previous && previous.state.mode !== binding.mode) return fail("wrong-workspace", "Recovery belongs to another view mode.");
|
|
220
|
+
const conflict = legacyConflict(binding, previous?.state ?? encoded.state);
|
|
221
|
+
if (conflict) return conflict;
|
|
222
|
+
if (hosting && previous) for (const buffer of previous.state.buffers) {
|
|
223
|
+
const next = encoded.state.buffers.find(b => b.id === buffer.id);
|
|
224
|
+
if (metadataWrites.get(binding.workspaceId + ":" + buffer.id)?.size
|
|
225
|
+
&& JSON.stringify(studioDocumentAuthorityIdentity(next)) !== JSON.stringify(studioDocumentAuthorityIdentity(buffer))) return fail("metadata-pending", "Document metadata is still saving; its backing was not changed.");
|
|
226
|
+
}
|
|
227
|
+
const filePlan = fileClaims?.checkCheckpoint(binding.workspaceId, encoded.state);
|
|
228
|
+
if (filePlan && !filePlan.ok) return filePlan;
|
|
229
|
+
// An exact retry after an acknowledgement was lost is safe; divergent writers need a new decision.
|
|
230
|
+
// It changes nothing, so it leaves a clean departure standing (the departing page's flush).
|
|
231
|
+
if (previous?.raw === encoded.raw) {
|
|
232
|
+
fileClaims?.commitCheckpoint(filePlan);
|
|
233
|
+
previous.touchedAt = now();
|
|
234
|
+
return { ok: true, revision: previous.revision, workspaceRevision: previous.state.revision, expiresInMs: ttlMs,
|
|
235
|
+
...(hosting ? { documents: hosting.documentAuthorities(binding.workspaceId), pendingSaves: fileClaims.list(binding.workspaceId) } : {}) };
|
|
236
|
+
}
|
|
237
|
+
if ((previous?.revision ?? null) !== expectedRevision) return fail("conflict", "Another page changed recovery. Current text and stored recovery were kept.");
|
|
238
|
+
if (hosting && previous && (options.hosting.requireView || documentEpochs !== undefined)) for (const buffer of previous.state.buffers) {
|
|
239
|
+
const authority = hosting.checkAuthority(binding.workspaceId, buffer.id, documentEpochs?.[buffer.id]); if (!authority.ok) return authority;
|
|
240
|
+
}
|
|
241
|
+
if (previous && (encoded.state.revision <= previous.state.revision || encoded.state.savedAt < previous.state.savedAt)) return fail("stale-state", "Recovery update is older than the stored workspace.");
|
|
242
|
+
const chars = encoded.state.buffers.reduce((sum, b) => sum + b.text.length + (b.baselineText?.length || 0), 0);
|
|
243
|
+
if (hosting && !previous && entries.size >= maxEntries) for (const [key, value] of entries) if (key !== binding.workspaceId) reclaim(key, value);
|
|
244
|
+
const total = [...entries.values()].reduce((sum, e) => sum + e.chars, 0) - (previous?.chars || 0) + chars;
|
|
245
|
+
if ((!previous && entries.size >= maxEntries) || total > maxTotalTextChars) return fail("capacity", "Server recovery capacity reached. No workspace was evicted; save or copy your text before closing.");
|
|
246
|
+
if (hosting) {
|
|
247
|
+
const owned = hosting.checkpoint(encoded.state);
|
|
248
|
+
if (!owned.ok) return owned;
|
|
249
|
+
}
|
|
250
|
+
const revision = token();
|
|
251
|
+
const lineage = hosting && previous?.lineage?.length ? [...previous.lineage, { kind: "publish", fromRevision: previous.revision,
|
|
252
|
+
toRevision: revision, fromWorkspaceRevision: previous.state.revision, toWorkspaceRevision: encoded.state.revision,
|
|
253
|
+
fromDigest: digest(previous.state), toDigest: digest(encoded.state) }].slice(-16) : [];
|
|
254
|
+
if (hosting && hostingViews.has(binding.workspaceId)) seenHostingViews.add(binding.workspaceId);
|
|
255
|
+
entries.set(binding.workspaceId, { state: encoded.state, raw: encoded.raw, revision, chars, lineage, touchedAt: now() });
|
|
256
|
+
cleanLeaves.delete(binding.workspaceId);
|
|
257
|
+
fileClaims?.commitCheckpoint(filePlan);
|
|
258
|
+
return { ok: true, revision, workspaceRevision: encoded.state.revision, expiresInMs: ttlMs,
|
|
259
|
+
...(hosting ? { documents: hosting.documentAuthorities(binding.workspaceId), pendingSaves: fileClaims.list(binding.workspaceId) } : {}) };
|
|
260
|
+
},
|
|
261
|
+
// Transport handles are server-owned identities, never values accepted from
|
|
262
|
+
// JSON. A late socket-close event cannot release its replacement's lease.
|
|
263
|
+
bindHostingView(capability, mode, handle) {
|
|
264
|
+
const binding = scope(capability);
|
|
265
|
+
if (!hosting || !binding || binding.mode !== mode || !handle) return fail("forbidden", "This connection cannot own this editing view.");
|
|
266
|
+
const current = hostingViews.get(binding.workspaceId);
|
|
267
|
+
if (current && (current.capability !== capability || current.handle !== handle)) return fail("view-already-open", "This workspace already has a live editing tab. Use that tab; its draft was kept.");
|
|
268
|
+
const generation = current?.generation ?? ++nextViewGeneration;
|
|
269
|
+
// A new lease takes the clean departure, whatever happens next; a rebind never does.
|
|
270
|
+
let continuation = null;
|
|
271
|
+
if (!current) {
|
|
272
|
+
const clean = cleanLeaves.get(binding.workspaceId); cleanLeaves.delete(binding.workspaceId);
|
|
273
|
+
if (clean && clean.generation === releasedGenerations.get(binding.workspaceId) && cleanContinuationEligible(binding.workspaceId, clean.entry)) continuation = { revision: clean.entry.revision };
|
|
274
|
+
}
|
|
275
|
+
if (entries.has(binding.workspaceId)) seenHostingViews.add(binding.workspaceId);
|
|
276
|
+
hostingViews.set(binding.workspaceId, { capability, handle, mode, generation });
|
|
277
|
+
return { ok: true, workspaceId: binding.workspaceId, mode, generation, ...(continuation ? { continuation } : {}) };
|
|
278
|
+
},
|
|
279
|
+
// The departing owner's own socket, at its own generation and exact checkpoint.
|
|
280
|
+
// Matched by the server-owned handle, so the page's capability release can't race it.
|
|
281
|
+
hostingCleanLeave(capability, handle, request) {
|
|
282
|
+
for (const [workspaceId, view] of hostingViews) if (view.capability === capability && view.handle === handle) {
|
|
283
|
+
const entry = entries.get(workspaceId);
|
|
284
|
+
cleanLeaves.delete(workspaceId);
|
|
285
|
+
if (view.generation !== request?.generation || !entry || entry.revision !== request?.revision || !cleanContinuationEligible(workspaceId, entry)) return fail("not-clean", "This departure can't skip the resume question.");
|
|
286
|
+
cleanLeaves.set(workspaceId, { generation: view.generation, entry });
|
|
287
|
+
return { ok: true };
|
|
288
|
+
}
|
|
289
|
+
return fail("forbidden", "Only the current owning connection can record its departure.");
|
|
290
|
+
},
|
|
291
|
+
hasCleanLeave(workspaceId) { const clean = cleanLeaves.get(workspaceId); return Boolean(clean && cleanContinuationEligible(workspaceId, clean.entry)); },
|
|
292
|
+
releaseHostingView(capability, handle) {
|
|
293
|
+
for (const [workspaceId, view] of hostingViews) if (view.capability === capability && view.handle === handle) {
|
|
294
|
+
const cancelled = hosting.cancelPending(workspaceId);
|
|
295
|
+
hostingViews.delete(workspaceId); releasedGenerations.set(workspaceId, view.generation); return { ok: true, released: true, cancellation: cancelled };
|
|
296
|
+
}
|
|
297
|
+
return { ok: true, released: false };
|
|
298
|
+
},
|
|
299
|
+
seenHostingView(workspaceId) { return seenHostingViews.has(workspaceId); },
|
|
300
|
+
forgetCleanLeave(workspaceId) { cleanLeaves.delete(workspaceId); },
|
|
301
|
+
capabilityWorkspace(capability) { cleanup(); const issued = capabilities.get(capability); return issued ? { workspaceId: issued.workspaceId, mode: issued.mode } : null; },
|
|
302
|
+
// Oliver, 13 Oct: switching editor mode restarts Studio and drops every retained copy.
|
|
303
|
+
// Reports work in flight, and each workspace holding a buffer not saved anywhere else.
|
|
304
|
+
// Only the carried Prompt (the asking page's active Prompt) is exempt (Sol, candidate69).
|
|
305
|
+
// Document text already in the carried text (what Add to Prompt makes) is kept too.
|
|
306
|
+
// pendingKeys names each operation in flight, so consent to drop one can't cover
|
|
307
|
+
// another that replaced it (Sol, candidate69).
|
|
308
|
+
/** @param {string | null} [carriedWorkspaceId] @param {string} [carriedText] */
|
|
309
|
+
retainedWork(carriedWorkspaceId = null, carriedText = "") {
|
|
310
|
+
cleanup();
|
|
311
|
+
const kept = (id, entry, b) => id === carriedWorkspaceId && (b.id === entry.state.activePromptId || (b.role === "document" && b.text.length > 0 && carriedText.includes(b.text)));
|
|
312
|
+
const keys = new Set(), unsaved = [];
|
|
313
|
+
for (const [key, writes] of metadataWrites) for (const write of writes) keys.add("metadata:" + key + ":" + operationIdentity(write));
|
|
314
|
+
for (const [key, receipt] of openReceipts) if (receipt.result.status !== "acknowledged") keys.add("open:" + key + ":" + receipt.result.status);
|
|
315
|
+
for (const [id, entry] of entries) {
|
|
316
|
+
if (hosting) {
|
|
317
|
+
if (hosting.hasPending(id)) keys.add("hosting:" + id + ":" + hosting.pendingId(id));
|
|
318
|
+
for (const move of hosting.pendingMoves(id)) keys.add("move:" + move.moveId);
|
|
319
|
+
for (const save of fileClaims.list(id)) keys.add("save:" + id + ":" + save.operationId); // Save ids are per workspace.
|
|
320
|
+
}
|
|
321
|
+
if (entry.state.buffers.some(b => !kept(id, entry, b) && (b.baselineText === null || b.text !== b.baselineText))) unsaved.push(id);
|
|
322
|
+
}
|
|
323
|
+
const pendingKeys = [...keys].sort();
|
|
324
|
+
return { pending: pendingKeys.length > 0, pendingKeys, unsaved };
|
|
325
|
+
},
|
|
326
|
+
activeHostingWorkspaces(mode) {
|
|
327
|
+
return [...hostingViews].filter(([, view]) => view.mode === mode).map(([workspaceId]) => workspaceId);
|
|
328
|
+
},
|
|
329
|
+
hostingCopy(capability, request) {
|
|
330
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
331
|
+
voidCleanLeave(binding.workspaceId);
|
|
332
|
+
if (!/^[a-f0-9]{48}$/.test(request.operationId || "")) return fail("invalid-request", "A fresh copy intent identity is required.");
|
|
333
|
+
const key = binding.workspaceId + ":" + request.operationId;
|
|
334
|
+
const intent = JSON.stringify([request.bufferId, request.documentEpoch, request.expectedRevision]);
|
|
335
|
+
const existing = copyReceipts.get(key);
|
|
336
|
+
if (existing) return existing.intent !== intent ? fail("invalid-request", "That copy identity belongs to a different intent.")
|
|
337
|
+
: hosting.owner(existing.result.bufferId) ? { ...existing.result } : fail("copy-closed", "The original copy is no longer retained. It was not recreated.");
|
|
338
|
+
if (copyReceipts.size >= 128) for (const [id, record] of copyReceipts) {
|
|
339
|
+
if (entries.get(record.source)?.revision !== record.revision || hosting.documentAuthorities(record.source)[record.bufferId] !== record.epoch) copyReceipts.delete(id);
|
|
340
|
+
}
|
|
341
|
+
if (copyReceipts.size >= 128) return fail("capacity", "Copy receipt capacity reached. Existing copies were kept.");
|
|
342
|
+
if (binding.entry.revision !== request.expectedRevision) return fail("conflict", "Checkpoint the copy's source first.");
|
|
343
|
+
const authority = hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch); if (!authority.ok) return authority;
|
|
344
|
+
const source = binding.entry.state.buffers.find(b => b.id === request.bufferId);
|
|
345
|
+
const copy = createStudioDocumentCopy(source, "copy_" + token()), workspaceId = "host_" + token();
|
|
346
|
+
const state = { version: 2, workspaceId, mode: "editor-only", revision: 1, savedAt: now(), selectedBufferId: copy.id,
|
|
347
|
+
activePromptId: null, order: [copy.id], buffers: [copy] };
|
|
348
|
+
const encoded = encodeStudioBufferRecovery(state, state); if (!encoded.ok) return encoded;
|
|
349
|
+
if (entries.size >= maxEntries) for (const [key, value] of entries) reclaim(key, value);
|
|
350
|
+
const chars = copy.text.length;
|
|
351
|
+
if (entries.size >= maxEntries || [...entries.values()].reduce((sum, e) => sum + e.chars, chars) > maxTotalTextChars) return fail("capacity", "There is no room for another retained copy; existing drafts were kept.");
|
|
352
|
+
const registered = hosting.checkpoint(encoded.state); if (!registered.ok) return registered;
|
|
353
|
+
entries.set(workspaceId, { state: encoded.state, raw: encoded.raw, chars, revision: token(), lineage: [], touchedAt: now() });
|
|
354
|
+
const result = { ok: true, operationId: request.operationId, created: true, workspaceId, bufferId: copy.id, mode: "editor-only" };
|
|
355
|
+
copyReceipts.set(key, { intent, result, source: binding.workspaceId, revision: request.expectedRevision, bufferId: request.bufferId, epoch: request.documentEpoch });
|
|
356
|
+
return result;
|
|
357
|
+
},
|
|
358
|
+
// One synchronous transaction: permission-checked read + canonical owner
|
|
359
|
+
// reuse + registration. A receipt never authorizes a filesystem read.
|
|
360
|
+
hostingLaunch(capability, request, loadFile, loadExport) {
|
|
361
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
362
|
+
voidCleanLeave(binding.workspaceId);
|
|
363
|
+
if (!request || typeof request.operationId !== "string" || !/^[a-f0-9]{48}$/.test(request.operationId || "") || !["blank", "file"].includes(request.kind)
|
|
364
|
+
|| ["path", "sourcePath", "resourceDir"].some(k => typeof request[k] !== "string" || request[k].length > 16_384 || request[k].includes("\0"))
|
|
365
|
+
|| (request.kind === "blank" ? request.path || request.sourcePath || request.resourceDir : !request.path)
|
|
366
|
+
|| (request.exportId !== undefined && (typeof request.exportId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(request.exportId)))) return fail("invalid-request", "A bounded immutable editor request is required.");
|
|
367
|
+
const key = binding.workspaceId + ":" + request.operationId;
|
|
368
|
+
const intent = JSON.stringify([request.kind, request.path, request.sourcePath, request.resourceDir, request.bufferId, request.documentEpoch, request.expectedRevision, request.exportId ?? null]);
|
|
369
|
+
const existing = launchReceipts.get(key);
|
|
370
|
+
const located = bufferId => { const owner = hosting.owner(bufferId); return owner ? { ok: true, ...owner } : fail("launch-closed", "The original editor is no longer retained. It was not recreated."); };
|
|
371
|
+
if (existing) return existing.intent === intent ? { ...located(existing.bufferId), operationId: request.operationId, created: existing.created } : fail("invalid-request", "That editor request identity belongs to another intent.");
|
|
372
|
+
if (binding.entry.revision !== request.expectedRevision) return fail("conflict", "Checkpoint the originating editor before opening another one.");
|
|
373
|
+
const authority = hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch); if (!authority.ok) return authority;
|
|
374
|
+
if (launchReceipts.size >= 128) for (const [id, record] of launchReceipts) {
|
|
375
|
+
if (entries.get(record.source)?.revision !== record.revision || hosting.documentAuthorities(record.source)[record.sourceBufferId] !== record.epoch) launchReceipts.delete(id);
|
|
376
|
+
}
|
|
377
|
+
if (launchReceipts.size >= 128) return fail("capacity", "Editor request capacity reached. Existing Documents were kept.");
|
|
378
|
+
const remember = (bufferId, created = false) => { launchReceipts.set(key, { intent, bufferId, created, source: binding.workspaceId, revision: request.expectedRevision, sourceBufferId: request.bufferId, epoch: request.documentEpoch }); return { ...located(bufferId), operationId: request.operationId, created }; };
|
|
379
|
+
let file = null, path = null, prepared = null;
|
|
380
|
+
if (request.exportId !== undefined) {
|
|
381
|
+
try { prepared = loadExport?.(request, { workspaceId: binding.workspaceId }); }
|
|
382
|
+
catch { return fail("export-unavailable", "The retained export could not be checked; no Document was created."); }
|
|
383
|
+
if (prepared?.then) return fail("authority-required", "Prepared export loading must complete synchronously with registration.");
|
|
384
|
+
if (!prepared?.ok) return prepared || fail("export-unavailable", "The prepared HTML is unavailable; no blank replacement was created.");
|
|
385
|
+
if (prepared.kind !== request.kind || (request.kind === "file" ? prepared.path !== request.path : prepared.path != null || prepared.diskRevision != null)) return fail("source-changed", "The prepared export kind or backing changed.");
|
|
386
|
+
const checked = this.hostingExportAuthority(capability, request); if (!checked.ok) return checked;
|
|
387
|
+
}
|
|
388
|
+
if (request.kind === "file") {
|
|
389
|
+
try { path = (options.hosting.canonicalPath ?? (p => p))(request.path); } catch { return fail("source-changed", "The requested file identity could not be checked."); }
|
|
390
|
+
const owned = fileClaims.owner(path) || hosting.findFile(path); if (owned) return remember(owned.bufferId);
|
|
391
|
+
// The caller supplies the existing permission and final-symlink checks.
|
|
392
|
+
file = loadFile?.(path, prepared);
|
|
393
|
+
if (file?.then) return fail("authority-required", "File loading must complete synchronously with registration.");
|
|
394
|
+
if (!file?.ok) return file || fail("permission-required", "A permission-checked file read is required.");
|
|
395
|
+
if (file.path !== path || (options.hosting.canonicalPath ?? (p => p))(request.path) !== path
|
|
396
|
+
|| (prepared && (file.text !== prepared.text || file.diskRevision !== prepared.diskRevision))) return fail("source-changed", "The file changed while opening. No editor was created.");
|
|
397
|
+
}
|
|
398
|
+
// Loader callbacks cannot borrow an authority retired during the read.
|
|
399
|
+
const current = fileBinding(capability, request.generation); if (!current.ok) return current;
|
|
400
|
+
if (current.entry.revision !== request.expectedRevision) return fail("conflict", "The originating checkpoint changed while opening.");
|
|
401
|
+
const checkedAuthority = hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch); if (!checkedAuthority.ok) return checkedAuthority;
|
|
402
|
+
const changedOwner = path && (fileClaims.owner(path) || hosting.findFile(path)); if (changedOwner) return remember(changedOwner.bufferId);
|
|
403
|
+
let document;
|
|
404
|
+
try { document = createStudioBuffer({ id: "document_" + token(), role: "document", ...(file ? { text: file.text, baselineText: file.text, diskRevision: file.diskRevision,
|
|
405
|
+
resourceDir: file.resourceDir, view: { editorLanguage: file.editorLanguage || "markdown" }, sourceState: { source: "file", label: file.label, path, draftId: null } } : prepared ? { text: prepared.text, baselineText: "", diskRevision: null,
|
|
406
|
+
resourceDir: prepared.resourceDir, view: { editorLanguage: "html" }, sourceState: { source: "import", label: prepared.label, path: null } } : {}) }); }
|
|
407
|
+
catch { return fail("invalid-document", "The file cannot be represented safely as a Document."); }
|
|
408
|
+
const workspaceId = "host_" + token(), state = { version: 2, workspaceId, mode: "editor-only", revision: 1, savedAt: now(), selectedBufferId: document.id, activePromptId: null, order: [document.id], buffers: [document] };
|
|
409
|
+
const encoded = encodeStudioBufferRecovery(state, state); if (!encoded.ok) return encoded;
|
|
410
|
+
if (entries.size >= maxEntries) for (const [id, value] of entries) reclaim(id, value);
|
|
411
|
+
const chars = document.text.length + document.baselineText.length;
|
|
412
|
+
if (entries.size >= maxEntries || [...entries.values()].reduce((sum, e) => sum + e.chars, chars) > maxTotalTextChars) return fail("capacity", "No room for another retained editor; existing work was kept.");
|
|
413
|
+
const registered = hosting.checkpoint(encoded.state); if (!registered.ok) return registered;
|
|
414
|
+
entries.set(workspaceId, { state: encoded.state, raw: encoded.raw, chars, revision: token(), lineage: [], touchedAt: now() });
|
|
415
|
+
return remember(document.id, true);
|
|
416
|
+
},
|
|
417
|
+
hostingOpen(capability, request, loadFile) {
|
|
418
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
419
|
+
voidCleanLeave(binding.workspaceId);
|
|
420
|
+
if (!request || !["open", "open-cancel", "open-ack"].includes(request.operation) || typeof request.operationId !== "string" || !/^[a-f0-9]{48}$/.test(request.operationId)
|
|
421
|
+
|| ["path", "sourcePath", "resourceDir"].some(k => typeof request[k] !== "string" || request[k].length > 16384 || request[k].includes("\0")) || !request.path) return fail("invalid-request", "A bounded file-open intent is required.");
|
|
422
|
+
const key = binding.workspaceId + ":" + request.operationId, intent = JSON.stringify([request.bufferId, request.documentEpoch, request.expectedRevision, request.path, request.sourcePath, request.resourceDir, request.discardTarget]);
|
|
423
|
+
const previous = openReceipts.get(key);
|
|
424
|
+
if (previous) {
|
|
425
|
+
if (previous.intent !== intent) return fail("invalid-request", "That open identity belongs to a different intent.");
|
|
426
|
+
if (request.operation === "open-ack") {
|
|
427
|
+
if (request.acknowledgedRevision !== binding.entry.revision || !hosting.checkAuthority(binding.workspaceId, request.bufferId, request.acknowledgedEpoch).ok) return fail("conflict", "A current adopted checkpoint is required before releasing the open receipt.");
|
|
428
|
+
previous.result = { ok: true, operationId: request.operationId, status: "acknowledged" }; previous.chars = 0;
|
|
429
|
+
}
|
|
430
|
+
if (previous.result.status !== "acknowledged" && binding.entry.revision !== previous.result.revision) return fail("outcome-changed", "The workspace changed after this open. Keep the backup and inspect recovery; do not replay an older checkpoint.");
|
|
431
|
+
return { ...previous.result, generation: request.generation };
|
|
432
|
+
}
|
|
433
|
+
if (request.operation === "open-ack") return fail("open-not-found", "The open receipt is no longer retained.");
|
|
434
|
+
if (binding.entry.revision !== request.expectedRevision) return fail("conflict", "The originating checkpoint changed; no file was opened.");
|
|
435
|
+
const authority = hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch); if (!authority.ok) return authority;
|
|
436
|
+
const idle = documentIdle(binding.workspaceId, request.bufferId); if (!idle.ok) return idle;
|
|
437
|
+
const before = binding.entry.state, old = before.buffers.find(b => b.id === request.bufferId);
|
|
438
|
+
if (request.discardTarget !== true && request.operation === "open") return fail("confirmation-required", "Explicit replacement consent is required.");
|
|
439
|
+
for (const [id, value] of openReceipts) if (value.result.status === "acknowledged" || entries.get(value.workspaceId)?.revision !== value.result.revision) openReceipts.delete(id);
|
|
440
|
+
if (openReceipts.size >= 128) return fail("capacity", "Resolve existing file-open receipts first. Nothing was replaced.");
|
|
441
|
+
let status = "cancelled", owner = null, nextBuffer = old;
|
|
442
|
+
if (request.operation === "open") {
|
|
443
|
+
const canonical = (options.hosting.canonicalPath ?? (p => p))(request.path);
|
|
444
|
+
owner = fileClaims.owner(canonical) || hosting.findFile(canonical);
|
|
445
|
+
if (!owner) {
|
|
446
|
+
const file = loadFile?.(canonical);
|
|
447
|
+
if (file?.then) return fail("authority-required", "Opening and registration must be synchronous.");
|
|
448
|
+
if (!file?.ok) return file || fail("permission-required", "An authorized file read is required.");
|
|
449
|
+
if (file.path !== canonical || (options.hosting.canonicalPath ?? (p => p))(request.path) !== canonical) return fail("source-changed", "The file changed while opening.");
|
|
450
|
+
const current = fileBinding(capability, request.generation); if (!current.ok) return current;
|
|
451
|
+
if (current.entry !== binding.entry || !hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch).ok) return fail("conflict", "The owning checkpoint changed while reading.");
|
|
452
|
+
const stillIdle = documentIdle(binding.workspaceId, request.bufferId); if (!stillIdle.ok) return stillIdle;
|
|
453
|
+
owner = fileClaims.owner(canonical) || hosting.findFile(canonical);
|
|
454
|
+
if (!owner) try { nextBuffer = createStudioBuffer({ ...old, revision: old.revision + 1, text: file.text, baselineText: file.text, diskRevision: file.diskRevision, resourceDir: file.resourceDir,
|
|
455
|
+
sourceState: { source: "file", path: canonical, label: file.label, draftId: null },
|
|
456
|
+
view: { ...old.view, editorView: "markdown", rightView: "editor-preview", editorLanguage: file.editorLanguage || "markdown", selectionStart: 0, selectionEnd: 0, selectionDirection: "none", scrollTop: 0, previewScrollTop: 0, rightScrollTop: 0 },
|
|
457
|
+
metadata: { ...old.metadata, scratchpadKey: "file:" + canonical, reviewNotesKey: "file:" + canonical } }); }
|
|
458
|
+
catch {
|
|
459
|
+
// Say plainly when a file is simply too large to edit (Oliver, 9 Oct). Like the
|
|
460
|
+
// refusal below, nothing has been recorded yet.
|
|
461
|
+
if (typeof file.text === "string" && file.text.length > STUDIO_BUFFER_LIMITS.textChars) {
|
|
462
|
+
// The limit counts text characters, not bytes (Sol).
|
|
463
|
+
const count = file.text.length.toLocaleString("en-US"), limit = STUDIO_BUFFER_LIMITS.textChars.toLocaleString("en-US");
|
|
464
|
+
return fail("too-large", (basename(file.path || "") || "That file") + " is too large to edit in Studio (" + count + " characters; the limit is " + limit + "). Open it in a browser instead.");
|
|
465
|
+
}
|
|
466
|
+
return fail("invalid-document", "The file cannot be represented safely. Nothing was replaced.");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
status = owner ? "reused" : "committed";
|
|
470
|
+
}
|
|
471
|
+
// Even refusal-to-replace/cancellation advances the checkpoint, fencing a
|
|
472
|
+
// delayed request after receipt reclamation. No disk write occurs here.
|
|
473
|
+
const state = { ...before, revision: before.revision + 1, savedAt: Math.max(before.savedAt + 1, now()), buffers: before.buffers.map(b => b.id === old.id ? nextBuffer : b) };
|
|
474
|
+
const encoded = encodeStudioBufferRecovery(state, before); if (!encoded.ok) return encoded;
|
|
475
|
+
const chars = encoded.state.buffers.reduce((n, b) => n + b.text.length + (b.baselineText?.length || 0), 0);
|
|
476
|
+
if ([...entries.values()].reduce((n, e) => n + e.chars, chars - binding.entry.chars) > maxTotalTextChars
|
|
477
|
+
|| [...openReceipts.values()].reduce((n, r) => n + (r.workspaceId === binding.workspaceId ? 0 : r.chars), chars) > maxTotalTextChars) return fail("capacity", "Open/recovery capacity reached. Existing work was kept.");
|
|
478
|
+
const filePlan = fileClaims.checkCheckpoint(binding.workspaceId, encoded.state); if (!filePlan.ok) return filePlan;
|
|
479
|
+
const owned = hosting.checkpoint(encoded.state); if (!owned.ok) return owned;
|
|
480
|
+
const revision = token(), receipt = { kind: "open", operationId: request.operationId, fromRevision: binding.entry.revision, toRevision: revision,
|
|
481
|
+
fromWorkspaceRevision: before.revision, toWorkspaceRevision: state.revision, fromDigest: digest(before), toDigest: digest(encoded.state),
|
|
482
|
+
beforeOrder: before.order, beforeSelected: before.selectedBufferId, beforePromptId: before.activePromptId,
|
|
483
|
+
slot: { beforeId: old.id, beforeDigest: digest(old), afterId: old.id }, unchanged: Object.fromEntries(before.buffers.filter(b => b.id !== old.id).map(b => [b.id, digest(b)])) };
|
|
484
|
+
entries.set(binding.workspaceId, { state: encoded.state, raw: encoded.raw, chars, revision, lineage: [...(binding.entry.lineage || []), receipt].slice(-16), touchedAt: now() });
|
|
485
|
+
fileClaims.commitCheckpoint(filePlan);
|
|
486
|
+
const result = { ok: true, operationId: request.operationId, workspaceId: binding.workspaceId, bufferId: old.id, status, owner,
|
|
487
|
+
intentRevision: request.expectedRevision, state: encoded.state, revision, documents: Object.freeze(hosting.documentAuthorities(binding.workspaceId)), expiresInMs: ttlMs };
|
|
488
|
+
if (owner) Object.freeze(owner);
|
|
489
|
+
// A later explicitly authorized checkpoint makes an older snapshot
|
|
490
|
+
// unusable for adoption. Its original revision is already fenced.
|
|
491
|
+
for (const [id, value] of openReceipts) if (value.workspaceId === binding.workspaceId) openReceipts.delete(id);
|
|
492
|
+
openReceipts.set(key, { workspaceId: binding.workspaceId, intent, chars, result }); return { ...result, generation: request.generation };
|
|
493
|
+
},
|
|
494
|
+
// For the authenticated HTML launcher only, never a JSON recovery response.
|
|
495
|
+
hostedWorkspace(workspaceId) { cleanup(); return hosting?.workspace(workspaceId) ?? null; },
|
|
496
|
+
hostedDocumentOwner(bufferId) { cleanup(); return hosting?.owner(bufferId) ?? null; },
|
|
497
|
+
// Capabilities, never caller-supplied workspace IDs, authorize each side.
|
|
498
|
+
hostingMove(capability, request) {
|
|
499
|
+
const binding = scope(capability);
|
|
500
|
+
if (!hosting || !binding) return fail("forbidden", "Document hosting is unavailable for this page.");
|
|
501
|
+
const viewError = request?.operation === "status" ? null : hostingViewError(capability, binding, request?.generation);
|
|
502
|
+
if (viewError) return viewError;
|
|
503
|
+
if (request?.operation !== "status") voidCleanLeave(binding.workspaceId, request?.targetWorkspaceId);
|
|
504
|
+
const entry = entries.get(binding.workspaceId);
|
|
505
|
+
if (!entry || entry.state.mode !== binding.mode) return fail("not-ready", "A current recovery checkpoint is required before moving a document.");
|
|
506
|
+
const stale = () => fail("conflict", "The workspace checkpoint changed. Nothing was moved.");
|
|
507
|
+
if (["move-out", "begin", "accept"].includes(request?.operation)) {
|
|
508
|
+
const guarded = documentIdle(binding.workspaceId, request.operation === "accept" ? request.targetBufferId : request.bufferId);
|
|
509
|
+
if (!guarded.ok) return guarded;
|
|
510
|
+
}
|
|
511
|
+
const withCheckpoint = result => {
|
|
512
|
+
if (!result.ok || !["committed", "cancelled"].includes(result.status)) return result;
|
|
513
|
+
const current = entries.get(binding.workspaceId), conflict = legacyConflict(binding, current.state);
|
|
514
|
+
// Ownership outcome is still useful when recovery has a legacy
|
|
515
|
+
// conflict, but it must not masquerade as a persistence acknowledgement.
|
|
516
|
+
const receipt = current.lineage?.findLast(r => r.move?.moveId === result.moveId);
|
|
517
|
+
return { ...result, state: current.state, revision: current.revision, documents: hosting.documentAuthorities(binding.workspaceId),
|
|
518
|
+
...(receipt ? { intentRevision: receipt.fromRevision } : {}),
|
|
519
|
+
expiresInMs: conflict ? 0 : ttlMs, ...(conflict ? { checkpointError: conflict.message } : {}) };
|
|
520
|
+
};
|
|
521
|
+
if (request?.operation === "move-out") {
|
|
522
|
+
if (typeof request.moveId !== "string" || !/^[a-f0-9]{48}$/.test(request.moveId)) return fail("invalid-request", "Allocate a fresh move identity before sending.");
|
|
523
|
+
const receipt = entry.lineage?.findLast(r => r.kind === "move-out" && r.move.moveId === request.moveId);
|
|
524
|
+
if (receipt) {
|
|
525
|
+
if (receipt.fromRevision !== request.expectedRevision || receipt.move.bufferId !== request.bufferId) return fail("invalid-move", "The retry changed the original move intent.");
|
|
526
|
+
return withCheckpoint({ ok: true, ...receipt.move });
|
|
527
|
+
}
|
|
528
|
+
if (entry.revision !== request.expectedRevision) return stale();
|
|
529
|
+
if (entries.size >= maxEntries) for (const [key, value] of entries) if (key !== binding.workspaceId) reclaim(key, value);
|
|
530
|
+
return withCheckpoint(hosting.moveOut({ workspaceId: binding.workspaceId, expectedRevision: entry.state.revision,
|
|
531
|
+
bufferId: request.bufferId, moveId: request.moveId, destinationId: "host_" + token() }));
|
|
532
|
+
}
|
|
533
|
+
if (request?.operation === "begin") {
|
|
534
|
+
if (typeof request.moveId !== "string" || !/^[a-f0-9]{48}$/.test(request.moveId)) return fail("invalid-request", "Allocate a fresh random move identity before sending Begin, so an uncertain request can be cancelled safely.");
|
|
535
|
+
if (entry.revision !== request.expectedRevision) return stale();
|
|
536
|
+
return hosting.beginMove({ workspaceId: binding.workspaceId, expectedRevision: entry.state.revision,
|
|
537
|
+
bufferId: request.bufferId, targetMode: request.targetMode, targetWorkspaceId: request.targetWorkspaceId, moveId: request.moveId });
|
|
538
|
+
}
|
|
539
|
+
if (request?.operation === "accept") {
|
|
540
|
+
// Allow an exact lost-ack retry only for the already-bound destination.
|
|
541
|
+
const status = hosting.moveStatus(binding.workspaceId, request.moveId);
|
|
542
|
+
if (status.ok && status.status === "committed" && status.targetWorkspaceId === binding.workspaceId) return withCheckpoint(status);
|
|
543
|
+
if (entry.revision !== request.expectedRevision) return stale();
|
|
544
|
+
return withCheckpoint(hosting.acceptMove({ workspaceId: binding.workspaceId, expectedRevision: entry.state.revision,
|
|
545
|
+
targetBufferId: request.targetBufferId, moveId: request.moveId, discardTarget: request.discardTarget === true }));
|
|
546
|
+
}
|
|
547
|
+
const retainedOutcome = () => {
|
|
548
|
+
const receipt = entry.lineage?.findLast(r => r.move?.moveId === request.moveId);
|
|
549
|
+
return receipt ? { ok: true, ...receipt.move } : null;
|
|
550
|
+
};
|
|
551
|
+
if (request?.operation === "status") {
|
|
552
|
+
const result = hosting.moveStatus(binding.workspaceId, request.moveId, request.incoming === true);
|
|
553
|
+
return withCheckpoint(result.ok ? result : retainedOutcome() || result);
|
|
554
|
+
}
|
|
555
|
+
if (request?.operation === "cancel") {
|
|
556
|
+
const known = hosting.moveStatus(binding.workspaceId, request.moveId);
|
|
557
|
+
if (!known.ok) {
|
|
558
|
+
const retained = retainedOutcome();
|
|
559
|
+
if (retained?.sourceWorkspaceId === binding.workspaceId) {
|
|
560
|
+
const receipt = entry.lineage.findLast(r => r.move?.moveId === request.moveId);
|
|
561
|
+
if (request.expectedRevision !== receipt.fromRevision) return fail("invalid-move", "This cancellation does not match the original intent.");
|
|
562
|
+
return withCheckpoint(retained);
|
|
563
|
+
}
|
|
564
|
+
if (entry.revision !== request.expectedRevision) return stale();
|
|
565
|
+
}
|
|
566
|
+
return withCheckpoint(hosting.cancelMove(binding.workspaceId, request.moveId));
|
|
567
|
+
}
|
|
568
|
+
if (request?.operation === "decline") return withCheckpoint(hosting.declineMove(binding.workspaceId, request.moveId));
|
|
569
|
+
if (request?.operation === "adopted") {
|
|
570
|
+
let result = hosting.adoptMove(binding.workspaceId, request.moveId, request.bufferId);
|
|
571
|
+
if (!result.ok) {
|
|
572
|
+
const retained = retainedOutcome();
|
|
573
|
+
if (retained?.status === "committed" && retained.targetWorkspaceId === binding.workspaceId
|
|
574
|
+
&& retained.bufferId === request.bufferId && hosting.owner(request.bufferId)?.workspaceId === binding.workspaceId) result = retained;
|
|
575
|
+
}
|
|
576
|
+
if (!result.ok) return result;
|
|
577
|
+
for (const [key, value] of entries) for (const receipt of value.lineage || []) if (receipt.move?.moveId === request.moveId) { receipt.move.adopted = true; cleanLeaves.delete(key); }
|
|
578
|
+
return withCheckpoint({ ...result, adopted: true });
|
|
579
|
+
}
|
|
580
|
+
return fail("invalid-request", "Unknown document hosting operation.");
|
|
581
|
+
},
|
|
582
|
+
// Refresh is a read of this incarnation's exact backing, not an opening
|
|
583
|
+
// operation or a filesystem grant. The trusted reader still checks access.
|
|
584
|
+
hostingRefresh(capability, request, readFile) {
|
|
585
|
+
const check = () => {
|
|
586
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
587
|
+
const authority = hosting.checkAuthority(binding.workspaceId, request?.bufferId, request?.documentEpoch); if (!authority.ok) return authority;
|
|
588
|
+
if (binding.entry.revision !== request.expectedRevision) return fail("conflict", "The Document checkpoint changed before Refresh. Keep current text and try again.");
|
|
589
|
+
const idle = documentIdle(binding.workspaceId, request.bufferId); if (!idle.ok) return idle;
|
|
590
|
+
const buffer = binding.entry.state.buffers.find(b => b.id === request.bufferId);
|
|
591
|
+
const path = buffer?.sourceState.source === "file" ? buffer.sourceState.path : null;
|
|
592
|
+
if (!path || typeof request.path !== "string" || request.path !== path) return fail("source-changed", "Refresh can only read this Document's current backing file. Use Open Document for a different file.");
|
|
593
|
+
try { if ((options.hosting.canonicalPath ?? (p => p))(path) !== path) return fail("source-changed", "The file location changed. Current work was kept."); }
|
|
594
|
+
catch { return fail("source-changed", "The backing file identity could not be checked. Current work was kept."); }
|
|
595
|
+
return { ok: true, buffer, path };
|
|
596
|
+
};
|
|
597
|
+
const before = check(); if (!before.ok) return before;
|
|
598
|
+
let result;
|
|
599
|
+
try { result = readFile(before.path, structuredClone(before.buffer)); }
|
|
600
|
+
catch (error) { return fail("read-failed", error instanceof Error ? error.message : "The file could not be refreshed. Current work was kept."); }
|
|
601
|
+
if (result && typeof result.then === "function") { result.catch?.(() => undefined); return fail("authority-required", "Refresh requires a synchronous authorized reader."); }
|
|
602
|
+
if (result?.ok === false) return result;
|
|
603
|
+
const after = check(); if (!after.ok) return after;
|
|
604
|
+
if (!result?.ok || result.resolvedPath !== before.path || typeof result.text !== "string") return fail("source-changed", "Refresh did not return the same backing file. Current work was kept.");
|
|
605
|
+
return result;
|
|
606
|
+
},
|
|
607
|
+
// The writer is a trusted server callback, never a client-provided function.
|
|
608
|
+
hostingSave(capability, request, writer) {
|
|
609
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
610
|
+
voidCleanLeave(binding.workspaceId);
|
|
611
|
+
if (metadataWrites.get(binding.workspaceId + ":" + request.bufferId)?.size) return fail("metadata-pending", "Finish saving this Document's metadata before writing its file.");
|
|
612
|
+
return fileClaims.execute(capability, request, writer);
|
|
613
|
+
},
|
|
614
|
+
hostingSaveMetadata(capability, request, transaction) {
|
|
615
|
+
if (!hosting) return Promise.resolve(fail("forbidden", "Document hosting is unavailable."));
|
|
616
|
+
voidCleanLeave(capabilities.get(capability)?.workspaceId);
|
|
617
|
+
return fileClaims.metadata(capability, request, transaction);
|
|
618
|
+
},
|
|
619
|
+
ownsMetadataKey(kind, documentKey) {
|
|
620
|
+
if (fileClaims?.ownsMetadataKey(kind, documentKey)) return true;
|
|
621
|
+
const field = kind === "scratchpad" ? "scratchpadKey" : "reviewNotesKey";
|
|
622
|
+
return Boolean(hosting && [...entries.values()].some(e => e.state.buffers.some(b => studioDocumentMetadataKey(b, field) === documentKey)));
|
|
623
|
+
},
|
|
624
|
+
async hostingMetadata(capability, request, kind, documentKey, write) {
|
|
625
|
+
const field = kind === "scratchpad" ? "scratchpadKey" : kind === "review-notes" ? "reviewNotesKey" : null;
|
|
626
|
+
const check = () => {
|
|
627
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
628
|
+
const authority = hosting.checkAuthority(binding.workspaceId, request?.bufferId, request?.documentEpoch); if (!authority.ok) return authority;
|
|
629
|
+
const buffer = binding.entry.state.buffers.find(b => b.id === request.bufferId);
|
|
630
|
+
if (!field || studioDocumentMetadataKey(buffer, field) !== documentKey) return fail("document-stale", "This metadata association no longer belongs to the Document.");
|
|
631
|
+
if (fileClaims.ownsMetadataKey(kind, documentKey)) return fail("save-ack-pending", "Resolve the retained disk save before producing new metadata for this association.");
|
|
632
|
+
return binding;
|
|
633
|
+
};
|
|
634
|
+
const binding = check(); if (!binding.ok) return binding;
|
|
635
|
+
voidCleanLeave(binding.workspaceId);
|
|
636
|
+
const key = binding.workspaceId + ":" + request.bufferId;
|
|
637
|
+
let complete; const pending = new Promise(resolve => { complete = resolve; });
|
|
638
|
+
const writes = metadataWrites.get(key) || new Set(); writes.add(pending); metadataWrites.set(key, writes);
|
|
639
|
+
try {
|
|
640
|
+
await write(() => { const current = check(); if (!current.ok) throw Object.assign(new Error(current.message), { hostingResult: current }); });
|
|
641
|
+
return { ok: true };
|
|
642
|
+
} catch (error) { return error.hostingResult || fail("metadata-failed", "Metadata could not be saved; keep its unsynced browser copy."); }
|
|
643
|
+
finally { writes.delete(pending); if (!writes.size) metadataWrites.delete(key); complete(); }
|
|
644
|
+
},
|
|
645
|
+
async awaitHostingMetadata(capability, request) {
|
|
646
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
647
|
+
const key = binding.workspaceId + ":" + request.bufferId;
|
|
648
|
+
while (metadataWrites.get(key)?.size) await Promise.all([...metadataWrites.get(key)]);
|
|
649
|
+
const current = fileBinding(capability, request.generation); if (!current.ok) return current;
|
|
650
|
+
return hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch);
|
|
651
|
+
},
|
|
652
|
+
discardHostingSave(capability, request) {
|
|
653
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
654
|
+
voidCleanLeave(binding.workspaceId);
|
|
655
|
+
const authority = hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch); if (!authority.ok) return authority;
|
|
656
|
+
if (binding.entry.revision !== request.expectedRevision) return fail("conflict", "The Document changed while choosing how to resolve its save.");
|
|
657
|
+
return fileClaims.discard(binding.workspaceId, request.operationId, request.bufferId, request.confirmed === true);
|
|
658
|
+
},
|
|
659
|
+
hostingAuthority(capability, generation, bufferId, documentEpoch) {
|
|
660
|
+
const binding = scope(capability);
|
|
661
|
+
if (!hosting || !binding) return fail("forbidden", "The owning editing connection is required.");
|
|
662
|
+
const viewError = hostingViewError(capability, binding, generation); if (viewError) return viewError;
|
|
663
|
+
return hosting.checkAuthority(binding.workspaceId, bufferId, documentEpoch);
|
|
664
|
+
},
|
|
665
|
+
hostingWriteCheck(capability, bufferId, path, generation, documentEpoch) {
|
|
666
|
+
const binding = scope(capability);
|
|
667
|
+
if (!hosting || !binding) return fail("forbidden", "The owning editing connection is required before saving.");
|
|
668
|
+
const viewError = hostingViewError(capability, binding, generation); if (viewError) return viewError;
|
|
669
|
+
if (options.hosting.requireView || documentEpoch !== undefined) {
|
|
670
|
+
const authority = hosting.checkAuthority(binding.workspaceId, bufferId, documentEpoch); if (!authority.ok) return authority;
|
|
671
|
+
}
|
|
672
|
+
return hosting.checkWrite(binding.workspaceId, bufferId, path);
|
|
673
|
+
},
|
|
674
|
+
// Extension-local coordination only. Never exposed as a page capability,
|
|
675
|
+
// file grant, checkpoint or writable Document owner.
|
|
676
|
+
checkUnownedHostedFile(path) {
|
|
677
|
+
if (!hosting || !fileClaims) return fail("hosting-unavailable", "Document ownership cannot be checked; nothing was written.");
|
|
678
|
+
if (typeof path !== "string" || !isAbsolute(path) || path.includes("\0")) return fail("identity-unknown", "An absolute HTML export identity is required; nothing was written.");
|
|
679
|
+
const requested = resolve(path), unowned = hosting.checkUnownedFile(requested);
|
|
680
|
+
return unowned.ok ? fileClaims.checkUnclaimedFile(requested) : unowned;
|
|
681
|
+
},
|
|
682
|
+
hostingOwner(capability, path) {
|
|
683
|
+
if (!hosting || !scope(capability)) return fail("forbidden", "Document hosting is unavailable for this page.");
|
|
684
|
+
const held = fileClaims.owner(path);
|
|
685
|
+
return { ok: true, owner: held ? hosting.owner(held.bufferId) : hosting.findFile(path) };
|
|
686
|
+
},
|
|
687
|
+
/** @returns {{ok: true, workspaceId: string, bufferId: string} | {ok: false, reason?: string, message?: string}} */
|
|
688
|
+
hostingExportAuthority(capability, request) {
|
|
689
|
+
const binding = fileBinding(capability, request?.generation); if (!binding.ok) return binding;
|
|
690
|
+
if (!request || !/^[a-zA-Z0-9_-]{1,128}$/.test(request.bufferId || "") || !Number.isSafeInteger(request.documentEpoch) || request.documentEpoch < 1
|
|
691
|
+
|| typeof request.expectedRevision !== "string" || binding.entry.revision !== request.expectedRevision) return fail("conflict", "The originating export checkpoint changed; current work was kept.");
|
|
692
|
+
const authority = hosting.checkAuthority(binding.workspaceId, request.bufferId, request.documentEpoch); if (!authority.ok) return authority;
|
|
693
|
+
return { ok: true, workspaceId: binding.workspaceId, bufferId: request.bufferId };
|
|
694
|
+
},
|
|
695
|
+
hostingViewAuthority(capability, generation) { const binding = fileBinding(capability, generation); return binding.ok ? { ok: true } : binding; },
|
|
696
|
+
release(capability) { return { ok: true, released: capabilities.delete(capability) }; },
|
|
697
|
+
clear() { entries.clear(); capabilities.clear(); hostingViews.clear(); seenHostingViews.clear(); cleanLeaves.clear(); releasedGenerations.clear(); copyReceipts.clear(); launchReceipts.clear(); openReceipts.clear(); hosting?.clear(); fileClaims?.clear(); },
|
|
698
|
+
get size() { cleanup(); return entries.size; },
|
|
699
|
+
});
|
|
700
|
+
}
|