Integrate current mainline for ACP production qualification

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/runner-cursor-acp:
  feat(runner): add rich ACP transport and durable interaction foundation (#14430)
  fix: preserve terminal task owners during release (#14561)
  fix(ui): recover gracefully during server restarts (#14560)
This commit is contained in:
DottaandPaperclip committed 2026-09-29 09:33:40 -05:00
commit 9f4476feb8
18 files changed
+586 -52

No files matched your search

+1 -1
View File
@@ -1314,7 +1314,7 @@ export function registerIssueCommands(program: Command): void {
addCommonClientOptions(
issue
.command("release")
.description("Release issue back to todo and clear assignee")
.description("Release issue execution locks; clear the assignee only for unfinished issues")
.argument("<issueId>", "Issue ID")
.action(async (issueId: string, opts: BaseClientOptions) => {
try {
+11
View File
@@ -238,6 +238,17 @@ owns committed updates.
## Hot-Restart Deploys
During a restart, the board's health, session, and access checks retry temporary
network/gateway failures and non-JSON API responses every five seconds. A new
page shows **Reconnecting to Paperclip** with a **Try again** action and waits
for startup health to become ready. Valid startup metadata remains available to
sign-in and invitation pages. An already
open page stays mounted during temporary background failures so unsaved edits
survive. Successful checks resume the same route and refresh other failed reads;
this recovery does not reload the browser or replay mutations. Authorization
failures still require sign-in or an explicit retry. Storybook **App / Connection
recovery** shows the startup recovery states.
Primary-instance rebuilds that restart `paperclip.service` can request one-shot live-run adoption instead of using the normal graceful shutdown drain. Before restarting the service, write the marker from the newly staged app with the current service PID:
```sh
+6
View File
@@ -1097,6 +1097,12 @@ Server behavior:
2. if updated row count is 0, return `409` with current owner/status
3. successful checkout sets `assignee_agent_id`, `status = in_progress`, and `started_at`
`POST /issues/:issueId/release` clears checkout and execution locks. For terminal
issues (`done` or `cancelled`), it preserves the assignee, status, and disposition
timestamps, including on repeated release. For unfinished issues it clears the
agent assignee; only `in_progress` changes to `todo`. Existing company access,
assignee/run ownership checks, and the `issue.released` activity event still apply.
`POST /issues/:issueId/admin/force-release` is an operator recovery endpoint for stale harness locks. It requires board access to the issue company, clears checkout and execution run lock fields, and may clear the agent assignee when `clearAssignee=true` is passed. The route must write an `issue.admin_force_release` activity log entry containing the previous checkout and execution run IDs.
## 10.5 Projects
+5
View File
@@ -414,6 +414,11 @@ Tasks use **single assignment** (one agent per task) with **atomic checkout**:
No optimistic locking or CRDTs needed. The single-assignment model + atomic checkout prevents conflicts at the design level.
Releasing a terminal task clears execution locks while preserving its assigned
owner and final status. Assignment remains part of the work history after Done
or Cancelled. Releasing unfinished work still relinquishes the agent assignment;
only an active `in_progress` task returns to `todo`.
### Human in the Loop
Agents can create tasks assigned to humans. The board member (or any human with access) can complete these tasks through the UI.
+7
View File
@@ -6,6 +6,13 @@ Run scope: `ui/src/components/` and `ui/src/pages/` on branch `design/token-extr
## Counts
### Connection recovery — 2026-09-29
`CloudAccessGate.tsx` contains the startup `CloudAccessError` composition, using
the existing Button and typography tokens. It covers automatic reconnection,
an in-flight check, and manual retry after access-check failures. See
`/design-guide` and Storybook **App / Connection recovery**.
### Independent MCP connection setup — 2026-09-21
`ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx` is the controlled
@@ -175,13 +175,16 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
});
it.each([
{ status: "done" as const, title: "Done release preserves status", completedAt: new Date() },
{ status: "cancelled" as const, title: "Cancelled release preserves status", cancelledAt: new Date() },
{ status: "done" as const, title: "Done release preserves owner", completedAt: new Date(), retainAssignee: true },
{ status: "cancelled" as const, title: "Cancelled release preserves owner", cancelledAt: new Date(), retainAssignee: true },
{ status: "in_review" as const, title: "In review release preserves status" },
{ status: "blocked" as const, title: "Blocked release preserves status" },
{ status: "backlog" as const, title: "Backlog release relinquishes ownership" },
{ status: "todo" as const, title: "Todo release relinquishes ownership" },
{ status: "in_progress" as const, title: "Active release requeues work", expectedStatus: "todo" },
])(
"preserves $status when releasing a non-in_progress issue",
async ({ status, title, completedAt, cancelledAt }) => {
"$title and clears execution locks",
async ({ status, title, completedAt, cancelledAt, retainAssignee, expectedStatus = status }) => {
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
const issueId = randomUUID();
await db.insert(issues).values({
@@ -204,7 +207,8 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
.send();
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.status).toBe(status);
const expectedAssignee = retainAssignee ? agentId : null;
expect(res.body).toMatchObject({ status: expectedStatus, assigneeAgentId: expectedAssignee });
const row = await db
.select({
@@ -212,18 +216,40 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
executionLockedAt: issues.executionLockedAt,
completedAt: issues.completedAt,
cancelledAt: issues.cancelledAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toEqual({
status,
assigneeAgentId: null,
status: expectedStatus,
assigneeAgentId: expectedAssignee,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
completedAt: completedAt ?? null,
cancelledAt: cancelledAt ?? null,
});
const events = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(events).toContainEqual(expect.objectContaining({
action: "issue.released", companyId, agentId, runId: currentRunId,
}));
if (retainAssignee) {
// Repeated cleanup, including by the board, must not erase attribution.
const retried = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/release`)
.send();
expect(retried.status, JSON.stringify(retried.body)).toBe(200);
expect(retried.body).toMatchObject({ status, assigneeAgentId: agentId, checkoutRunId: null, executionRunId: null });
const [saved] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(saved).toMatchObject(row);
}
},
);
+4 -2
View File
@@ -11787,14 +11787,16 @@ export function issueService(db: Db) {
}
}
// Release clears checkout/assignee locks; only in_progress work re-queues to todo.
// Terminal assignment records who owned the work, not a live execution
// claim. Cleanup must preserve it; unfinished release still relinquishes it.
const isTerminal = existing.status === "done" || existing.status === "cancelled";
const releaseStatus =
existing.status === "in_progress" ? "todo" : existing.status;
const updated = await tx
.update(issues)
.set({
status: releaseStatus,
assigneeAgentId: null,
assigneeAgentId: isTerminal ? existing.assigneeAgentId : null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
+4 -4
View File
@@ -1355,9 +1355,9 @@ Terminal states: `done`, `cancelled`
- `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout.
- `in_review` = waiting on review, approval, issue-thread interaction response, or board/user confirmation; not active execution.
- `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker.
- `done` = completed.
- `cancelled` = intentionally abandoned.
- `in_progress` requires an assignee (use checkout).
- `done` = completed. Release clears execution locks but preserves the assignee and `completedAt`.
- `cancelled` = intentionally abandoned. Release clears execution locks but preserves the assignee and `cancelledAt`.
- `in_progress` requires an assignee (use checkout). Release returns it to `todo` and clears the agent assignee.
- `started_at` is auto-set on `in_progress`.
- `completed_at` is auto-set on `done`.
- One assignee per task at a time.
@@ -1418,7 +1418,7 @@ Terminal states: `done`, `cancelled`
| POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) |
| PATCH | `/api/issues/:issueId` | Update issue; response is authoritative and includes `changes` + `comment` (`Prefer: return=minimal` supported); `blockedByIssueIds` replaces blocker set |
| POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. |
| POST | `/api/issues/:issueId/release` | Release task ownership |
| POST | `/api/issues/:issueId/release` | Release execution locks; preserve terminal task ownership |
| GET | `/api/issues/:issueId/comments` | List comments |
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
+5 -2
View File
@@ -121,7 +121,9 @@ describe("CloudAccessGate", () => {
it("does not mistake a session service failure for a signed-out user", async () => {
mockAuthApi.getSession.mockRejectedValue(new Error("Session service unavailable"));
const root = renderGate(container);
await waitForText(container, "Session service unavailable");
await waitForText(container, "Unable to load Paperclip");
expect(container.querySelector("button")?.textContent).toBe("Try again");
expect(container.textContent).not.toContain("Outlet content");
expect(container.textContent).not.toContain("Navigate:/auth");
expect(beginCloudSignInMock).not.toHaveBeenCalled();
unmountRoot(root);
@@ -193,7 +195,8 @@ describe("CloudAccessGate", () => {
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
mockAccessApi.getCurrentBoardAccess.mockRejectedValueOnce(new Error("Access check unavailable"));
const root = renderGate(container, true);
await waitForText(container, "Access check unavailable");
await waitForText(container, "Unable to load Paperclip");
expect(container.querySelector("button")?.textContent).toBe("Try again");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
+4 -2
View File
@@ -10,6 +10,7 @@ import {
} from "@paperclipai/shared";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
import { readApiJson } from "./response";
type AuthErrorBody =
| {
@@ -158,13 +159,14 @@ export const authApi = {
const res = await fetch("/api/auth/get-session", {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
});
const payload = await res.json().catch(() => null);
const payload = await readApiJson(res);
if (!res.ok) {
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload);
if (recovery) return recovery;
if (res.status === 401) return null;
throw new Error(`Failed to load session (${res.status})`);
throw extractAuthError(payload as AuthErrorBody, res.status);
}
const direct = toSession(payload);
if (direct) return direct;
+3 -2
View File
@@ -1,5 +1,6 @@
import { getPageVisibility, getVisibilityHeaderValue } from "@/lib/page-visibility";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
import { readApiJson } from "./response";
const BASE = "/api";
@@ -56,7 +57,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
...init,
});
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
const errorBody = await readApiJson(res);
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, errorBody);
if (recovery) return recovery;
throw new ApiError(
@@ -66,7 +67,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
);
}
if (res.status === 204) return undefined as T;
return res.json();
return readApiJson<T>(res);
}
// --- In-tab request coalescing for identical safe GETs -----------------------
+9 -4
View File
@@ -1,5 +1,7 @@
import type { ServerInfoSnapshot } from "@paperclipai/shared";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
import { ApiError } from "./client";
import { ApiUnavailableError, readApiJson } from "./response";
export type DevServerHealthStatus = {
enabled: true;
@@ -24,7 +26,7 @@ export type CloudInstanceHealthStatus = {
};
export type HealthStatus = {
status: "ok";
status: "ok" | "starting";
version?: string;
/** Commit of the running server; null when build metadata is unavailable. */
commit?: string | null;
@@ -52,14 +54,17 @@ export const healthApi = {
const res = await fetch("/api/health", {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
});
const payload = await readApiJson<HealthStatus & { error?: string } | null>(res);
if (!res.ok) {
const payload = await res.json().catch(() => null) as { error?: string } | null;
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload);
if (recovery) return recovery;
throw new Error(payload?.error ?? `Failed to load health (${res.status})`);
throw new ApiError(payload?.error ?? `Failed to load health (${res.status})`, res.status, payload);
}
return res.json();
// Startup recovery can still serve sign-in and deployment metadata.
if (payload?.status !== "ok" && payload?.status !== "starting") throw new ApiUnavailableError(res.status);
return payload;
},
requestDevServerRestart: async (): Promise<void> => {
const res = await fetch("/api/health/dev-server/restart", {
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api, ApiError } from "./client";
import { authApi } from "./auth";
import { healthApi } from "./health";
import { ApiUnavailableError, isTemporaryApiError, readApiJson } from "./response";
afterEach(() => vi.unstubAllGlobals());
describe("API responses during a restart", () => {
it.each([200, 502, 503, 504])("handles HTML with status %i on every startup API", async (status) => {
const fetchMock = vi.fn().mockImplementation(async () => new Response("<!doctype html><h1>Restarting</h1>", {
status,
headers: { "Content-Type": "text/html" },
}));
vi.stubGlobal("fetch", fetchMock);
for (const read of [healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")]) {
await expect(read()).rejects.toMatchObject({
name: "ApiUnavailableError",
message: "Paperclip is temporarily unavailable. Please try again in a moment.",
status,
});
}
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("does not replay a mutation after an HTML response", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("<!doctype html>", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(api.post("/issues", { title: "Save once" })).rejects.toBeInstanceOf(ApiUnavailableError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("preserves empty mutation successes", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
await expect(api.delete("/issues/example")).resolves.toBeUndefined();
});
it("preserves valid startup health metadata for sign-in and invitations", async () => {
const payload = {
status: "starting",
deploymentMode: "authenticated",
cloud: { managed: true, managedBy: "paperclip-cloud", stackSlug: "example", cloudBaseUrl: "https://example.com" },
};
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload)));
await expect(healthApi.get()).resolves.toEqual(payload);
});
it.each([null, {}, { status: "unhealthy" }])("rejects an unusable health payload: %j", async (payload) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload)));
await expect(healthApi.get()).rejects.toBeInstanceOf(ApiUnavailableError);
});
it.each([healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")])(
"retains the status of JSON gateway errors for retry classification",
async (read) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "Service unavailable" }, { status: 503 })));
const error = await read().catch((error: unknown) => error);
expect(isTemporaryApiError(error)).toBe(true);
},
);
it("preserves HTTP denials with or without JSON", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response("Forbidden", { status: 403 }))
.mockResolvedValueOnce(Response.json({ error: "Access denied" }, { status: 403 })));
await expect(api.get("/denied")).rejects.toMatchObject({ name: "ApiError", status: 403 });
await expect(api.get("/denied")).rejects.toMatchObject({ message: "Access denied", status: 403 });
});
it("does not swallow an aborted response body", async () => {
const error = new DOMException("Aborted", "AbortError");
const response = new Response();
vi.spyOn(response, "json").mockRejectedValue(error);
await expect(readApiJson(response)).rejects.toBe(error);
});
it.each(["Failed to fetch", "NetworkError when attempting to fetch resource.", "Load failed"])(
"recognizes the browser network failure: %s", (message) => {
expect(isTemporaryApiError(new TypeError(message))).toBe(true);
},
);
it.each([
new ApiError("Unauthorized", 401, null),
new ApiError("Forbidden", 403, null),
new ApiError("Not found", 404, null),
new ApiError("Internal error", 500, null),
new TypeError("Cannot read properties of undefined"),
new DOMException("Aborted", "AbortError"),
])("does not automatically retry non-transport errors: %s", (error) => {
expect(isTemporaryApiError(error)).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
/** A proxy or restarting server answered an API request without usable JSON. */
export class ApiUnavailableError extends Error {
constructor(public readonly status: number) {
super("Paperclip is temporarily unavailable. Please try again in a moment.");
this.name = "ApiUnavailableError";
}
}
export function isTemporaryApiError(error: unknown): boolean {
if (error instanceof ApiUnavailableError) return true;
if (!(error instanceof Error)) return false;
if ("status" in error && [502, 503, 504].includes(error.status as number)) return true;
// Fetch uses different network-failure messages across browsers. Do not
// classify arbitrary TypeErrors (programming bugs) or aborts as outages.
return error instanceof TypeError && /fetch|network|load failed/i.test(error.message);
}
export async function readApiJson<T = unknown>(response: Response): Promise<T> {
try {
return await response.json() as T;
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
// An HTML fallback can have status 200 during a deployment. Never expose
// its parser error or treat it as a successful, empty API response.
if (response.ok || response.status >= 500) throw new ApiUnavailableError(response.status);
// Preserve HTTP/auth error handling even when a 4xx response has no JSON.
return null as T;
}
}
+237
View File
@@ -0,0 +1,237 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { focusManager, QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CloudAccessGate } from "./CloudAccessGate";
import { queryKeys } from "@/lib/queryKeys";
import { ApiUnavailableError } from "@/api/response";
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/CON/agents/conan/instructions", search: "?tab=edit", hash: "#draft" }),
Navigate: ({ to }: { to: string }) => <div data-redirect={to} />,
Outlet: () => <textarea aria-label="Instructions" defaultValue="Unsaved instructions" />,
}));
vi.mock("@/components/AnimatedPaperclipIcon", () => ({ PaperclipLoading: () => <div>Loading…</div> }));
vi.mock("@/components/CloudSignIn", () => ({ CloudSignIn: () => <div>Sign in to Cloud</div> }));
vi.mock("@/components/BootstrapPendingPage", () => ({ BootstrapPendingPage: () => <div>Set up instance</div> }));
const responses: Record<string, unknown> = {
"/api/health": { status: "ok", deploymentMode: "authenticated", bootstrapStatus: "ready" },
"/api/auth/get-session": {
session: { id: "session", userId: "user" },
user: { id: "user", name: "Operator", email: "operator@example.com", image: null },
sentryDsn: null,
},
"/api/cli-auth/me": { isInstanceAdmin: false, companyIds: ["company"] },
};
const checks = [
["/api/health", queryKeys.health],
["/api/auth/get-session", queryKeys.auth.session],
["/api/cli-auth/me", queryKeys.access.currentBoardAccess],
] as const;
describe("CloudAccessGate restart recovery", () => {
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
let failingPath: string | null;
let failure: () => Response;
const fetchMock = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
failingPath = null;
failure = () => new Response("<!doctype html><h1>Restarting</h1>", { status: 200 });
fetchMock.mockImplementation(async (path: string) => {
if (path === failingPath) return failure();
if (!(path in responses)) throw new Error(`Unexpected request: ${path}`);
return Response.json(responses[path]);
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
});
afterEach(() => {
flushSync(() => root.unmount());
client.clear();
focusManager.setFocused(undefined);
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
async function flushReact() {
await vi.advanceTimersByTimeAsync(20);
flushSync(() => {});
}
async function render() {
flushSync(() => root.render(<QueryClientProvider client={client}><CloudAccessGate /></QueryClientProvider>));
await flushReact();
}
it.each(checks)("recovers from an HTML response on %s without navigating", async (path) => {
failingPath = path;
await render();
expect(container.textContent).toContain("Reconnecting to Paperclip");
expect(container.textContent).not.toContain("Unexpected token");
expect(container.querySelector("[data-redirect]")).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
const callsBeforeRetry = fetchMock.mock.calls.filter(([url]) => url === path).length;
await vi.advanceTimersByTimeAsync(4_000);
expect(fetchMock.mock.calls.filter(([url]) => url === path)).toHaveLength(callsBeforeRetry);
failingPath = null;
await vi.advanceTimersByTimeAsync(1_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
expect(container.textContent).not.toContain("Reconnecting");
const callsAfterRecovery = fetchMock.mock.calls.length;
await vi.advanceTimersByTimeAsync(15_000);
expect(fetchMock).toHaveBeenCalledTimes(callsAfterRecovery);
expect(fetchMock.mock.calls.every(([, init]) => !init.method || init.method === "GET")).toBe(true);
});
it.each(checks)("preserves a mounted editor through an outage on %s", async (path, queryKey) => {
await render();
const editor = container.querySelector("textarea")!;
editor.value = "Keep my unsaved changes";
failingPath = path;
failure = () => new Response("Bad gateway", { status: 502 });
await client.refetchQueries({ queryKey });
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(editor.value).toBe("Keep my unsaved changes");
expect(container.textContent).toContain("Reconnecting automatically");
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(editor.value).toBe("Keep my unsaved changes");
expect(container.textContent).not.toContain("Reconnecting");
});
it.each(checks)("keeps retrying %s while the tab is hidden", async (path) => {
focusManager.setFocused(false);
failingPath = path;
await render();
expect(container.textContent).toContain("Reconnecting to Paperclip");
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("waits for ready health before opening the board even when access checks succeed", async () => {
failingPath = "/api/health";
failure = () => Response.json({ status: "starting", deploymentMode: "authenticated", bootstrapStatus: "ready" });
await render();
expect(container.querySelector("textarea")).toBeNull();
expect(container.textContent).toContain("Reconnecting");
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(checks.map(([path]) => path));
await vi.advanceTimersByTimeAsync(5_100);
expect(container.querySelector("textarea")).toBeNull();
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
expect(container.textContent).not.toContain("Reconnecting");
});
it("supports manual retry while startup health is pending", async () => {
fetchMock.mockResolvedValueOnce(Response.json({ status: "starting", deploymentMode: "local_trusted" }));
await render();
expect(container.querySelector("textarea")).toBeNull();
container.querySelector("button")!.click();
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("preserves an open editor while health reports startup recovery", async () => {
await render();
const editor = container.querySelector("textarea")!;
editor.value = "Keep my unsaved changes";
fetchMock.mockResolvedValueOnce(Response.json({
status: "starting", deploymentMode: "authenticated", bootstrapStatus: "ready",
}));
await client.refetchQueries({ queryKey: queryKeys.health });
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(container.textContent).toContain("Reconnecting automatically");
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(editor.value).toBe("Keep my unsaved changes");
expect(container.textContent).not.toContain("Reconnecting");
});
it("offers immediate retry while waiting for the next automatic check", async () => {
failingPath = "/api/health";
await render();
failingPath = null;
container.querySelector("button")!.click();
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("retries a dropped connection and opens the requested page when it returns", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await render();
expect(container.textContent).toContain("Reconnecting to Paperclip");
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
expect(container.querySelector("[data-redirect]")).toBeNull();
});
it("refreshes other failed reads after access checks recover", async () => {
const read = vi.fn().mockRejectedValueOnce(new ApiUnavailableError(503)).mockResolvedValue([]);
const observer = new QueryObserver(client, { queryKey: ["companies", "test"], queryFn: read, retry: false });
const unsubscribe = observer.subscribe(() => {});
failingPath = "/api/health";
await render();
expect(read).toHaveBeenCalledTimes(1);
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(read).toHaveBeenCalledTimes(2);
expect(observer.getCurrentResult().data).toEqual([]);
unsubscribe();
});
it("fails closed for access denials even with cached access, and does not poll", async () => {
await render();
failingPath = "/api/cli-auth/me";
failure = () => Response.json({ error: "Forbidden" }, { status: 403 });
await client.refetchQueries({ queryKey: queryKeys.access.currentBoardAccess });
await flushReact();
expect(container.textContent).toContain("Unable to load Paperclip");
expect(container.textContent).not.toContain("reconnect automatically");
expect(container.querySelector("textarea")).toBeNull();
const calls = fetchMock.mock.calls.length;
await vi.advanceTimersByTimeAsync(15_000);
expect(fetchMock).toHaveBeenCalledTimes(calls);
failingPath = null;
container.querySelector("button")!.click();
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("still redirects to sign-in when the session expires", async () => {
await render();
failingPath = "/api/auth/get-session";
failure = () => Response.json({ error: "unauthorized" }, { status: 401 });
await client.refetchQueries({ queryKey: queryKeys.auth.session });
await flushReact();
expect(container.querySelector("textarea")).toBeNull();
expect(container.querySelector("[data-redirect]")?.getAttribute("data-redirect"))
.toBe("/auth?next=%2FCON%2Fagents%2Fconan%2Finstructions%3Ftab%3Dedit");
});
});
+105 -28
View File
@@ -1,17 +1,53 @@
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { Navigate, Outlet, useLocation } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { authApi } from "@/api/auth";
import { healthApi } from "@/api/health";
import { isTemporaryApiError } from "@/api/response";
import { queryKeys } from "@/lib/queryKeys";
import { BootstrapPendingPage } from "@/components/BootstrapPendingPage";
import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CloudSignIn } from "@/components/CloudSignIn";
import { clearCloudSignInAttempt } from "@/lib/cloud-sign-in";
const RECONNECT_INTERVAL_MS = 5_000;
export function CloudAccessError({
temporary,
retrying,
onRetry,
}: {
temporary: boolean;
retrying: boolean;
onRetry: () => void;
}) {
return (
<div className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-16">
<RefreshCw className="size-6 text-muted-foreground" aria-hidden="true" />
<div className="flex flex-col gap-2" role="status">
<h1 className="text-xl font-semibold">
{temporary ? "Reconnecting to Paperclip" : "Unable to load Paperclip"}
</h1>
<p className="text-sm text-muted-foreground">
{temporary
? "The server may be restarting or your connection was interrupted. We’ll try again every few seconds and reconnect automatically."
: "We couldn’t check your access to this instance. Try again, or contact your instance administrator if this continues."}
</p>
</div>
<div>
<Button variant="outline" onClick={onRetry} disabled={retrying}>
{retrying ? "Connecting…" : "Try again"}
</Button>
</div>
</div>
);
}
function NoBoardAccessPage() {
return (
<div className="mx-auto max-w-xl py-10">
@@ -32,14 +68,15 @@ function NoBoardAccessPage() {
export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembershipRequest?: boolean } = {}) {
const location = useLocation();
const queryClient = useQueryClient();
const [hasOpenedBoard, setHasOpenedBoard] = useState(false);
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
| undefined;
if (query.state.error) return isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false;
const data = query.state.data;
if (data?.status === "starting") return RECONNECT_INTERVAL_MS;
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
? 2000
: false;
@@ -54,6 +91,8 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
queryFn: () => authApi.getSession(),
enabled: isAuthenticatedMode,
retry: false,
refetchInterval: (query) => isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false,
refetchIntervalInBackground: true,
});
useEffect(() => {
@@ -65,6 +104,8 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
queryFn: () => accessApi.getCurrentBoardAccess(),
enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data,
retry: false,
refetchInterval: (query) => isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false,
refetchIntervalInBackground: true,
});
const claimMutation = useMutation({
mutationFn: () => accessApi.claimBootstrapAdmin(),
@@ -77,26 +118,59 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
},
});
if (
const activeQueries = [
healthQuery,
...(isAuthenticatedMode ? [sessionQuery] : []),
...(isAuthenticatedMode && !isBootstrapPending && sessionQuery.data ? [boardAccessQuery] : []),
];
const isServerStarting = healthQuery.data?.status === "starting";
const isReconnecting = isServerStarting || activeQueries.some((query) => isTemporaryApiError(query.error));
// A background outage must not unmount editors and discard drafts. Cached
// access is only retained for transport failures; 401/403 still fail closed.
const blockingError = activeQueries.find((query) => query.error
&& !(query.data !== undefined && isTemporaryApiError(query.error)))?.error;
const isLoading =
healthQuery.isLoading ||
(isAuthenticatedMode && sessionQuery.isLoading) ||
(isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading)
) {
return <PaperclipLoading />;
(isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading);
const hasBoardAccess = allowMembershipRequest || !isAuthenticatedMode
|| !!boardAccessQuery.data?.isInstanceAdmin || (boardAccessQuery.data?.companyIds.length ?? 0) > 0;
const canAccessBoard = !isLoading && !blockingError && !isBootstrapPending
&& (!isAuthenticatedMode || !!sessionQuery.data) && hasBoardAccess;
useEffect(() => {
if (!canAccessBoard) setHasOpenedBoard(false);
else if (healthQuery.data?.status === "ok") setHasOpenedBoard(true);
}, [canAccessBoard, healthQuery.data?.status]);
const wasReconnecting = useRef(false);
useEffect(() => {
if (isReconnecting) {
wasReconnecting.current = true;
} else if (wasReconnecting.current && !isLoading && !blockingError) {
wasReconnecting.current = false;
// Other reads (including the company list) may have failed during boot.
// Refresh those too so recovery does not strand the user on a second error.
void queryClient.invalidateQueries({ predicate: (query) => isTemporaryApiError(query.state.error) });
}
}, [isReconnecting, isLoading, blockingError, queryClient]);
if (blockingError || (isServerStarting && !hasOpenedBoard)) {
return (
<CloudAccessError
temporary={blockingError ? isTemporaryApiError(blockingError) : true}
retrying={activeQueries.some((query) => query.isFetching)}
onRetry={() => {
for (const query of activeQueries) {
if (query.error || (query === healthQuery && isServerStarting)) {
void query.refetch({ cancelRefetch: false });
}
}
}}
/>
);
}
if (healthQuery.error || (isAuthenticatedMode && sessionQuery.error) || boardAccessQuery.error) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
{healthQuery.error instanceof Error
? healthQuery.error.message
: sessionQuery.error instanceof Error
? sessionQuery.error.message
: boardAccessQuery.error instanceof Error
? boardAccessQuery.error.message
: "Failed to load app state"}
</div>
);
if (isLoading) {
return <PaperclipLoading />;
}
if (isAuthenticatedMode && healthQuery.data?.cloud && !sessionQuery.data) {
@@ -132,15 +206,18 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
// Private invitation pages may let signed-in nonmembers request access.
// Their token APIs still enforce membership before granting any authority.
if (
!allowMembershipRequest &&
isAuthenticatedMode &&
sessionQuery.data &&
!boardAccessQuery.data?.isInstanceAdmin &&
(boardAccessQuery.data?.companyIds.length ?? 0) === 0
) {
if (!hasBoardAccess) {
return <NoBoardAccessPage />;
}
return <Outlet />;
return (
<>
{isReconnecting && (
<div role="status" className="bg-muted px-4 py-2 text-center text-sm text-muted-foreground">
Connection interrupted. Reconnecting automatically…
</div>
)}
<Outlet />
</>
);
}
+13
View File
@@ -1,5 +1,6 @@
import { DispositionRecoveryNotice } from "../components/DispositionRecoveryNotice";
import { CloudSignIn } from "../components/CloudSignIn";
import { CloudAccessError } from "../components/CloudAccessGate";
import { SetupPrompt } from "./apps/chat/SetupPrompt";
import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard";
import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning";
@@ -2374,6 +2375,18 @@ export function DesignGuide() {
</div>
</Section>
<Section title="Connection recovery">
<SubSection title="Waiting for server">
<CloudAccessError temporary retrying={false} onRetry={() => undefined} />
</SubSection>
<SubSection title="Checking connection">
<CloudAccessError temporary retrying onRetry={() => undefined} />
</SubSection>
<SubSection title="Access check failed">
<CloudAccessError temporary={false} retrying={false} onRetry={() => undefined} />
</SubSection>
</Section>
<Section title="Media artifacts">
<p className="text-sm text-muted-foreground">Images and videos use gallery tiles. The whole tile opens the task gallery; files and links keep compact, fully clickable rows. Task/Artifact Gallery in Storybook covers playable videos, mixed files, narrow panels, and unavailable previews.</p>
<div className="grid max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CloudAccessError } from "@/components/CloudAccessGate";
const meta: Meta<typeof CloudAccessError> = {
title: "App/Connection recovery",
component: CloudAccessError,
parameters: { layout: "fullscreen" },
args: { temporary: true, retrying: false, onRetry: () => undefined },
};
export default meta;
type Story = StoryObj<typeof CloudAccessError>;
export const WaitingForServer: Story = {};
export const CheckingConnection: Story = { args: { retrying: true } };
export const AccessCheckFailed: Story = { args: { temporary: false } };