mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The browser must load its JavaScript before React can render a task. > - A failed import can stop that process before the React error boundary exists. > - The HTML entry then leaves an empty page with no recovery action. > - This PR adds a small recovery screen that works without React. > - The user can retry the same page and return to saved task content. ## Linked Issues or Issue Description Refs #13824 and #13895. This is a follow-up to their browser startup investigation. **What happened?** Interrupting the app bundle or a required import leaves an empty React root. A startup exception has the same effect. A React error boundary cannot handle these failures because React has not started. **Expected behavior** The page must explain the startup failure and offer a manual retry. A late successful load must dismiss the recovery message without a reload. **Steps to reproduce** 1. Open a saved task in the browser. 2. Abort the application bundle request, or make a required module return HTTP 503. 3. Observe the empty page before this change. With this change, use Reload page after the fault clears and verify the saved task and comment. **Paperclip version or commit** The failing regression baseline used master at `8781f06a8`. **Deployment mode** Local source build and compiled UI. Tests cover both initial navigation and a page controlled by the production service worker. The exact cause of the older intermittent Vite stall remains unconfirmed. Forty app loads and thirty replays of retained responses did not reproduce it. This PR fixes the missing recovery path; it does not claim to remove that historical cause. A normal HTTP 304 response is not a failure. ## What Changed - Add an inline startup guard and recovery screen in the HTML entry. It does not depend on the app module graph. - Show a manual reload action after a startup error or after 30 seconds without rendered root content. - Remove the notice, timer, observer, and error listeners when the app starts. Never reload automatically. - Keep the recovery screen outside the React root so it cannot satisfy app-readiness checks. - Add browser tests for interrupted imports, a stalled import, an evaluation error, service-worker-controlled retry, repeated offline retry, and cleanup after successful startup. - Return a static, uncached HTML retry screen when a service-worker-controlled navigation fails offline. It contains no task content. - Add a full-app test that retries an interrupted compiled bundle and checks the saved task, comment, composer, route, and absence of agent runs. - Document the coverage and the limits of the historical diagnosis. ## Verification - Red baseline: four recovery cases failed; the normal-startup case passed. After the change, all five recovery cases passed. The review found an offline retry gap; that additional case failed before the worker fix and passed afterward. - Full provider-free browser-support suite: 16 passed. - Compiled-app browser tests: four passed, including saved-task reload, interrupted-bundle recovery, slow-CPU service-worker reload, and sidebar navigation. - Expanded service-worker, offline response, PWA, and worker build-ID unit tests: 37 passed. The two old plain-text offline expectations were reproduced as failures and updated for the HTML retry contract. - UI production build, full local repository typecheck (`pnpm -r typecheck`), runner-E2E typecheck, and design token checks passed. - Manual browser check: a temporary server failed the compiled bundle once. The recovery screen appeared. Clicking Reload page restored the same saved task, comment, and composer. - Full local `pnpm build` passed. - Full local `pnpm test:run` was attempted with a bounded deadline and stopped after it timed out. Workspace runtime/cleanup tests reported timeouts on this host. The monolithic local run is not a pass. The focused tests above and the complete Linux CI run provide the successful verification. - Final-head [CI run](https://github.com/paperclipai/paperclip/actions/runs/36072201966) passed. All 53 check runs succeeded; the two Storybook jobs were intentionally skipped. The legacy security status also passed. - Greptile reviewed `f83e0f51fb760541d83353f2c1df4e182f3948f9`: 5/5. Both review findings are fixed and resolved. ## Risks - The guard only handles startup before React renders root content. Existing React boundaries handle later rendering errors. - A slow startup can show the message after 30 seconds. A later successful render removes it; the page does not reload by itself. - The fallback uses native HTML when the app stylesheet is unavailable. - The worker changes only its offline navigation response. It returns static HTML with a reload button and `Cache-Control: no-store`. Its cache allowlist, private-response protections, task state, provider prompts, and grading rules stay unchanged. - This does not establish or fix the unknown cause of the historical intermittent Vite stall. ## Model Used OpenAI GPT-6 through Codex. The session exposes the GPT-6 family but not an exact served model ID or context window size. Used reasoning, code editing, shell tools, and browser testing. No subagents were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
101 lines
4.4 KiB
JavaScript
101 lines
4.4 KiB
JavaScript
// The build id is stamped into this file at production build time (see
|
|
// stampServiceWorkerBuildId in vite.config.ts), so a deploy that changes only
|
|
// the app bundle still changes sw.js byte-for-byte. That is what makes the
|
|
// browser install a new worker, which — via skipWaiting + controllerchange —
|
|
// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in
|
|
// dev, where HMR (not the worker) drives refreshes.
|
|
const BUILD_ID = "__PAPERCLIP_BUILD_ID__";
|
|
// Separate this allowlisted cache from older workers that cached arbitrary URLs.
|
|
const CACHE_NAME = `paperclip-public-assets-${BUILD_ID}`;
|
|
const privateRequests = new Set();
|
|
const privateCacheControl = /(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i;
|
|
|
|
// Static recovery only: never cache or embed authenticated page content here.
|
|
function offlineNavigationResponse() {
|
|
return new Response(`<!doctype html>
|
|
<html lang="en">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="light dark"><title>Paperclip is offline</title></head>
|
|
<body><main><h1>Paperclip is offline</h1>
|
|
<p>Check your connection, then reload this page to try again.</p>
|
|
<button type="button" onclick="window.location.reload()">Reload page</button>
|
|
</main></body></html>`, {
|
|
status: 503,
|
|
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
|
|
});
|
|
}
|
|
|
|
async function evictRequest(request) {
|
|
await Promise.all((await caches.keys()).map(async (key) => {
|
|
const cache = await caches.open(key);
|
|
await cache.delete(request, { ignoreVary: true });
|
|
}));
|
|
}
|
|
|
|
self.addEventListener("install", () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) =>
|
|
Promise.all(keys.map((key) => caches.delete(key)))
|
|
)
|
|
);
|
|
self.clients.claim();
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
// Only immutable Vite build assets have a public offline-cache contract.
|
|
// Never infer that application/extension responses are public from absent
|
|
// headers, or from an in-memory classification lost when this worker restarts.
|
|
const publicAsset = url.origin === self.location.origin && !url.search &&
|
|
/^\/assets\/[^/]+-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9.]+$/.test(url.pathname);
|
|
|
|
// Explicitly private requests must bypass BOTH cache writes and offline
|
|
// fallback, including extension endpoints outside the host /api namespace.
|
|
if (request.method !== "GET" || url.pathname.startsWith("/api")) {
|
|
return;
|
|
}
|
|
if (request.cache === "no-store") {
|
|
privateRequests.add(request.url);
|
|
event.waitUntil(evictRequest(request).catch(() => {}));
|
|
return;
|
|
}
|
|
|
|
// Network-first; only public build assets can use an offline fallback.
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then(async (response) => {
|
|
const cacheControl = response.headers.get("cache-control") ?? "";
|
|
if (privateCacheControl.test(cacheControl)) {
|
|
// Revoke earlier cacheable responses too. Keep an in-memory denylist
|
|
// if storage is unavailable so offline fallback still fails closed.
|
|
privateRequests.add(request.url);
|
|
await evictRequest(request).catch(() => {});
|
|
} else if (response.ok && publicAsset && !privateRequests.has(request.url)) {
|
|
const clone = response.clone();
|
|
await caches.open(CACHE_NAME).then(async (cache) => {
|
|
await cache.put(request, clone);
|
|
// A concurrent response may have revoked this URL during put().
|
|
if (privateRequests.has(request.url)) await cache.delete(request, { ignoreVary: true });
|
|
}).catch(() => {});
|
|
}
|
|
return response;
|
|
})
|
|
.catch(async () => {
|
|
if (privateRequests.has(request.url)) return Response.error();
|
|
if (!publicAsset) return request.mode === "navigate" ? offlineNavigationResponse() : Response.error();
|
|
// Restrict lookup to this policy's cache; old arbitrary-response caches
|
|
// must not become fallback candidates if activation cleanup fails.
|
|
try {
|
|
const cached = await (await caches.open(CACHE_NAME)).match(request);
|
|
if (cached && !privateCacheControl.test(cached.headers.get("cache-control") ?? "")) return cached;
|
|
} catch { /* Unavailable cache storage is an offline miss. */ }
|
|
return Response.error();
|
|
})
|
|
);
|
|
});
|