From 7b7c4d4172d6aac14919e2682b702ae87bc17653 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 21:09:28 -0700 Subject: [PATCH] fix(ui): recover an archived Cloud stack from its own health probe (#13913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - On Paperclip Cloud, a stack whose organization is archived is served through the Cloud harness router. > - An archived stack answers every tenant request — including the SPA's own health probe — with a 423 archived status page. > - If the SPA is already loaded (a stale tab, or a load that raced the stack's archival), that 423 surfaces as a dead "Failed to load health" screen with no way out. > - This pull request recovers it the same way an expired tenant session already does: a top-level reload re-enters the Cloud harness, which redirects a fresh browser navigation on an archived host to the portfolio. > - The benefit is that an archived stack never traps the user on a broken health screen. ## Linked Issues or Issue Description No public issue exists. Description follows the bug template: **What happened?** A Cloud stack that becomes archived while its SPA is open (or is opened in a stale tab) shows a "Failed to load health" screen. The SPA's health probe receives the harness's 423 archived status page and has no recovery path, so the browser is stuck on a dead screen. **Expected behavior** The browser should leave the archived stack for the Cloud portfolio, as it already does for an expired tenant session — no dead "Failed to load health" screen. **Steps to reproduce** 1. On a Cloud-managed instance, open an organization's SPA in the browser. 2. Archive that organization (or open a stale tab for one that was archived). 3. The SPA's health probe returns 423 (archived) and the page shows "Failed to load health" with no way out. **Paperclip version or commit** `master` (Paperclip Cloud managed instances). **Deployment mode** Cloud-managed (`authenticated`). Self-hosted instances are unaffected: the 423 archived status page only originates from the Cloud harness router that fronts managed stacks; a self-hosted server serves its own health. **Subsystem affected** UI: tenant document recovery (`ui/src/lib/tenant-session-recovery.ts`), which the health/client/heartbeats/audit API surfaces already route error responses through. ## What Changed - `isArchivedStackRecoveryError(status, body)`: true for a 423 whose body carries a `statusPage.code === "archived"`. - `isTenantDocumentRecoveryError` unifies the existing 401 tenant-session codes with the new archived case; the recovery coordinator now fires on either. - The recovery action is unchanged — a single top-level reload — so an archived stack re-enters the harness and is redirected to the portfolio. The existing single-reload guard prevents any loop, and only the exact `archived` code triggers it (suspended/deleted/other 423s pass through as normal errors). ## Verification - `pnpm exec vitest run src/lib/tenant-session-recovery.test.ts src/api/auth.test.ts src/api/audit.test.ts` — pass. - New tests: the predicate accepts a 423 archived body and rejects suspended/deleted/error-shaped/503/null; the coordinator triggers the same single top-level reload for an archived body and stays inert for unrelated responses. - `pnpm exec tsc --noEmit` in `ui/` is clean. - Pairs with the harness-side redirect (paperclipai/paperclip-cloud#545): the harness redirects fresh navigations on an archived host to `/orgs`; this makes an already-loaded SPA trigger that navigation on its own health 423. ## Risks - Low. The recovery path and its single-reload guard are unchanged; only the set of conditions that trigger it grows by one exact code. Non-archived 423s and all other statuses behave as before. ## Model Used - Claude Fable 5 (`claude-fable-5`), via Claude Code CLI, extended thinking and tool use enabled. ## 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 --- ui/src/lib/tenant-session-recovery.test.ts | 29 +++++++++++++++++++ ui/src/lib/tenant-session-recovery.ts | 33 ++++++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/tenant-session-recovery.test.ts b/ui/src/lib/tenant-session-recovery.test.ts index 619753a4e7..7384f2cc53 100644 --- a/ui/src/lib/tenant-session-recovery.test.ts +++ b/ui/src/lib/tenant-session-recovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createTenantSessionRecoveryCoordinator, + isArchivedStackRecoveryError, isTenantSessionRecoveryError, } from "./tenant-session-recovery"; @@ -24,6 +25,23 @@ describe("isTenantSessionRecoveryError", () => { }); }); +describe("isArchivedStackRecoveryError", () => { + it("recognizes a 423 archived status-page body", () => { + expect(isArchivedStackRecoveryError(423, { statusPage: { code: "archived" } })).toBe(true); + }); + + it.each([ + [423, { statusPage: { code: "suspended" } }], + [423, { statusPage: { code: "deleted" } }], + [423, { error: "archived" }], + [503, { statusPage: { code: "archived" } }], + [423, { statusPage: null }], + [423, null], + ])("rejects status/body combination %j %j", (status, body) => { + expect(isArchivedStackRecoveryError(status, body)).toBe(false); + }); +}); + describe("tenant-session recovery coordinator", () => { it("reloads once and shares one never-settling promise across concurrent failures", async () => { const reload = vi.fn(); @@ -45,11 +63,22 @@ describe("tenant-session recovery coordinator", () => { expect(settled).toBe(false); }); + it("recovers an archived stack with the same top-level reload", () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + + const recovered = recovery.recoverIfNeeded(423, { statusPage: { code: "archived" } }); + + expect(recovered).not.toBeNull(); + expect(reload).toHaveBeenCalledTimes(1); + }); + it("does nothing for unrelated responses", () => { const reload = vi.fn(); const recovery = createTenantSessionRecoveryCoordinator(reload); expect(recovery.recoverIfNeeded(401, { error: "unauthorized" })).toBeNull(); + expect(recovery.recoverIfNeeded(423, { statusPage: { code: "suspended" } })).toBeNull(); expect(reload).not.toHaveBeenCalled(); }); }); diff --git a/ui/src/lib/tenant-session-recovery.ts b/ui/src/lib/tenant-session-recovery.ts index 1c7417ee13..210839bfb9 100644 --- a/ui/src/lib/tenant-session-recovery.ts +++ b/ui/src/lib/tenant-session-recovery.ts @@ -9,6 +9,31 @@ export function isTenantSessionRecoveryError(status: number, body: unknown): boo return typeof error === "string" && TENANT_SESSION_ERROR_CODES.has(error); } +/** + * An archived Cloud stack answers every tenant request — including the + * SPA's own health probe — with a 423 archived status page. Left unhandled + * this surfaces as a dead "Failed to load health" screen. The recovery is + * the same top-level reload as an expired session: the Cloud harness + * redirects a fresh browser navigation on an archived host to the + * portfolio, so reloading leaves this document instead of dwelling on it. + */ +export function isArchivedStackRecoveryError(status: number, body: unknown): boolean { + if (status !== 423 || !body || typeof body !== "object") return false; + const statusPage = (body as Record).statusPage; + if (!statusPage || typeof statusPage !== "object") return false; + return (statusPage as Record).code === "archived"; +} + +/** + * Conditions the browser recovers from by re-entering the Cloud harness on a + * fresh top-level navigation: an expired/absent tenant session (the harness + * re-runs the cookie/OIDC handoff) and an archived stack (the harness + * redirects to the portfolio). Both are corrected by the same reload. + */ +export function isTenantDocumentRecoveryError(status: number, body: unknown): boolean { + return isTenantSessionRecoveryError(status, body) || isArchivedStackRecoveryError(status, body); +} + export interface TenantSessionRecoveryCoordinator { recoverIfNeeded: (status: number, body: unknown) => Promise | null; } @@ -20,7 +45,7 @@ export function createTenantSessionRecoveryCoordinator( return { recoverIfNeeded(status, body) { - if (!isTenantSessionRecoveryError(status, body)) return null; + if (!isTenantDocumentRecoveryError(status, body)) return null; if (recoveryPromise) return recoveryPromise; // Keep every affected consumer pending while the browser leaves this @@ -39,8 +64,10 @@ export function createTenantSessionRecoveryCoordinator( } export const tenantSessionRecovery = createTenantSessionRecoveryCoordinator(() => { - // A document navigation re-enters Cloud's existing HttpOnly-cookie/OIDC - // handoff, preserving the current route and query without exposing tokens. + // A document navigation re-enters the Cloud harness, which corrects the + // browser's state: an expired session re-runs the HttpOnly-cookie/OIDC + // handoff (route and query preserved, no tokens exposed), and an archived + // stack is redirected to the portfolio. const topLevelWindow = window.top ?? window; topLevelWindow.location.reload(); });