Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ notes. Entries describe what users experience, not internal refactors.

### Fixed

- Editing a document after more than seven idle days keeps the new recovery
bytes instead of leaving an empty history record.
- Turning off recovery, or stopping it after a storage failure, keeps ordinary
saves linked to the document's authorized file.
- Closing or replacing a document cancels recovery startup still waiting for a
browser lock, so late callbacks cannot revive the previous document's session.
- Anthropic requests now time out even when response data stops after headers;
timeouts and service overloads show the corresponding recovery guidance.
- Browser tools report failed or superseded local opens as failures and apply
read-only mode only as part of the document being opened.
- Repeated document opens no longer retain extra Blob URLs for previous source
files. App updates reuse unchanged prepared assistant runtime assets already
held in earlier app caches.
- First-visit editor preparation waits for the service worker to control the
page, so warmed font resources remain available in its offline cache.
- A failed recovery snapshot no longer removes an older document when storage
Expand Down
117 changes: 117 additions & 0 deletions docs/explorations/2026-10-17-document-lifecycle-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Document lifecycle follow-ups

Baseline: `6b7fbdfd`. The user explicitly approved all five demonstrated defects
and both optimizations from the post-PR-279 audit, regression tests, separate
commits, a new PR and following its latest CI head until every check passes.

## Scope lock and evidence

| Task | Verified baseline evidence | Change boundary |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Expired active document | Real store/database with fake IndexedDB returns successful metadata but no latest bytes after seven days. | `lib/history/store.ts`, history store/browser regressions. |
| Independent document identity | Real session/autosave/disk routing returns unavailable with an authorized target after autosave is disabled. | `lib/history/{session,index,autosave}.ts`, document/close callers as needed, related tests. |
| Pending lock cancellation | Delayed Web Locks callback recreates a stopped autosave session. | `lib/history/autosave.ts`, scheduler regressions. |
| Anthropic lifetime/errors | Actual SDK with a short timeout stays pending after headers; real SDK timeout/overload errors miss UI classification. | Anthropic provider/factory options and request error normalization, focused tests. |
| Local open error ownership | Actual entry/tool code reports success and applies readonly after injected conversion failure. | `lib/{document,web-mcp}.ts`, entry/recovery callers and tests. |
| Original Blob URLs | Actual entry source opens three files; all abandoned URLs remain readable after creating a blank document. | `lib/{document,embed-api,events}.ts`, URL lifetime tests. |
| Runtime cache reuse | Worker install requests an identical asset held by an owned previous core and fails when its redundant network request fails. | `public/sw.js`, worker/offline regressions. |

Use existing modules and explicit lifecycle ownership. Keep identity independent
of the recovery timer, invalidate pending asynchronous starts, and reuse only
exact same-origin build-owned URLs from owned caches. Preserve complete runtime
preparation before activation. Remove redundant original-file URL allocation;
the actual editor document URL retains its existing owner.

No schema migration, new dependency, vendored/runtime edits, immutable archive
changes, model-default changes, arbitrary cache discovery or new data destination.
Keep user cancellation reasons, save target consent, retry ordering, offline
availability and document mutation approval intact. Add an Anthropic-owned total
deadline covering SDK consumption; do not rely on the SDK header-only timeout.

## Execution and verification

1. Add failing behavioral regressions before implementation in each owned scope.
2. Implement history, Anthropic and offline work independently; parent owns local
open/URL callers, integration, documentation, Git and PR operations.
3. Review each scope against all consumers, then review the complete branch.
4. Run full unit coverage, repository formatting/lint/types, production build and
targeted real Chromium regressions. Preserve existing test deadlines.
5. Commit by topic, push normally, create a new English PR and attach it to the
task. Preserve multiple commits; no squash or force-push. Follow only the
newest PR head until all shards and required summaries succeed. Do not merge.

Ruling: reuse this clean checkout on a dedicated `codex/` branch. Existing
unrelated worktrees are untouched. The user's explicit implementation request
approves the seven concrete audit outcomes; no additional approval stage is
needed for routine implementation choices within these boundaries.

## Implementation evidence

