Compare commits

..

1 Commits

Author SHA1 Message Date
dotta
5561a9c17f Improve CLI API connection errors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:48:43 -05:00
5 changed files with 165 additions and 150 deletions

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
describe("PaperclipApiClient", () => {
afterEach(() => {
@@ -59,6 +59,29 @@ describe("PaperclipApiClient", () => {
} satisfies Partial<ApiRequestError>);
});
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
url: "http://localhost:3100/api/companies/import/preview",
method: "POST",
causeMessage: "fetch failed",
} satisfies Partial<ApiConnectionError>);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/Could not reach the Paperclip API\./,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
);
});
it("retries once after interactive auth recovery", async () => {
const fetchMock = vi
.fn()

View File

@@ -13,6 +13,26 @@ export class ApiRequestError extends Error {
}
}
export class ApiConnectionError extends Error {
url: string;
method: string;
causeMessage?: string;
constructor(input: {
apiBase: string;
path: string;
method: string;
cause?: unknown;
}) {
const url = buildUrl(input.apiBase, input.path);
const causeMessage = formatConnectionCause(input.cause);
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
this.url = url;
this.method = input.method;
this.causeMessage = causeMessage;
}
}
interface RequestOptions {
ignoreNotFound?: boolean;
}
@@ -76,6 +96,7 @@ export class PaperclipApiClient {
hasRetriedAuth = false,
): Promise<T | null> {
const url = buildUrl(this.apiBase, path);
const method = String(init.method ?? "GET").toUpperCase();
const headers: Record<string, string> = {
accept: "application/json",
@@ -94,10 +115,20 @@ export class PaperclipApiClient {
headers["x-paperclip-run-id"] = this.runId;
}
const response = await fetch(url, {
...init,
headers,
});
let response: Response;
try {
response = await fetch(url, {
...init,
headers,
});
} catch (error) {
throw new ApiConnectionError({
apiBase: this.apiBase,
path,
method,
cause: error,
});
}
if (opts?.ignoreNotFound && response.status === 404) {
return null;
@@ -108,7 +139,7 @@ export class PaperclipApiClient {
if (!hasRetriedAuth && this.recoverAuth) {
const recoveredToken = await this.recoverAuth({
path,
method: String(init.method ?? "GET").toUpperCase(),
method,
error: apiError,
});
if (recoveredToken) {
@@ -166,6 +197,50 @@ async function toApiError(response: Response): Promise<ApiRequestError> {
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
}
function buildConnectionErrorMessage(input: {
apiBase: string;
url: string;
method: string;
causeMessage?: string;
}): string {
const healthUrl = buildHealthCheckUrl(input.url);
const lines = [
"Could not reach the Paperclip API.",
"",
`Request: ${input.method} ${input.url}`,
];
if (input.causeMessage) {
lines.push(`Cause: ${input.causeMessage}`);
}
lines.push(
"",
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
"",
"Try:",
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
);
return lines.join("\n");
}
function buildHealthCheckUrl(requestUrl: string): string {
const url = new URL(requestUrl);
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
url.search = "";
url.hash = "";
return url.toString();
}
function formatConnectionCause(error: unknown): string | undefined {
if (!error) return undefined;
if (error instanceof Error) {
return error.message.trim() || error.name;
}
const message = String(error).trim();
return message || undefined;
}
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (Array.isArray(headers)) {

View File

@@ -296,7 +296,6 @@ describe("inbox helpers", () => {
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
@@ -306,37 +305,6 @@ describe("inbox helpers", () => {
]);
});
it("mixes join requests into the inbox feed by most recent activity", () => {
const issue = makeIssue("1", true);
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
const joinRequest = makeJoinRequest("join-1");
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-oldest",
"pending",
"2026-03-11T02:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [issue],
approvals: [approval],
joinRequests: [joinRequest],
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
"issue:1",
"join:join-1",
"approval:approval-oldest",
]);
});
it("can include sections on recent without forcing them to be unread", () => {
expect(
shouldShowInboxSection({

View File

@@ -28,11 +28,6 @@ export type InboxWorkItem =
kind: "failed_run";
timestamp: number;
run: HeartbeatRun;
}
| {
kind: "join_request";
timestamp: number;
joinRequest: JoinRequest;
};
export interface InboxBadgeData {
@@ -157,12 +152,10 @@ export function getInboxWorkItems({
issues,
approvals,
failedRuns = [],
joinRequests = [],
}: {
issues: Issue[];
approvals: Approval[];
failedRuns?: HeartbeatRun[];
joinRequests?: JoinRequest[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
@@ -180,11 +173,6 @@ export function getInboxWorkItems({
timestamp: normalizeTimestamp(run.createdAt),
run,
})),
...joinRequests.map((joinRequest) => ({
kind: "join_request" as const,
timestamp: normalizeTimestamp(joinRequest.createdAt),
joinRequest,
})),
].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff;

View File

@@ -36,7 +36,6 @@ import {
XCircle,
X,
RotateCcw,
UserPlus,
} from "lucide-react";
import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
@@ -62,6 +61,7 @@ type InboxCategoryFilter =
| "alerts";
type SectionKey =
| "work_items"
| "join_requests"
| "alerts";
function firstNonEmptyLine(value: string | null | undefined): string | null {
@@ -281,84 +281,6 @@ function ApprovalInboxRow({
);
}
function JoinRequestInboxRow({
joinRequest,
onApprove,
onReject,
isPending,
}: {
joinRequest: JoinRequest;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
}) {
const label =
joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="flex items-start gap-2 sm:items-center">
<div className="flex min-w-0 flex-1 items-start gap-2">
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<UserPlus className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{label}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}</span>
{joinRequest.adapterType && <span>adapter: {joinRequest.adapterType}</span>}
</span>
</span>
</div>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
</div>
);
}
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -509,22 +431,14 @@ export function Inbox() {
return failedRuns;
}, [failedRuns, tab, showFailedRunsCategory]);
const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return [];
if (tab === "recent") return joinRequests;
if (tab === "unread") return joinRequests;
return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory]);
const workItemsToRender = useMemo(
() =>
getInboxWorkItems({
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
failedRuns: failedRunsForTab,
joinRequests: joinRequestsForTab,
}),
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
);
const agentName = (id: string | null) => {
@@ -688,7 +602,10 @@ export function Inbox() {
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const hasJoinRequests = joinRequests.length > 0;
const showWorkItemsSection = workItemsToRender.length > 0;
const showJoinRequestsSection =
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showAlertsSection = shouldShowInboxSection({
tab,
hasItems: hasAlerts,
@@ -699,6 +616,7 @@ export function Inbox() {
const visibleSections = [
showAlertsSection ? "alerts" : null,
showJoinRequestsSection ? "join_requests" : null,
showWorkItemsSection ? "work_items" : null,
].filter((key): key is SectionKey => key !== null);
@@ -839,18 +757,6 @@ export function Inbox() {
);
}
if (item.kind === "join_request") {
return (
<JoinRequestInboxRow
key={`join:${item.joinRequest.id}`}
joinRequest={item.joinRequest}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
/>
);
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
@@ -900,6 +806,61 @@ export function Inbox() {
</>
)}
{showJoinRequestsSection && (
<>
{showSeparatorBefore("join_requests") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Join Requests
</h3>
<div className="grid gap-3">
{joinRequests.map((joinRequest) => (
<div key={joinRequest.id} className="rounded-xl border border-border bg-card p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`}
</p>
<p className="text-xs text-muted-foreground">
requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}
</p>
{joinRequest.requestEmailSnapshot && (
<p className="text-xs text-muted-foreground">
email: {joinRequest.requestEmailSnapshot}
</p>
)}
{joinRequest.adapterType && (
<p className="text-xs text-muted-foreground">adapter: {joinRequest.adapterType}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
onClick={() => rejectJoinMutation.mutate(joinRequest)}
>
Reject
</Button>
<Button
size="sm"
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
onClick={() => approveJoinMutation.mutate(joinRequest)}
>
Approve
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
{showAlertsSection && (
<>
{showSeparatorBefore("alerts") && <Separator />}