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,663 @@
|
|
|
1
|
+
import { STUDIO_BUFFER_LIMITS, createStudioBuffer, createStudioBufferStore, validateStudioBufferWorkspace } from "./studio-buffer-store.js";
|
|
2
|
+
import { createStudioBufferRecoveryStorage, migrateStudioWorkspaceV1 } from "./studio-buffer-recovery.js";
|
|
3
|
+
import { isStudioPromptDocumentBufferState, needsStudioLegacyPromptChoice, prepareStudioPromptDocumentWorkspace } from "./studio-buffer-switching.js";
|
|
4
|
+
import { reconcileStudioHostingLineage } from "./studio-buffer-lineage.js";
|
|
5
|
+
import { createStudioDocumentCopyIntent, createStudioDocumentEscrow } from "./studio-document-escrow.js";
|
|
6
|
+
import { createStudioDocumentLaunchIntent } from "./studio-document-launch-client.js";
|
|
7
|
+
import { createStudioDocumentOpenStorage } from "./studio-document-open-storage.js";
|
|
8
|
+
|
|
9
|
+
const fail = (reason, message) => ({ ok: false, reason, message });
|
|
10
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
11
|
+
|
|
12
|
+
function literalStudioBufferLabel(source) {
|
|
13
|
+
// Provenance is not a link or a resource/terminal grant. Separate boundary
|
|
14
|
+
// backticks from delimiters; Markdown removes the paired padding.
|
|
15
|
+
const name = source.sourceState.path?.split(/[/\\]/).at(-1) || source.sourceState.label || "Document";
|
|
16
|
+
const label = JSON.stringify(name).slice(1, -1).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
17
|
+
const ticks = "`".repeat(1 + Math.max(0, ...(label.match(/`+/g) || []).map(run => run.length)));
|
|
18
|
+
const padding = label.startsWith("`") || label.endsWith("`") ? " " : "";
|
|
19
|
+
return ticks + padding + label + padding + ticks;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function projectStudioBufferEditor(state) {
|
|
23
|
+
const b = state.buffers.find((entry) => entry.id === state.selectedBufferId);
|
|
24
|
+
return { version: 1, savedAt: state.savedAt, sourceState: b.sourceState, diskRevision: b.diskRevision,
|
|
25
|
+
resourceDir: b.resourceDir, text: b.text, editorView: b.view.editorView, rightView: b.view.rightView,
|
|
26
|
+
editorLanguage: b.view.editorLanguage, followLatest: b.view.followLatest, responseHistoryIndex: b.view.responseHistoryIndex,
|
|
27
|
+
selectionStart: b.view.selectionStart, selectionEnd: b.view.selectionEnd, scrollTop: b.view.scrollTop };
|
|
28
|
+
}
|
|
29
|
+
export function isStudioSingleEditorBufferState(state) {
|
|
30
|
+
// Until tabs exist, never adopt a collection whose other valuable buffers would be unreachable.
|
|
31
|
+
const hidden = state.buffers.filter((b) => b.id !== state.selectedBufferId);
|
|
32
|
+
return hidden.length === 0 || (hidden.length === 1 && hidden[0].id === state.activePromptId
|
|
33
|
+
&& hidden[0].role === "prompt" && hidden[0].text === "" && hidden[0].baselineText === ""
|
|
34
|
+
&& hidden[0].sourceState.path === null && hidden[0].sourceState.source === "blank"
|
|
35
|
+
&& hidden[0].metadata.reviewNotesKey === null && hidden[0].metadata.scratchpadKey === null);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Single-editor adapter by default. The separate full-view switching opt-in binds
|
|
39
|
+
// two buffers to that surface; it never submits text or changes browser navigation.
|
|
40
|
+
export function createStudioBufferClient(options) {
|
|
41
|
+
const { workspaceId, mode, storage, makeBufferId, canRestore, readRemote, writeRemote, readLegacyRemote } = options;
|
|
42
|
+
const expected = { workspaceId, mode };
|
|
43
|
+
const switching = options.switching === true && mode === "full";
|
|
44
|
+
const now = options.now ?? Date.now;
|
|
45
|
+
const expiry = (result, startedAt) => Number.isSafeInteger(result.expiresInMs) && result.expiresInMs > 0
|
|
46
|
+
? startedAt + Math.min(result.expiresInMs, 24 * 60 * 60 * 1000) : 0;
|
|
47
|
+
const local = createStudioBufferRecoveryStorage({ storage, ...expected });
|
|
48
|
+
const ackKey = local.key + ":serverAck";
|
|
49
|
+
const escrow = options.hosting === true ? createStudioDocumentEscrow(storage, local.key + ":handoff") : null;
|
|
50
|
+
const opening = options.hosting === true ? createStudioDocumentOpenStorage(storage, local.key + ":open", expected) : null;
|
|
51
|
+
let store = null, localRaw = null, localEnabled = true, localBlocked = false;
|
|
52
|
+
let remoteRevision = null, remoteEnabled = true, remoteBlocked = false, documentEpochs = {}, pendingHostingSaves = [];
|
|
53
|
+
const receiveAuthorities = result => {
|
|
54
|
+
if (options.hosting !== true) return;
|
|
55
|
+
documentEpochs = Object.fromEntries(Object.entries(result.documents || {}).filter(([, epoch]) => Number.isSafeInteger(epoch) && epoch > 0));
|
|
56
|
+
if (Array.isArray(result.pendingSaves)) pendingHostingSaves = result.pendingSaves;
|
|
57
|
+
issue("file-save", pendingHostingSaves.length ? fail("save-ack-pending", "A disk save needs a backing decision. Use Resolve disk save; current text and the disk output were kept.") : null);
|
|
58
|
+
options.onHostingAuthority?.();
|
|
59
|
+
};
|
|
60
|
+
let queued = null, draining = null, disposed = false, initialized = false, recoveryEpoch = 0;
|
|
61
|
+
let lastRemoteSnapshot = null, lastRemoteExpiresAt = 0, unrepresentedEditor = false, hostingFreeze = null;
|
|
62
|
+
let hostingRecoveryUnverified = options.hosting === true, hostingUnrepresented = false;
|
|
63
|
+
const frozen = () => fail("hosting-frozen", "This editor is waiting for a document move outcome. Current text was kept.");
|
|
64
|
+
const issues = new Map();
|
|
65
|
+
function issue(channel, result) {
|
|
66
|
+
if (result) issues.set(channel, result.message || "Recovery is unavailable."); else issues.delete(channel);
|
|
67
|
+
options.onIssue?.([...issues.values()].join(" "));
|
|
68
|
+
}
|
|
69
|
+
function ack() {
|
|
70
|
+
try {
|
|
71
|
+
const value = JSON.parse(storage.getItem(ackKey) || "null");
|
|
72
|
+
return value?.version === 1 && /^[a-f0-9]{48}$/.test(value.remoteRevision)
|
|
73
|
+
&& Number.isSafeInteger(value.workspaceRevision) ? value : null;
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
function rememberAck(revision, workspaceRevision) {
|
|
77
|
+
// Do not attach a new ancestry marker to browser data we could not read.
|
|
78
|
+
if (!localEnabled) return;
|
|
79
|
+
try {
|
|
80
|
+
storage.setItem(ackKey, JSON.stringify({ version: 1, remoteRevision: revision, workspaceRevision }));
|
|
81
|
+
issue("ack", null);
|
|
82
|
+
} catch { issue("ack", fail("storage-unavailable", "Server recovery was acknowledged, but its browser checkpoint could not be stored.")); }
|
|
83
|
+
}
|
|
84
|
+
function validate(state, checkCompatibility = true) {
|
|
85
|
+
const checked = validateStudioBufferWorkspace(state, expected);
|
|
86
|
+
if (!checked.ok) return checked;
|
|
87
|
+
if (checkCompatibility && !(switching ? isStudioPromptDocumentBufferState(checked.state) : isStudioSingleEditorBufferState(checked.state))) return fail("unsupported-collection", switching
|
|
88
|
+
? "Studio shows one Prompt and one Document. Other buffers were retained; export them or use a compatible build."
|
|
89
|
+
: "Recovery contains other buffers that this single-editor build cannot display. They were retained; use a compatible build or copy them before starting fresh.");
|
|
90
|
+
if (checkCompatibility && !canRestore(projectStudioBufferEditor(checked.state))) return fail("wrong-document", "Recovery belongs to another document. It was retained; open a fresh browser tab for this document.");
|
|
91
|
+
return checked;
|
|
92
|
+
}
|
|
93
|
+
function saveLocal(state) {
|
|
94
|
+
if (!localEnabled || localBlocked) return;
|
|
95
|
+
const result = local.write(state, { expectedRaw: localRaw });
|
|
96
|
+
if (result.ok) { localRaw = result.raw; hostingRecoveryUnverified = false; issue("local", null); }
|
|
97
|
+
else {
|
|
98
|
+
if (options.hosting === true) hostingRecoveryUnverified = true;
|
|
99
|
+
if (!["storage-write-failed", "storage-unverified"].includes(result.reason)) localBlocked = true;
|
|
100
|
+
issue("local", result);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
async function drain() {
|
|
105
|
+
while (queued && remoteEnabled && !remoteBlocked && !disposed) {
|
|
106
|
+
const state = queued; queued = null;
|
|
107
|
+
let result;
|
|
108
|
+
const requestStartedAt = now();
|
|
109
|
+
const operationEpoch = recoveryEpoch;
|
|
110
|
+
try { result = await writeRemote(state, remoteRevision, documentEpochs); }
|
|
111
|
+
catch { result = fail("unavailable", "Server recovery could not be acknowledged. Browser recovery is retained; reload before retrying the server."); }
|
|
112
|
+
if (disposed || operationEpoch !== recoveryEpoch) break;
|
|
113
|
+
if (!result.ok || result.workspaceRevision !== state.revision || typeof result.revision !== "string" || !/^[a-f0-9]{48}$/.test(result.revision)) {
|
|
114
|
+
remoteBlocked = true;
|
|
115
|
+
lastRemoteSnapshot = null;
|
|
116
|
+
issue("remote", result.ok ? fail("invalid-ack", "Server recovery acknowledgement did not match this snapshot.") : result);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
remoteRevision = result.revision;
|
|
120
|
+
lastRemoteSnapshot = state;
|
|
121
|
+
lastRemoteExpiresAt = expiry(result, requestStartedAt);
|
|
122
|
+
receiveAuthorities(result);
|
|
123
|
+
rememberAck(remoteRevision, state.revision);
|
|
124
|
+
issue("remote", null);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function publish(state) {
|
|
128
|
+
if (!remoteEnabled || remoteBlocked || disposed || hostingFreeze) return;
|
|
129
|
+
queued = state;
|
|
130
|
+
if (!draining) draining = drain().finally(() => { draining = null; if (queued && !remoteBlocked && !disposed) publish(queued); });
|
|
131
|
+
}
|
|
132
|
+
function capture(editor, baselineText, extra = {}) {
|
|
133
|
+
if (disposed) return fail("disposed", "This recovery owner has closed.");
|
|
134
|
+
if (hostingUnrepresented) return frozen();
|
|
135
|
+
if (hostingFreeze && store.snapshot().selectedBufferId === hostingFreeze.bufferId) {
|
|
136
|
+
const current = store.get(store.snapshot().selectedBufferId);
|
|
137
|
+
// Selecting/copying read-only text is harmless. A late producer changing
|
|
138
|
+
// content or metadata is not: never apply a move over that unseen work.
|
|
139
|
+
if (editor.text !== current.text || !same(editor.sourceState, current.sourceState) || editor.diskRevision !== current.diskRevision
|
|
140
|
+
|| editor.resourceDir !== current.resourceDir || (current.sourceState.path && baselineText !== current.baselineText)
|
|
141
|
+
|| !same({ ...current.metadata, ...extra.metadata }, current.metadata)) unrepresentedEditor = hostingUnrepresented = true;
|
|
142
|
+
return frozen();
|
|
143
|
+
}
|
|
144
|
+
if (!store) return fail("not-ready", "Buffer recovery is not initialized.");
|
|
145
|
+
unrepresentedEditor = true;
|
|
146
|
+
const state = store.snapshot(), current = store.get(state.selectedBufferId);
|
|
147
|
+
let next;
|
|
148
|
+
try {
|
|
149
|
+
next = createStudioBuffer({ ...current, text: editor.text, sourceState: editor.sourceState,
|
|
150
|
+
baselineText: editor.sourceState.path ? baselineText : (current.sourceState.path ? null : current.baselineText),
|
|
151
|
+
diskRevision: editor.diskRevision, resourceDir: editor.resourceDir,
|
|
152
|
+
view: { ...current.view, editorView: editor.editorView, rightView: editor.rightView, editorLanguage: editor.editorLanguage,
|
|
153
|
+
followLatest: editor.followLatest, responseHistoryIndex: editor.responseHistoryIndex,
|
|
154
|
+
selectionStart: editor.selectionStart, selectionEnd: editor.selectionEnd, scrollTop: editor.scrollTop,
|
|
155
|
+
...extra.view }, metadata: { ...current.metadata, ...extra.metadata } });
|
|
156
|
+
} catch (error) { return fail(error.reason || "invalid-state", error.message); }
|
|
157
|
+
if (current.text === next.text && current.baselineText === next.baselineText && current.diskRevision === next.diskRevision
|
|
158
|
+
&& current.resourceDir === next.resourceDir && same(current.sourceState, next.sourceState)
|
|
159
|
+
&& same(current.view, next.view) && same(current.metadata, next.metadata)) { unrepresentedEditor = false; return { ok: true }; }
|
|
160
|
+
const { id, role, revision, ...patch } = next;
|
|
161
|
+
const result = store.update(current.id, current.revision, patch);
|
|
162
|
+
unrepresentedEditor = !result.ok;
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
function documentAppendPlan() {
|
|
166
|
+
if (hostingFreeze) return frozen();
|
|
167
|
+
if (!switching) return fail("switching-disabled", "Internal document copying is not enabled in this view.");
|
|
168
|
+
if (disposed || !store || unrepresentedEditor) return fail("not-ready", "Keep the current editor visible until it can be represented safely in recovery.");
|
|
169
|
+
const state = store.snapshot(), source = store.get(state.selectedBufferId), prompt = store.get(state.activePromptId);
|
|
170
|
+
if (source?.role !== "document" || prompt?.role !== "prompt") return fail("wrong-buffer", "Select Document to add its full editor text to Prompt.");
|
|
171
|
+
if (source.view.editorView !== "markdown") return fail("wrong-view", "Switch Document to Editor to add its source text. Preview content is not copied.");
|
|
172
|
+
if (!source.text.length) return fail("empty-document", "Document is empty. Nothing to add.");
|
|
173
|
+
const separator = prompt.text ? "\n\n" : "", header = "From " + literalStudioBufferLabel(source) + " (whole document):\n\n";
|
|
174
|
+
const addedCharacters = separator.length + header.length + source.text.length;
|
|
175
|
+
const total = state.buffers.reduce((sum, b) => sum + b.text.length + (b.baselineText?.length || 0), 0);
|
|
176
|
+
const availableCharacters = Math.min(STUDIO_BUFFER_LIMITS.textChars - prompt.text.length, STUDIO_BUFFER_LIMITS.totalTextChars - total);
|
|
177
|
+
const size = { characters: source.text.length, addedCharacters, availableCharacters };
|
|
178
|
+
if (addedCharacters > availableCharacters) return { ...size, ...fail("limit-exceeded", "The full document and label exceed the remaining capacity. Nothing was added or truncated.") };
|
|
179
|
+
return { ok: true, ...size, source, prompt, separator, header };
|
|
180
|
+
}
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
// admission: the server's clean-departure continuation for this connection. It only
|
|
183
|
+
// lets initialization adopt that exact, unchanged server snapshot without asking;
|
|
184
|
+
// anything else returns "needs-decision" before any local save or publish (Sol).
|
|
185
|
+
async initialize(initialEditor, baselineText, isStillCurrent = () => true, admission = null) {
|
|
186
|
+
if (disposed || initialized) return fail("invalid-lifecycle", "Create a new recovery owner before initializing again.");
|
|
187
|
+
initialized = true;
|
|
188
|
+
const openBackup = opening?.peek(); if (openBackup?.error) return openBackup.error;
|
|
189
|
+
const browser = local.read();
|
|
190
|
+
if (!browser.ok && browser.reason !== "storage-unavailable") return browser;
|
|
191
|
+
if (!browser.ok) { localEnabled = false; issue("local", browser); }
|
|
192
|
+
let remote, legacyRemote;
|
|
193
|
+
const remoteReadStartedAt = now();
|
|
194
|
+
try { remote = await readRemote(); } catch { remote = fail("unavailable", "Server recovery could not be read; server writes are paused until reload."); }
|
|
195
|
+
if (!remote.ok && remote.reason !== "unavailable") return remote;
|
|
196
|
+
if (!remote.ok) { remoteEnabled = false; issue("remote", remote); }
|
|
197
|
+
else { remoteRevision = remote.revision; receiveAuthorities(remote); }
|
|
198
|
+
if (options.hosting === true && remote.pendingMoves?.some(move => move.sourceWorkspaceId === workspaceId)) {
|
|
199
|
+
return fail("move-pending", "Resolve the source's pending move before enabling editing. Browser recovery was retained.");
|
|
200
|
+
}
|
|
201
|
+
try { legacyRemote = await readLegacyRemote(); } catch { legacyRemote = fail("unavailable", "Legacy server recovery could not be checked."); }
|
|
202
|
+
if (!legacyRemote.ok) {
|
|
203
|
+
if (legacyRemote.reason !== "unavailable") return legacyRemote;
|
|
204
|
+
remoteEnabled = false; issue("remote", legacyRemote); legacyRemote = { ok: true, state: null };
|
|
205
|
+
}
|
|
206
|
+
if (!remoteEnabled && !localEnabled) return fail("unavailable", "Neither browser nor server recovery could be read. Nothing was replaced.");
|
|
207
|
+
if (disposed || !isStillCurrent()) return fail("editor-changed", "The editor changed while recovery was loading. Current text and recovery were kept; save or copy before reloading.");
|
|
208
|
+
let localState = null, remoteState = null;
|
|
209
|
+
if (browser.ok && browser.status === "found") { const checked = validate(browser.state, false); if (!checked.ok) return checked; localState = checked.state; localRaw = browser.raw; }
|
|
210
|
+
if (remote.ok && remote.state) { const checked = validate(remote.state, false); if (!checked.ok) return checked; remoteState = checked.state; }
|
|
211
|
+
let chosen = localState || remoteState, fromInitial = false;
|
|
212
|
+
const needsDecision = () => fail("needs-decision", "Choose whether to resume this workspace here.");
|
|
213
|
+
// This tab's own browser records, read without writing: any older-format copy, or
|
|
214
|
+
// Copy/Launch tracking that is pending or unreadable, means ask (Sol, candidate67).
|
|
215
|
+
const admissionKeys = [local.key, local.legacyKey, local.key + ":copy", local.key + ":launch"];
|
|
216
|
+
const admissionBytes = () => { try { return admissionKeys.map(key => storage.getItem(key) ?? null); } catch { return null; } };
|
|
217
|
+
const trackingSettled = () => [createStudioDocumentCopyIntent(storage, local.key + ":copy"), createStudioDocumentLaunchIntent(storage, workspaceId)]
|
|
218
|
+
.every(intent => { try { const saved = intent.peek(); return !saved.error && !saved.record; } catch { return false; } });
|
|
219
|
+
const inspected = admission ? admissionBytes() : null;
|
|
220
|
+
if (admission && !(remoteEnabled && remoteState && remoteRevision === admission.revision && (!localState || same(localState, remoteState))
|
|
221
|
+
&& inspected && inspected[0] === (browser.ok ? browser.raw ?? null : null) && inspected[1] === null && trackingSettled()
|
|
222
|
+
&& !legacyRemote.state && !openBackup?.record && !escrow?.peek().record && !escrow?.peek().error && admission.isCurrent())) return needsDecision();
|
|
223
|
+
if (localState && remoteState && !same(localState, remoteState)) {
|
|
224
|
+
const checkpoint = ack();
|
|
225
|
+
if (checkpoint?.remoteRevision !== remoteRevision) {
|
|
226
|
+
const reconciled = options.hosting === true ? await reconcileStudioHostingLineage({ local: localState, remote: remoteState,
|
|
227
|
+
revision: remoteRevision, checkpoint, lineage: remote.lineage, now }) : null;
|
|
228
|
+
if (!reconciled) return fail("diverged", "Browser and server recovery differ without proven move ancestry. Both were retained; inspect the copies before replacing either.");
|
|
229
|
+
chosen = reconciled.state;
|
|
230
|
+
} else if (localState.revision > checkpoint.workspaceRevision) chosen = localState;
|
|
231
|
+
else if (remoteState.revision === checkpoint.workspaceRevision && localState.revision < checkpoint.workspaceRevision) chosen = remoteState;
|
|
232
|
+
else return fail("diverged", "Browser and server recovery disagree at the same revision. Both were retained.");
|
|
233
|
+
}
|
|
234
|
+
if (escrow && localState && chosen) {
|
|
235
|
+
const removed = localState.buffers.find(b => b.role === "document" && !chosen.buffers.some(n => n.id === b.id));
|
|
236
|
+
if (removed && (removed.text || removed.sourceState.path || removed.metadata.reviewNotesKey || removed.metadata.scratchpadKey)) {
|
|
237
|
+
const receipt = remote.lineage?.find(r => r.slot?.beforeId === removed.id && r.move?.moveId);
|
|
238
|
+
if (!receipt) return fail("diverged", "The removed document has no verified transfer receipt. Both copies were kept.");
|
|
239
|
+
const kept = escrow.capture(receipt.move.moveId, removed); if (!kept.ok) return kept;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const legacy = [];
|
|
243
|
+
if (browser.ok && browser.legacyRaw !== null && browser.legacyRaw !== undefined) {
|
|
244
|
+
try { legacy.push(JSON.parse(browser.legacyRaw)); } catch { return fail("invalid-json", "Legacy browser recovery is malformed; it was retained."); }
|
|
245
|
+
}
|
|
246
|
+
if (legacyRemote.state) legacy.push(legacyRemote.state);
|
|
247
|
+
if (!chosen && legacy.length === 0 && localEnabled) {
|
|
248
|
+
try {
|
|
249
|
+
const raw = storage.getItem("piStudio.workspaceState.v1");
|
|
250
|
+
if (raw) return fail("unscoped-legacy", "Older recovery without a tab identity exists. It was retained; recover/copy it with the previous build before starting a fresh test profile.");
|
|
251
|
+
} catch { return fail("storage-unavailable", "Older browser recovery could not be checked."); }
|
|
252
|
+
}
|
|
253
|
+
for (const value of legacy) {
|
|
254
|
+
const migrated = migrateStudioWorkspaceV1(value, { ...expected, makeBufferId });
|
|
255
|
+
if (!migrated.ok) return migrated;
|
|
256
|
+
if (localState || remoteState) {
|
|
257
|
+
if (migrated.state.savedAt > chosen.savedAt) return fail("legacy-newer", "Newer single-editor recovery exists; it was retained for an explicit decision.");
|
|
258
|
+
// Older retained v1 may describe the draft before Save As. Its old source
|
|
259
|
+
// must not veto newer, validated v2 that matches this URL.
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!chosen || migrated.state.savedAt > chosen.savedAt) chosen = migrated.state;
|
|
263
|
+
else if (migrated.state.savedAt === chosen.savedAt && !same(projectStudioBufferEditor(migrated.state), projectStudioBufferEditor(chosen))) {
|
|
264
|
+
return fail("legacy-diverged", "Legacy browser and server recovery disagree at the same timestamp. Both were retained.");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!chosen) {
|
|
268
|
+
const migrated = migrateStudioWorkspaceV1(initialEditor, { ...expected, makeBufferId });
|
|
269
|
+
if (!migrated.ok) return migrated;
|
|
270
|
+
chosen = migrated.state; fromInitial = true;
|
|
271
|
+
}
|
|
272
|
+
let checked = validate(chosen); if (!checked.ok) return checked;
|
|
273
|
+
if (switching) {
|
|
274
|
+
let roleChoice = !localState && !remoteState ? "visible-prompt" : undefined;
|
|
275
|
+
if ((localState || remoteState) && needsStudioLegacyPromptChoice(checked.state)) {
|
|
276
|
+
if (admission) return needsDecision();
|
|
277
|
+
if (typeof options.decideLegacyPromptRole !== "function") return fail("role-decision-required", "Choose whether the recovered file is the Prompt or a separate document. No roles or text were changed.");
|
|
278
|
+
roleChoice = await options.decideLegacyPromptRole(checked.state);
|
|
279
|
+
if (!["visible-prompt", "keep-roles"].includes(roleChoice)) return fail("role-decision-cancelled", "Recovery role choice cancelled. Existing text and copies were kept.");
|
|
280
|
+
}
|
|
281
|
+
checked = prepareStudioPromptDocumentWorkspace(checked.state, { makeBufferId, roleChoice, now });
|
|
282
|
+
if (!checked.ok) return checked;
|
|
283
|
+
}
|
|
284
|
+
if (admission && (!same(checked.state, remoteState) || !same(admissionBytes(), inspected) || !admission.isCurrent())) return needsDecision();
|
|
285
|
+
if (disposed || !isStillCurrent()) return fail("editor-changed", "The editor changed while recovery was loading. Current text and recovery were kept; save or copy before reloading.");
|
|
286
|
+
store = createStudioBufferStore(checked.state);
|
|
287
|
+
if (openBackup?.record) hostingFreeze = { kind: "open", bufferId: openBackup.record.request.bufferId, startedAt: remoteReadStartedAt };
|
|
288
|
+
if (fromInitial && initialEditor.sourceState.path) {
|
|
289
|
+
const result = capture(initialEditor, baselineText); if (!result.ok) { store = null; return result; }
|
|
290
|
+
}
|
|
291
|
+
const state = store.snapshot();
|
|
292
|
+
if (localEnabled) {
|
|
293
|
+
const written = localState ? saveLocal(state) : local.adopt(state, { expectedLegacyRaw: browser.ok ? browser.legacyRaw : null });
|
|
294
|
+
if (written?.ok) { localRaw = written.raw; hostingRecoveryUnverified = false; }
|
|
295
|
+
else if (written) {
|
|
296
|
+
issue("local", written);
|
|
297
|
+
if (written.reason !== "storage-write-failed") { store = null; return written; }
|
|
298
|
+
localBlocked = true; // Native storage quota/permission failures leave the old value intact.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!remoteState || !same(remoteState, state)) publish(state);
|
|
302
|
+
else { lastRemoteSnapshot = state; lastRemoteExpiresAt = expiry(remote, remoteReadStartedAt); rememberAck(remoteRevision, state.revision); }
|
|
303
|
+
const backup = escrow?.peek().record;
|
|
304
|
+
if (backup && !hostingRecoveryUnverified && remoteState) {
|
|
305
|
+
const owned = remoteState.buffers.find(b => b.id === backup.document.id);
|
|
306
|
+
if (owned && same(owned, store.get(owned.id))) escrow.resolve(backup.moveId);
|
|
307
|
+
}
|
|
308
|
+
const selected = store.get(state.selectedBufferId);
|
|
309
|
+
return { ok: true, restored: !fromInitial, editor: projectStudioBufferEditor(state), baselineText: selected.baselineText };
|
|
310
|
+
},
|
|
311
|
+
capture,
|
|
312
|
+
select(id) {
|
|
313
|
+
if (hostingFreeze) return frozen();
|
|
314
|
+
if (!switching) return fail("switching-disabled", "Internal switching is not enabled in this view.");
|
|
315
|
+
if (disposed || !store || unrepresentedEditor) return fail("not-ready", "Keep the current editor visible until it can be represented safely in recovery.");
|
|
316
|
+
const result = store.select(id);
|
|
317
|
+
if (result.ok) { saveLocal(store.snapshot()); publish(store.snapshot()); }
|
|
318
|
+
return result;
|
|
319
|
+
},
|
|
320
|
+
replace(id, expectedRevision, editor, baselineText, extra = {}) {
|
|
321
|
+
if (hostingFreeze && id === hostingFreeze.bufferId) return frozen();
|
|
322
|
+
if (!switching && !(options.hosting === true && mode === "editor-only")) return fail("switching-disabled", "Internal switching is not enabled in this view.");
|
|
323
|
+
if (disposed || !store || unrepresentedEditor) return fail("not-ready", "Keep the current editor visible until it can be represented safely in recovery.");
|
|
324
|
+
const current = store.get(id);
|
|
325
|
+
if (!current || current.revision !== expectedRevision) return fail("stale-buffer", "The destination changed. Both buffers were kept; retry explicitly.");
|
|
326
|
+
let next;
|
|
327
|
+
try {
|
|
328
|
+
next = createStudioBuffer({ ...current, text: editor.text, sourceState: editor.sourceState,
|
|
329
|
+
baselineText: editor.sourceState.path ? baselineText : "", diskRevision: editor.diskRevision, resourceDir: editor.resourceDir,
|
|
330
|
+
view: { ...current.view, editorView: editor.editorView, rightView: editor.rightView, editorLanguage: editor.editorLanguage,
|
|
331
|
+
followLatest: editor.followLatest, responseHistoryIndex: editor.responseHistoryIndex,
|
|
332
|
+
selectionStart: editor.selectionStart, selectionEnd: editor.selectionEnd, selectionDirection: "none", scrollTop: editor.scrollTop,
|
|
333
|
+
previewScrollTop: 0, rightScrollTop: 0, ...extra.view },
|
|
334
|
+
metadata: { annotationsEnabled: null, reviewNotesKey: null, scratchpadKey: null, ...extra.metadata } });
|
|
335
|
+
} catch (error) { return fail(error.reason || "invalid-state", error.message); }
|
|
336
|
+
const { id: nextId, role, revision, ...patch } = next;
|
|
337
|
+
const result = store.update(id, expectedRevision, patch);
|
|
338
|
+
if (result.ok) { saveLocal(store.snapshot()); publish(store.snapshot()); }
|
|
339
|
+
return result;
|
|
340
|
+
},
|
|
341
|
+
appendSelectionToPrompt({ sourceId, sourceRevision, promptId, promptRevision, start, end } = {}) {
|
|
342
|
+
if (hostingFreeze) return frozen();
|
|
343
|
+
if (!switching) return fail("switching-disabled", "Internal selection copying is not enabled in this view.");
|
|
344
|
+
if (disposed || !store || unrepresentedEditor) return fail("not-ready", "Keep the current editor visible until it can be represented safely in recovery.");
|
|
345
|
+
const state = store.snapshot(), source = store.get(sourceId), prompt = store.get(promptId);
|
|
346
|
+
if (state.selectedBufferId !== sourceId || source?.role !== "document" || source.revision !== sourceRevision
|
|
347
|
+
|| state.activePromptId !== promptId || prompt?.role !== "prompt" || prompt.revision !== promptRevision) {
|
|
348
|
+
return fail("stale-buffer", "The Document or Prompt changed. Nothing was added; select and retry explicitly.");
|
|
349
|
+
}
|
|
350
|
+
if (source.view.editorView !== "markdown" || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)
|
|
351
|
+
|| start < 0 || end <= start || end > source.text.length || start !== source.view.selectionStart || end !== source.view.selectionEnd) {
|
|
352
|
+
return fail("invalid-selection", "Select text in the Document editor. Hidden or stale selections were not copied.");
|
|
353
|
+
}
|
|
354
|
+
const literalLabel = literalStudioBufferLabel(source);
|
|
355
|
+
const lineAt = offset => {
|
|
356
|
+
let line = 1;
|
|
357
|
+
for (const match of source.text.matchAll(/\r\n|\r|\n/g)) { if (match.index + match[0].length > offset) break; line++; }
|
|
358
|
+
return line;
|
|
359
|
+
};
|
|
360
|
+
const first = lineAt(start), last = lineAt(end - 1);
|
|
361
|
+
const header = "From " + literalLabel + " (" + (first === last ? "line " + first : "lines " + first + "–" + last) + "):\n\n";
|
|
362
|
+
const selection = source.text.slice(start, end);
|
|
363
|
+
// Text-only update: never use replace(), select(), copy source metadata,
|
|
364
|
+
// move views/carets or confer a submitted/terminal-cleanup baseline.
|
|
365
|
+
const result = store.update(promptId, promptRevision, { text: prompt.text + (prompt.text ? "\n\n" : "") + header + selection });
|
|
366
|
+
if (!result.ok) return result; // Strict per-buffer/aggregate bounds; no partial insertion.
|
|
367
|
+
saveLocal(store.snapshot()); publish(store.snapshot());
|
|
368
|
+
return { ok: true, characters: selection.length };
|
|
369
|
+
},
|
|
370
|
+
documentAppendInfo() {
|
|
371
|
+
// Read-only size/eligibility check; never capture or expose source text.
|
|
372
|
+
const { source, prompt, separator, header, ...info } = documentAppendPlan();
|
|
373
|
+
return info;
|
|
374
|
+
},
|
|
375
|
+
appendDocumentToPrompt(intent) {
|
|
376
|
+
const plan = documentAppendPlan();
|
|
377
|
+
if (!plan.ok) return plan;
|
|
378
|
+
const { source, prompt, separator, header } = plan;
|
|
379
|
+
if (!intent || intent.sourceId !== source.id || intent.sourceRevision !== source.revision
|
|
380
|
+
|| intent.promptId !== prompt.id || intent.promptRevision !== prompt.revision) {
|
|
381
|
+
return fail("stale-buffer", "The Document or Prompt changed. Nothing was added; retry explicitly.");
|
|
382
|
+
}
|
|
383
|
+
// Separate from selection copying: no temporary range, disk read,
|
|
384
|
+
// source metadata adoption, binding or submission. Store bounds recheck
|
|
385
|
+
// the complete resulting workspace before its single text-only update.
|
|
386
|
+
const result = store.update(prompt.id, prompt.revision, { text: prompt.text + separator + header + source.text });
|
|
387
|
+
if (!result.ok) return result;
|
|
388
|
+
saveLocal(store.snapshot()); publish(store.snapshot());
|
|
389
|
+
return { ok: true, characters: plan.characters, addedCharacters: plan.addedCharacters };
|
|
390
|
+
},
|
|
391
|
+
// Undo a just-made Add to Prompt: only while the Document is still selected and the Prompt is
|
|
392
|
+
// exactly as the add left it, so later work is never overwritten (Oliver, 11 Oct).
|
|
393
|
+
undoPromptAppend({ promptId, revision, addedText, previousText } = {}) {
|
|
394
|
+
if (hostingFreeze) return frozen();
|
|
395
|
+
if (!switching) return fail("switching-disabled", "Internal selection copying is not enabled in this view.");
|
|
396
|
+
if (disposed || !store || unrepresentedEditor) return fail("not-ready", "Keep the current editor visible until it can be represented safely in recovery.");
|
|
397
|
+
const state = store.snapshot(), prompt = store.get(promptId), selected = store.get(state.selectedBufferId);
|
|
398
|
+
if (selected?.role !== "document" || state.activePromptId !== promptId || prompt?.role !== "prompt"
|
|
399
|
+
|| prompt.revision !== revision || prompt.text !== addedText || typeof previousText !== "string") {
|
|
400
|
+
return fail("stale-buffer", "The Prompt changed after the add. Nothing was undone.");
|
|
401
|
+
}
|
|
402
|
+
const result = store.update(promptId, revision, { text: previousText });
|
|
403
|
+
if (!result.ok) return result;
|
|
404
|
+
saveLocal(store.snapshot()); publish(store.snapshot());
|
|
405
|
+
return { ok: true };
|
|
406
|
+
},
|
|
407
|
+
persist(editor, baselineText, extra) {
|
|
408
|
+
const result = capture(editor, baselineText, extra);
|
|
409
|
+
if (!result.ok) { if (result.reason !== "hosting-frozen") issue("capture", result); return result; }
|
|
410
|
+
issue("capture", null);
|
|
411
|
+
saveLocal(store.snapshot()); publish(store.snapshot());
|
|
412
|
+
return { ok: true };
|
|
413
|
+
},
|
|
414
|
+
async retry(isStillCurrent = () => true) {
|
|
415
|
+
if (hostingFreeze) return frozen();
|
|
416
|
+
if (disposed || !store || unrepresentedEditor) return fail("not-ready", "Choose a recovery copy or export current text before retrying.");
|
|
417
|
+
// Suspend the old upload epoch before reading. Never treat a retry as
|
|
418
|
+
// permission to adopt stored text over the live editor or a newer branch.
|
|
419
|
+
recoveryEpoch++; const epoch = recoveryEpoch;
|
|
420
|
+
remoteBlocked = true; queued = null; lastRemoteSnapshot = null;
|
|
421
|
+
if (draining) await draining;
|
|
422
|
+
const state = store.snapshot();
|
|
423
|
+
let remote;
|
|
424
|
+
try { remote = await readRemote(); } catch { remote = fail("unavailable", "Server recovery is still unavailable. Current text was kept."); }
|
|
425
|
+
if (disposed || epoch !== recoveryEpoch || !isStillCurrent() || store.snapshot() !== state) return fail("editor-changed", "Editor or connection changed during retry. Nothing was replaced.");
|
|
426
|
+
if (!remote.ok) { issue("remote", remote); return remote; }
|
|
427
|
+
if (remote.state) { const checked = validate(remote.state, false); if (!checked.ok) return checked; }
|
|
428
|
+
if (remote.state && remote.revision !== remoteRevision && !same(remote.state, state)) {
|
|
429
|
+
const result = fail("conflict", "Server recovery changed. Inspect the copies before choosing a fresh workspace."); issue("remote", result); return result;
|
|
430
|
+
}
|
|
431
|
+
const browser = local.read();
|
|
432
|
+
if (!browser.ok) {
|
|
433
|
+
issue("local", browser);
|
|
434
|
+
if (browser.reason !== "storage-unavailable") return browser;
|
|
435
|
+
localEnabled = false; // Server-only recovery is supported; never write unseen browser data/ancestry.
|
|
436
|
+
} else {
|
|
437
|
+
if ((browser.raw ?? null) !== localRaw) return fail("storage-changed", "Browser recovery changed. Inspect the copies before retrying.");
|
|
438
|
+
const written = local.write(state, { expectedRaw: localRaw });
|
|
439
|
+
if (!written.ok && written.reason !== "storage-write-failed") { issue("local", written); return written; }
|
|
440
|
+
localEnabled = true; localBlocked = false;
|
|
441
|
+
if (written.ok) { localRaw = written.raw; hostingRecoveryUnverified = false; issue("local", null); } else issue("local", written);
|
|
442
|
+
}
|
|
443
|
+
if (disposed || epoch !== recoveryEpoch || !isStillCurrent() || store.snapshot() !== state) return fail("editor-changed", "Editor or connection changed during retry. Nothing was replaced.");
|
|
444
|
+
remoteRevision = remote.revision; remoteEnabled = true; remoteBlocked = false; receiveAuthorities(remote);
|
|
445
|
+
publish(state);
|
|
446
|
+
if (draining) await draining;
|
|
447
|
+
if (disposed || epoch !== recoveryEpoch || !isStillCurrent() || store.snapshot() !== state) return fail("editor-changed", "Editor or connection changed during retry. Current text was kept.");
|
|
448
|
+
return lastRemoteSnapshot === state && now() < lastRemoteExpiresAt ? { ok: true } : fail("unacknowledged", "Recovery is not currently acknowledged. Save or export before closing.");
|
|
449
|
+
},
|
|
450
|
+
freezeHosting({ moveId, direction, bufferId }) {
|
|
451
|
+
if (options.hosting !== true) return fail("hosting-disabled", "Document hosting is not enabled in this view.");
|
|
452
|
+
if (hostingFreeze) return frozen();
|
|
453
|
+
if (disposed || !store || unrepresentedEditor || draining || queued || remoteBlocked || store.snapshot() !== lastRemoteSnapshot || now() >= lastRemoteExpiresAt) {
|
|
454
|
+
return fail("unacknowledged", "Checkpoint the current editor before moving a document.");
|
|
455
|
+
}
|
|
456
|
+
if (localEnabled) {
|
|
457
|
+
const browser = local.read();
|
|
458
|
+
if (!browser.ok) return browser;
|
|
459
|
+
if ((browser.raw ?? null) !== localRaw) return fail("storage-changed", "Browser recovery changed. Inspect the copies before moving.");
|
|
460
|
+
}
|
|
461
|
+
const state = store.snapshot(), entry = store.get(bufferId);
|
|
462
|
+
if (typeof moveId !== "string" || !/^[a-f0-9]{48}$/.test(moveId) || !["incoming", "outgoing"].includes(direction)
|
|
463
|
+
|| entry?.role !== "document" || (direction === "outgoing" && state.selectedBufferId !== bufferId)) return fail("invalid-move", "Choose the originating Document or destination Document slot.");
|
|
464
|
+
if (direction === "outgoing") { const kept = escrow.capture(moveId, entry); if (!kept.ok) return kept; }
|
|
465
|
+
hostingUnrepresented = false;
|
|
466
|
+
hostingFreeze = Object.freeze({ moveId, direction, bufferId, state, expectedRevision: remoteRevision, startedAt: now() });
|
|
467
|
+
return { ok: true, proof: hostingFreeze };
|
|
468
|
+
},
|
|
469
|
+
finishHosting(proof, result, isStillCurrent = () => true) {
|
|
470
|
+
if (!hostingFreeze || proof !== hostingFreeze || disposed || unrepresentedEditor || hostingUnrepresented || !isStillCurrent()
|
|
471
|
+
|| !same(store.get(proof.bufferId), proof.state.buffers.find(b => b.id === proof.bufferId))
|
|
472
|
+
|| store.snapshot().selectedBufferId !== proof.state.selectedBufferId || !same(store.snapshot().order, proof.state.order)) {
|
|
473
|
+
return fail("editor-changed", "The editor changed during the move. Keep all copies; nothing was replaced locally.");
|
|
474
|
+
}
|
|
475
|
+
if (!result?.ok || result.moveId !== proof.moveId || !["committed", "cancelled"].includes(result.status)
|
|
476
|
+
|| (result.intentRevision !== undefined && result.intentRevision !== proof.expectedRevision)
|
|
477
|
+
|| (proof.direction === "outgoing" && result.sourceWorkspaceId !== workspaceId)
|
|
478
|
+
|| (result.status === "committed" && proof.direction === "incoming" && result.targetWorkspaceId !== workspaceId)
|
|
479
|
+
|| typeof result.revision !== "string" || !/^[a-f0-9]{48}$/.test(result.revision)) {
|
|
480
|
+
return fail("unknown-outcome", "A matching server move outcome is required before this editor can resume.");
|
|
481
|
+
}
|
|
482
|
+
if (result.status === "committed" && (result.sourceWorkspaceId === result.targetWorkspaceId
|
|
483
|
+
|| typeof result.targetWorkspaceId !== "string" || !/^[a-zA-Z0-9_-]{20,128}$/.test(result.targetWorkspaceId))) return fail("unknown-outcome", "The new editing location was not identified.");
|
|
484
|
+
const checked = validate(result.state, false); if (!checked.ok) return checked;
|
|
485
|
+
const next = checked.state, before = proof.state;
|
|
486
|
+
if (!(switching ? isStudioPromptDocumentBufferState(next) : isStudioSingleEditorBufferState(next))) return fail("unsupported-collection", "The moved workspace cannot be displayed safely here.");
|
|
487
|
+
if (result.status === "cancelled") {
|
|
488
|
+
if (next.revision < before.revision || next.savedAt < before.savedAt
|
|
489
|
+
|| !same({ ...next, revision: before.revision, savedAt: before.savedAt }, before)) return fail("editor-changed", "The server workspace changed while cancelling. Both copies were retained.");
|
|
490
|
+
} else {
|
|
491
|
+
const oldDoc = before.buffers.find(b => b.id === proof.bufferId), newDoc = next.buffers.find(b => b.role === "document");
|
|
492
|
+
const unchanged = before.buffers.filter(b => b.id !== oldDoc.id);
|
|
493
|
+
if (next.revision !== before.revision + 1 || next.savedAt < before.savedAt || next.buffers.length !== before.buffers.length
|
|
494
|
+
|| next.activePromptId !== before.activePromptId || !unchanged.every(b => same(b, next.buffers.find(n => n.id === b.id)))
|
|
495
|
+
|| !newDoc || next.selectedBufferId !== (before.selectedBufferId === oldDoc.id ? newDoc.id : before.selectedBufferId)
|
|
496
|
+
|| !same(next.order, before.order.map(id => id === oldDoc.id ? newDoc.id : id))) return fail("invalid-transition", "Move changed unrelated workspace state. Nothing was replaced locally.");
|
|
497
|
+
if (proof.direction === "outgoing") {
|
|
498
|
+
if (result.bufferId !== oldDoc.id || newDoc.id === oldDoc.id || newDoc.text || newDoc.baselineText !== "" || newDoc.sourceState.path
|
|
499
|
+
|| newDoc.metadata.reviewNotesKey || newDoc.metadata.scratchpadKey) return fail("invalid-transition", "The source was not replaced with a fresh empty Document.");
|
|
500
|
+
} else if (newDoc.id !== result.bufferId) return fail("invalid-transition", "The destination does not contain the moved document.");
|
|
501
|
+
}
|
|
502
|
+
// Never overwrite an independently changed browser copy. Native quota
|
|
503
|
+
// failure can fall back to the authenticated server checkpoint instead.
|
|
504
|
+
if (localEnabled) {
|
|
505
|
+
const browser = local.read();
|
|
506
|
+
if (!browser.ok) return browser;
|
|
507
|
+
if ((browser.raw ?? null) !== localRaw) return fail("storage-changed", "Browser recovery changed during the move. Both copies were retained.");
|
|
508
|
+
}
|
|
509
|
+
const current = store.snapshot(), rebased = !same(current, before);
|
|
510
|
+
const adopted = rebased ? { ...next, buffers: next.buffers.map(b => b.id === next.buffers.find(n => n.role === "document").id ? b : store.get(b.id)),
|
|
511
|
+
revision: Math.max(current.revision, next.revision) + 1, savedAt: Math.max(current.savedAt, next.savedAt, now()) + 1 } : next;
|
|
512
|
+
const merged = validate(adopted, false); if (!merged.ok) return merged;
|
|
513
|
+
const written = saveLocal(merged.state);
|
|
514
|
+
if (written && !written.ok && !["storage-write-failed", "storage-unverified"].includes(written.reason)) return written;
|
|
515
|
+
hostingRecoveryUnverified = written?.ok !== true;
|
|
516
|
+
store = createStudioBufferStore(merged.state); recoveryEpoch++; queued = null;
|
|
517
|
+
remoteRevision = result.revision; remoteEnabled = true; remoteBlocked = Boolean(result.checkpointError);
|
|
518
|
+
lastRemoteSnapshot = remoteBlocked ? null : rebased ? next : store.snapshot(); lastRemoteExpiresAt = remoteBlocked ? 0 : expiry(result, proof.startedAt);
|
|
519
|
+
receiveAuthorities(result);
|
|
520
|
+
if (written?.ok && !remoteBlocked) rememberAck(remoteRevision, next.revision);
|
|
521
|
+
hostingFreeze = null; issue("remote", remoteBlocked ? fail("conflict", result.checkpointError) : null); issue("capture", null);
|
|
522
|
+
if (result.status === "cancelled" && proof.direction === "outgoing") escrow.resolve(proof.moveId);
|
|
523
|
+
if (result.status === "committed" && proof.direction === "incoming" && written?.ok) {
|
|
524
|
+
const old = escrow.peek().record;
|
|
525
|
+
if (old?.document.id === result.bufferId) escrow.resolve(old.moveId); // Moving back restored the original backup's owner here.
|
|
526
|
+
}
|
|
527
|
+
escrow.retry();
|
|
528
|
+
if (rebased) publish(store.snapshot());
|
|
529
|
+
return { ok: true, editor: projectStudioBufferEditor(store.snapshot()), baselineText: store.get(store.snapshot().selectedBufferId).baselineText,
|
|
530
|
+
selectedChanged: before.selectedBufferId === proof.bufferId, localPersisted: written?.ok === true, status: result.status };
|
|
531
|
+
},
|
|
532
|
+
openBackup: () => opening?.peek() ?? null,
|
|
533
|
+
beginOpen(request) {
|
|
534
|
+
if (!opening || disposed || !store || hostingFreeze || opening.peek().record || opening.peek().error) return fail("open-pending", "Resolve the retained Document operation first.");
|
|
535
|
+
const state = store.snapshot();
|
|
536
|
+
if (unrepresentedEditor || draining || queued || remoteBlocked || !remoteEnabled || state !== lastRemoteSnapshot || now() >= lastRemoteExpiresAt
|
|
537
|
+
|| !localEnabled || localBlocked || hostingRecoveryUnverified || request.expectedRevision !== remoteRevision || request.documentEpoch !== documentEpochs[request.bufferId]) return fail("unacknowledged", "Checkpoint the current editor before opening a file.");
|
|
538
|
+
const browser = local.read(); if (!browser.ok) return browser;
|
|
539
|
+
if ((browser.raw ?? null) !== localRaw) return fail("storage-changed", "Browser recovery changed. Nothing was opened.");
|
|
540
|
+
const written = opening.capture({ version: 1, request, before: state }); if (!written.ok) return written;
|
|
541
|
+
hostingFreeze = { kind: "open", bufferId: request.bufferId, startedAt: now() }; hostingUnrepresented = false;
|
|
542
|
+
options.onHostingAuthority?.(); return written;
|
|
543
|
+
},
|
|
544
|
+
finishOpen(result, isStillCurrent = () => true) {
|
|
545
|
+
const record = opening?.peek().record;
|
|
546
|
+
if (disposed || hostingFreeze?.kind !== "open" || !record || !result?.ok || result.operationId !== record.request.operationId
|
|
547
|
+
|| result.intentRevision !== record.request.expectedRevision || result.workspaceId !== workspaceId || result.bufferId !== record.request.bufferId
|
|
548
|
+
|| !["committed", "cancelled", "reused"].includes(result.status) || !/^[a-f0-9]{48}$/.test(result.revision || "")) return fail("unknown-outcome", "Keep the file-open backup until its exact outcome is known.");
|
|
549
|
+
if (hostingUnrepresented || unrepresentedEditor || draining || queued || !isStillCurrent()
|
|
550
|
+
|| (options.getHostingGeneration && (!(result.generation > 0) || result.generation !== options.getHostingGeneration()))) return fail("editor-changed", "Newer work or a changed connection prevents applying this open outcome. Both copies were kept.");
|
|
551
|
+
const checked = validate(result.state, false); if (!checked.ok) return checked;
|
|
552
|
+
const before = record.before, after = checked.state, current = store.snapshot(), id = record.request.bufferId;
|
|
553
|
+
const old = before.buffers.find(b => b.id === id), next = after.buffers.find(b => b.id === id), live = current.buffers.find(b => b.id === id);
|
|
554
|
+
if (!(switching ? isStudioPromptDocumentBufferState(after) : isStudioSingleEditorBufferState(after)) || !next || next.role !== old.role
|
|
555
|
+
|| !same(after.order, before.order) || after.selectedBufferId !== before.selectedBufferId || after.activePromptId !== before.activePromptId
|
|
556
|
+
|| after.revision !== before.revision + 1 || after.savedAt < before.savedAt || after.buffers.length !== before.buffers.length
|
|
557
|
+
|| after.buffers.some(b => b.id !== id && !same(b, before.buffers.find(n => n.id === b.id)))
|
|
558
|
+
|| !same(current.order, before.order) || current.activePromptId !== before.activePromptId || current.selectedBufferId !== before.selectedBufferId
|
|
559
|
+
|| (!same(live, old) && !same(live, next)) || (result.status !== "committed" && !same(next, old))
|
|
560
|
+
|| !Number.isSafeInteger(result.documents?.[id]) || result.documents[id] < record.request.documentEpoch
|
|
561
|
+
|| (result.status === "committed" && (result.documents[id] <= record.request.documentEpoch || next.sourceState.source !== "file" || !next.sourceState.path || next.baselineText !== next.text
|
|
562
|
+
|| next.metadata.scratchpadKey !== "file:" + next.sourceState.path || next.metadata.reviewNotesKey !== "file:" + next.sourceState.path))) return fail("invalid-outcome", "The file-open receipt does not prove this replacement. All copies were kept.");
|
|
563
|
+
const browser = local.read(); if (!browser.ok) return browser;
|
|
564
|
+
if ((browser.raw ?? null) !== localRaw) return fail("storage-changed", "Browser recovery changed. The opening backup was kept.");
|
|
565
|
+
const buffers = current.buffers.map(b => b.id === id ? next : b), rebased = !same(buffers, after.buffers);
|
|
566
|
+
const merged = rebased ? { ...after, buffers, revision: Math.max(after.revision, current.revision) + 1, savedAt: Math.max(after.savedAt, current.savedAt, now()) + 1 } : after;
|
|
567
|
+
const valid = validate(merged, false); if (!valid.ok) return valid;
|
|
568
|
+
const written = saveLocal(valid.state); if (!written?.ok) return written || fail("storage-unavailable", "Keep the opening backup until browser recovery can be stored.");
|
|
569
|
+
// Never remove the original backup before readback of its replacement.
|
|
570
|
+
const cleared = opening.clear(record.request.operationId); if (!cleared.ok) return cleared;
|
|
571
|
+
const startedAt = hostingFreeze.startedAt;
|
|
572
|
+
store = createStudioBufferStore(valid.state); recoveryEpoch++; queued = null;
|
|
573
|
+
remoteRevision = result.revision; remoteBlocked = false; remoteEnabled = true;
|
|
574
|
+
lastRemoteSnapshot = rebased ? after : store.snapshot(); lastRemoteExpiresAt = expiry(result, startedAt);
|
|
575
|
+
receiveAuthorities(result); rememberAck(remoteRevision, after.revision);
|
|
576
|
+
hostingFreeze = null; hostingRecoveryUnverified = false; issue("remote", null); issue("capture", null);
|
|
577
|
+
if (rebased) publish(store.snapshot());
|
|
578
|
+
return { ok: true, status: result.status, selectedChanged: current.selectedBufferId === id && !same(live, next), rebased,
|
|
579
|
+
editor: projectStudioBufferEditor(store.snapshot()), baselineText: store.get(current.selectedBufferId).baselineText };
|
|
580
|
+
},
|
|
581
|
+
isHostingFrozen: () => hostingFreeze !== null,
|
|
582
|
+
hostingAuthority(bufferId = store?.snapshot().selectedBufferId) {
|
|
583
|
+
const current = store?.get(bufferId), acknowledged = lastRemoteSnapshot?.buffers.find(b => b.id === bufferId);
|
|
584
|
+
const generation = options.getHostingGeneration?.();
|
|
585
|
+
if (options.hosting !== true || disposed || remoteBlocked || !remoteEnabled || !current || !acknowledged
|
|
586
|
+
|| now() >= lastRemoteExpiresAt || !Number.isSafeInteger(documentEpochs[bufferId])
|
|
587
|
+
|| !same(current.sourceState, acknowledged.sourceState) || current.resourceDir !== acknowledged.resourceDir
|
|
588
|
+
|| (options.getHostingGeneration && (!Number.isSafeInteger(generation) || generation < 1))
|
|
589
|
+
|| hostingFreeze?.bufferId === bufferId) return fail("view-not-ready", "The document's owning connection and source checkpoint must be ready first.");
|
|
590
|
+
return { ok: true, bufferId, documentEpoch: documentEpochs[bufferId], generation, expectedRevision: remoteRevision };
|
|
591
|
+
},
|
|
592
|
+
noteHostingSave(proof, result) {
|
|
593
|
+
const epoch = result?.documents?.[proof?.bufferId];
|
|
594
|
+
if (options.hosting !== true || disposed || !store?.get(proof?.bufferId)
|
|
595
|
+
|| proof.generation !== options.getHostingGeneration?.() || !Number.isSafeInteger(epoch)
|
|
596
|
+
|| epoch <= proof.documentEpoch || epoch < (documentEpochs[proof.bufferId] || 0)) return fail("document-stale", "The save belongs to an earlier editor incarnation. Recheck its retained outcome.");
|
|
597
|
+
documentEpochs[proof.bufferId] = epoch;
|
|
598
|
+
hostingRecoveryUnverified = true;
|
|
599
|
+
options.onHostingAuthority?.();
|
|
600
|
+
return { ok: true };
|
|
601
|
+
},
|
|
602
|
+
attachHostingSave(operationId) {
|
|
603
|
+
const state = store?.snapshot(), current = store?.get(state?.selectedBufferId);
|
|
604
|
+
const record = pendingHostingSaves.find(r => r.operationId === operationId && r.bufferId === current?.id);
|
|
605
|
+
if (options.hosting !== true || disposed || !record || record.status !== "pending") return fail("save-not-found", "Recheck the selected Document's retained save outcome first.");
|
|
606
|
+
if (hostingFreeze || unrepresentedEditor || draining || queued || remoteBlocked || !remoteEnabled
|
|
607
|
+
|| state !== lastRemoteSnapshot || now() >= lastRemoteExpiresAt) return fail("unacknowledged", "Checkpoint current text and recheck the save before attaching its backing.");
|
|
608
|
+
if (typeof record.path !== "string" || typeof record.label !== "string" || typeof record.resourceDir !== "string" || typeof record.savedText !== "string"
|
|
609
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(record.diskRevision || "") || !(documentEpochs[current.id] > record.beforeDocumentEpoch)) return fail("invalid-outcome", "The retained save did not identify its committed backing.");
|
|
610
|
+
const changedPath = current.sourceState.path !== record.path;
|
|
611
|
+
const destinationKey = record.metadataTransfer?.destinationKey;
|
|
612
|
+
if (changedPath && (destinationKey !== "file:" + record.path || !["carry", "destination"].includes(record.metadataTransfer?.choice))) {
|
|
613
|
+
return fail("metadata-decision-required", "Prepare the saved file's metadata on the server before attaching its backing. Current notes were kept.");
|
|
614
|
+
}
|
|
615
|
+
if (localEnabled) {
|
|
616
|
+
const browser = local.read(); if (!browser.ok) return browser;
|
|
617
|
+
if ((browser.raw ?? null) !== localRaw) return fail("storage-changed", "Browser recovery changed. Current text and the saved outcome were kept.");
|
|
618
|
+
}
|
|
619
|
+
const result = store.update(current.id, current.revision, {
|
|
620
|
+
sourceState: { source: "file", path: record.path, label: changedPath ? record.label : current.sourceState.label, draftId: null,
|
|
621
|
+
...(record.beforeSourceState?.provenance ? { provenance: record.beforeSourceState.provenance } : {}) },
|
|
622
|
+
baselineText: record.savedText, diskRevision: record.diskRevision, resourceDir: record.resourceDir,
|
|
623
|
+
metadata: changedPath ? { ...current.metadata, reviewNotesKey: destinationKey, scratchpadKey: destinationKey } : current.metadata,
|
|
624
|
+
});
|
|
625
|
+
if (!result.ok) return result;
|
|
626
|
+
const written = saveLocal(store.snapshot()); publish(store.snapshot());
|
|
627
|
+
return { ok: true, editor: projectStudioBufferEditor(store.snapshot()), baselineText: record.savedText, localPersisted: written?.ok === true };
|
|
628
|
+
},
|
|
629
|
+
pendingHostingSaves: () => pendingHostingSaves,
|
|
630
|
+
refreshHostingReadiness: () => options.onHostingAuthority?.(),
|
|
631
|
+
notifyHostingViewBound(bufferId) { if (store?.snapshot().selectedBufferId === bufferId) options.onHostingAuthority?.({ boundBufferId: bufferId }); },
|
|
632
|
+
handoffBackup: () => escrow?.peek() ?? null,
|
|
633
|
+
hasVerifiedLocalCheckpoint: () => Boolean(store && localEnabled && !localBlocked && !hostingRecoveryUnverified),
|
|
634
|
+
resolveHandoff(result) {
|
|
635
|
+
const backup = escrow?.peek().record;
|
|
636
|
+
if (hostingFreeze || !result?.ok || !backup || result.moveId !== backup.moveId || result.sourceWorkspaceId !== workspaceId
|
|
637
|
+
|| (result.status !== "cancelled" && !(result.status === "committed" && result.adopted === true && result.bufferId === backup.document.id))) {
|
|
638
|
+
return fail("unknown-outcome", "The destination must acknowledge adoption before removing this backup.");
|
|
639
|
+
}
|
|
640
|
+
return escrow.resolve(result.moveId);
|
|
641
|
+
},
|
|
642
|
+
discardHandoff(moveId, confirmed) {
|
|
643
|
+
if (hostingFreeze || confirmed !== true) return fail("confirmation-required", "Confirm discarding the noneditable backup first.");
|
|
644
|
+
return escrow?.resolve(moveId) ?? fail("hosting-disabled", "Document hosting is not enabled.");
|
|
645
|
+
},
|
|
646
|
+
snapshot: () => store?.snapshot() ?? null,
|
|
647
|
+
// The acknowledged checkpoint a departing page may vouch for, or null.
|
|
648
|
+
cleanDepartureRevision() { return this.needsUnloadConfirmation() || remoteBlocked || !remoteEnabled ? null : remoteRevision; },
|
|
649
|
+
needsUnloadConfirmation() {
|
|
650
|
+
// beforeunload cannot distinguish reload from tab closure. Session storage can
|
|
651
|
+
// vanish on closure, so only a current, unexpired server acknowledgement allows
|
|
652
|
+
// silent departure. Measure the TTL from request start, not receipt, to avoid
|
|
653
|
+
// extending server lifetime by network latency. No durable-recovery claim.
|
|
654
|
+
return disposed || !store || unrepresentedEditor || hostingFreeze !== null || hostingRecoveryUnverified || pendingHostingSaves.length > 0 || Boolean(escrow?.peek().record || escrow?.peek().error || opening?.peek().record || opening?.peek().error) || store.snapshot() !== lastRemoteSnapshot || now() >= lastRemoteExpiresAt;
|
|
655
|
+
},
|
|
656
|
+
invalidateRemoteRecovery() {
|
|
657
|
+
recoveryEpoch++; lastRemoteSnapshot = null; remoteBlocked = true; queued = null;
|
|
658
|
+
issue("remote", fail("connection-lost", "Connection closed. New edits aren't being checkpointed to the server."));
|
|
659
|
+
},
|
|
660
|
+
async settled() { while (draining) await draining; },
|
|
661
|
+
dispose() { disposed = true; queued = null; store?.clearPendingOperations(); },
|
|
662
|
+
});
|
|
663
|
+
}
|