- Snapshot expiry excludes the document whose fresh bytes are being written.
Its new bytes, revision counters and authorized target survive while other
expired rows still retire atomically. The regression failed before the fix.
- Active document identity now outlives the recovery scheduler. Recovery-only
stops keep it; actual close/replacement clears it. Disk routing and saved
metadata use the active identity without trusting a URL. Real repository and
file-target tests cover disabled-before/after-open and storage-stop behavior.
- A generation invalidates every pending recovery start, including stops before
a session exists. Late lock grants release before timers/listeners or writes.
Both A/B completion orders, disable and explicit close have regressions.
- Anthropic owns a configurable five-minute deadline across SDK request/body
consumption. Controlled cancellation, callback failures and reentrant Stop
settle promptly and clean up timers/listeners/streams. Tests exercise actual
SDK stalled JSON/SSE responses and actual timeout/overload error classes.
- Local opens reject failures and obsolete completions. Picker/bootstrap callers
consume already-reported errors; tool/recovery callers receive the rejection.
Buffer read-only mode travels with the owned open rather than a later global
mutation. Cross-entry replacement and failed-source preservation remain tested.
- Standalone, embedded and desktop opens retain the File without allocating an
unused second original-file URL. The actual editor URL keeps its existing
allocation/revocation owner. A tracked URL registry exposed seven baseline
entry/outcome failures before the fix; all eight new entry cases now pass.
- Runtime preparation copies exact successful same-origin allowlisted responses
from earlier owned core caches before fetching misses. Sixteen regressions
cover headers/bytes, changed hashes, failures, unrelated caches, concurrency
and complete-before-activation. The original avoidable install failure now
succeeds without requesting the already-held runtime again.

Four independent scoped reviews reported no actionable findings. The full unit
coverage run passes **6,349 tests in 243 files**, with 89.67% lines and 83.19%
branches; every repository coverage gate passes.

Both new real-editor browser regressions failed against the exact baseline
production Docker image: the expired document's new revision was absent, and
two ordinary saves with recovery disabled opened two pickers. These are actual
IndexedDB/editor/file-system flows, with prior timestamps and a genuine OPFS
handle used as controlled fixtures.

## Assembled verification

- All **33 Chromium cases pass** against a fresh production Docker image in
2.6 minutes, including both new regressions, source entry paths, queue/Stop,
service errors/streaming, offline capability upgrades, actual recovery,
authorized disk writes, save ordering, PDF export and reload target reuse.
- Repository formatting, archive integrity (1,511 immutable artifacts), oxlint,
refreshed workspace declarations and application TypeScript checks pass.
- Production Docker build passes; existing bundler size warnings remain.
- Independent complete-branch review approves all seven changes and their
consumers, with no unresolved actionable findings.

```sh
pnpm run test:coverage --maxWorkers=2
pnpm run lint:ts
pnpm run format:check
docker build -t document:lifecycle-followups .
docker run -d --rm --name document-followups-preview -p 127.0.0.1:4202:80 document:lifecycle-followups
E2E_BASE_URL=http://127.0.0.1:4202 E2E_PORT=4202 pnpm exec playwright test \
test/e2e/history-quota.spec.ts test/e2e/save-to-file.spec.ts \
test/e2e/entry-paths.spec.ts test/e2e/autosave-recovery.spec.ts \
test/e2e/agent-error-recovery.spec.ts test/e2e/agent-request-queue.spec.ts \
test/e2e/assistant-offline-assets.spec.ts test/e2e/agent-cloud-streaming.spec.ts \
test/e2e/landing-prefetch.spec.ts --workers=1 --reporter=line
docker stop document-followups-preview
```

