mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
fix(ui): recover an archived Cloud stack from its own health probe (#13913)
## 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
This commit is contained in:
1 parent
94a0aa7726
commit
7b7c4d4172
2 files changed
+59
-3
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).statusPage;
|
||||
if (!statusPage || typeof statusPage !== "object") return false;
|
||||
return (statusPage as Record<string, unknown>).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<never> | 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();
|
||||
});
|
||||
Reference in new issue
Block a user