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:
Devin Foley authored and GitHub committed 2026-09-23 21:09:28 -07:00
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();
});
});
+30 -3
View File
@@ -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();
});