These checks use deterministic failure/storage/lock fixtures and actual
Chromium/IndexedDB/OPFS/worker/editor behavior. They do not certify live provider
accounts, physical disk exhaustion or every browser/runtime inference backend.
9 changes: 7 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ void (async () => {
// would produce, and it is the copy nobody else has.
if (savedParam && !isEmbedded) {
const { restoreSavedDocument } = await import('./lib/history/recovery');
if (await restoreSavedDocument(savedParam, { readonly: isReadonly })) return;
try {
if (await restoreSavedDocument(savedParam, { readonly: isReadonly })) return;
} catch {
// Opening already reported failure, or another entry replaced it.
return;
}
// No snapshot yet (nothing was edited before the reload) or it expired:
// fall through and open the same document again under the same id.
}
Expand Down Expand Up @@ -206,7 +211,7 @@ void (async () => {
cleaned.searchParams.delete('open');
window.history.replaceState(null, '', cleaned);
if (file) {
await openLocalFile(file, { historyId: savedParam || undefined });
await openLocalFile(file, { historyId: savedParam || undefined }).catch(() => {});
return;
}
// Stale deep link (reload, bookmarked URL): nothing pending -- back to the landing.
Expand Down
3 changes: 2 additions & 1 deletion lib/agent-plugin/ui/request-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function requestErrorKey(
if (error instanceof DOMException && error.name === 'TimeoutError') return 'agentServiceTimedOut';
if (!(error instanceof Error) && typeof error !== 'string') return;
const message = error instanceof Error ? error.message : error;
if (message === 'AI request timed out') return 'agentServiceTimedOut';
if (message === 'AI request timed out' || message === 'Request timed out.') return 'agentServiceTimedOut';
if (error instanceof Error && error.name === 'TimeoutError') return 'agentServiceTimedOut';
if (
['Failed to fetch', 'NetworkError when attempting to fetch resource.', 'Load failed', 'Connection error.'].includes(
Expand All @@ -23,6 +23,7 @@ export function requestErrorKey(
error instanceof Error && 'status' in error && typeof error.status === 'number'
? error.status
: Number(/^(?:OpenAI|Ollama|Gemini) request failed: (\d{3})(?:\s|$)/.exec(message)?.[1]);
if (error instanceof Error && 'type' in error && error.type === 'overloaded_error') return 'agentServiceUnavailable';
if (status === 401 || status === 403) return 'agentServiceAuthFailed';
if (status === 429) return 'agentServiceRateLimited';
if (status === 408 || status === 504) return 'agentServiceTimedOut';
Expand Down
28 changes: 15 additions & 13 deletions lib/document.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { beginDocumentOpen, commitDocumentOpen, isMountedDocumentOpen } from './document-open';
import { fetchRemoteDocument } from './remote-document';
import { remoteFileName } from './remote-file-name';
import { createObjectURL } from 'ranuts/utils';
import { View } from 'ranui/builder';
import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store';
import { t } from '@ranuts/shared/i18n';
import 'ranui/message';
import { handleDocumentOperation, loadEditorApi } from './converter';
import { showLoading } from './loading';
import { startDocumentSession, newDocumentId } from './history/session';
import { stopAutosaveSession } from './history/autosave';
import { clearDocumentSession } from './history/autosave';
import { rememberActiveSource, clearActiveSource } from './active-document-source';

// Import UI functions with type-only to avoid circular dependency
Expand Down Expand Up @@ -58,7 +57,7 @@ export const onCreateNew = async (ext: string, options?: { docId?: string }): Pr
const { fileName, file: fileBlob } = getDocmentObj();
await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent });
if (!commitDocumentOpen(operation)) return;
stopAutosaveSession();
clearDocumentSession();
await clearActiveSource();
if (!isMountedDocumentOpen(operation)) return;
// Recovery points from here on. A blank document is the case with the most
Expand Down Expand Up @@ -92,9 +91,10 @@ export const openLocalFile = async (
if (hideControlPanelFn) {
hideControlPanelFn();
}
const objectUrl = await createObjectURL(file);
opening.assertCurrent();
setDocmentObj({ fileName: file.name, file, url: objectUrl });
// The editor owns the URL it creates from these bytes. This store needs
// only the File; a second Blob URL would retain every replaced original.
setDocmentObj({ fileName: file.name, file });
const { fileName, file: fileBlob } = getDocmentObj();
await handleDocumentOperation({
file: fileBlob,
Expand All @@ -103,21 +103,22 @@ export const openLocalFile = async (
readonly: options?.readonly,
isCurrent: opening.isCurrent,
});
if (!commitDocumentOpen(operation)) return;
if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError');
// The replacement editor now owns exports; retire the previous identity
// before asynchronous source storage, while delaying URL stamping until commit.
stopAutosaveSession();
clearDocumentSession();
const docId = options?.historyId || newDocumentId();
await rememberActiveSource(docId, file);
if (!isMountedDocumentOpen(operation)) return;
if (!isMountedDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError');
startDocumentSession({ title: file.name, origin: 'local', docId });
} catch (error) {
if (!opening.isCurrent()) return;
if (!opening.isCurrent()) throw error;
console.error('Error opening document:', error);
// Ensure control panel is shown on error
if (showControlPanelFn) {
showControlPanelFn();
}
throw error;
} finally {
// Always remove loading, even if there's an error
removeLoading();
Expand All @@ -139,7 +140,9 @@ export const onOpenDocument = (): void => {
// Only process if a file was actually selected
// If user cancelled, onchange won't fire, nothing happens
if (file) {
await openLocalFile(file);
// The open path already reports failure. A DOM event has no caller to
// receive its rejection, unlike tools and recovery operations.
await openLocalFile(file).catch(() => {});
// Clear file selection so the same file can be selected again
fileInput.value = '';
}
Expand Down Expand Up @@ -209,9 +212,8 @@ export const openDocumentFromUrl = async (
const file = new File([remote.blob], finalFileName, { type: remote.blob.type });

// Set document object
const objectUrl = await createObjectURL(file);
opening.assertCurrent();
setDocmentObj({ fileName: finalFileName, file, url: objectUrl });
setDocmentObj({ fileName: finalFileName, file });

// Initialize and open document
const { fileName: docFileName, file: fileBlob } = getDocmentObj();
Expand All @@ -223,7 +225,7 @@ export const openDocumentFromUrl = async (
isCurrent: opening.isCurrent,
});
if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError');
stopAutosaveSession();
clearDocumentSession();
await clearActiveSource();
if (!isMountedDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError');
startDocumentSession({ title: docFileName, origin: 'url', docId: options?.docId });
Expand Down
5 changes: 2 additions & 3 deletions lib/embed-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { clearActiveSource } from './active-document-source';
import { stopAutosaveSession } from './history/autosave';
import { clearDocumentSession } from './history/autosave';
import { beginDocumentOpen, commitDocumentOpen, isMountedDocumentOpen } from './document-open';
import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store';
import { handleDocumentOperation, loadEditorApi } from './converter';
Expand Down Expand Up @@ -134,7 +134,6 @@ async function openFile(file: File, readonly = false): Promise<void> {
setDocmentObj({
fileName: file.name,
file,
url: URL.createObjectURL(file),
});
await handleDocumentOperation({
fileName: file.name,
Expand All @@ -144,7 +143,7 @@ async function openFile(file: File, readonly = false): Promise<void> {
isCurrent: opening.isCurrent,
});
if (!commitDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError');
stopAutosaveSession();
clearDocumentSession();
await clearActiveSource();
if (!isMountedDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError');
}
Expand Down
11 changes: 4 additions & 7 deletions lib/events.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { clearActiveSource } from './active-document-source';
import { beginDocumentOpen, cancelDocumentOpen, commitDocumentOpen } from './document-open';
import { stopAutosaveSession } from './history/autosave';
import { MessageCodec, Platform, createObjectURL } from 'ranuts/utils';
import { clearDocumentSession } from './history/autosave';
import { MessageCodec, Platform } from 'ranuts/utils';
import type { MessageHandler } from 'ranuts/utils';
import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store';
import { handleDocumentOperation } from './converter';
Expand Down Expand Up @@ -47,17 +47,14 @@ export const events: Record<string, MessageHandler<any, unknown>> = {
try {
const file = await MessageCodec.decodeFileChunked(chunks);
opening.assertCurrent();
const url = await createObjectURL(file);
opening.assertCurrent();
setDocmentObj({
fileName: file.name,
file: file,
url,
});
const { fileName, file: fileBlob } = getDocmentObj();
await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent });
if (commitDocumentOpen(opening.id)) {
stopAutosaveSession();
clearDocumentSession();
await clearActiveSource();
}
} catch (error) {
Expand All @@ -70,7 +67,7 @@ export const events: Record<string, MessageHandler<any, unknown>> = {
},
CLOSE_EDITOR: () => {
cancelDocumentOpen();
stopAutosaveSession();
clearDocumentSession();
chunkOpening = null;
fileChunks = [];
if (window.editor && typeof window.editor.destroyEditor === 'function') {
Expand Down
Loading
Loading