mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-06 07:02:11 +02:00
Compare commits
1 Commits
pr/pap-817
...
pr/pap-817
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5561a9c17f |
@@ -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()
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user