mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Dot joins through a public MCP connection and the Runner task system. > - A new hire must connect before its lifecycle can become ready. > - The invite UI treated the hire's temporary paused status as a manual pause. > - Anonymous Cloud consent also reloaded after protected background probes returned 401. > - This pull request fixes both gates without changing server access checks. > - The benefit is that a new Dot can complete Cloud setup and the UI waits for actual readiness. ## Linked Issues or Issue Description Related merged work: #15691 and #15581. I searched open Dot PR and issue titles. I found no duplicate. **What happened?** A new Dot invite displayed “Resume this agent before connecting Dot.” The hire was preparing or verifying. The anonymous Cloud consent page remained on “Loading connection request…” because background tenant-session failures caused repeated document reloads. **Expected behavior** The invite must issue a setup prompt when the server permits connection setup. Public Dot consent must load without a board session. The Done button must wait for both the binding and agent lifecycle to become ready. **Steps to reproduce** 1. Enable Dot, Assistant connections (MCP), and the native Runner on a Cloud test tenant. 2. Choose New Agent, Invite an external agent, then Dot. 3. Observe the false resume gate on the new hire. 4. Open the plugin's public Dot consent page in a browser without a tenant board session. 5. Observe the loading loop after a protected background probe returns `tenant_session_required`. **Paperclip version or commit** Reproduced on `556daf9a99e8674f9c4a1c38ac20ae9adafdd417`. **Deployment mode** Cloud staging with a managed Daytona Runner. No production tenant changes. ## What Changed - Return lifecycle state and connection setup authority in the operator Dot binding response. - Use that authority to prepare prompts during hire preparation and verification. - Continue polling after event readiness until the agent lifecycle is ready. - Keep expected tenant-session failures from reloading an exact public Dot consent route. - Classify Dot consent as a global route. Retain board session and archived-stack recovery. - Add regression tests and update the Dot deployment documentation. ## Verification - Before the fix, regression tests reproduced the false pairing gate and anonymous consent reload. - After the fix, 34 Dot invitation tests and 51 public consent/API/route tests passed before rebasing. - Full workspace typecheck, full build, final UI typecheck, and design token gates passed locally. All current-head CI gates are green, including full tests and browser E2E. - The final UI regression run passed 80 tests. The broad local test run has reported failures in unchanged chat, OAuth-tool-access, and native-resume suites while running alongside other development instances; the equivalent isolated CI lanes passed. The broad local run was stopped after these failures to release local resources; it is not claimed as passing. - Live Cloud qualification passed OAuth discovery, the approved agent grant, ChatGPT account handoff, a signed event subscription, and the inbox readiness challenge. The agent became ready and normal scheduling launched the managed Runner. A real Dot accepted the task, read its context, wrote and verified a document, posted a comment, and completed through paperclip_finish and paperclip_dot_finish. The task persisted as Done after reload, and the successful turn's sandbox was destroyed with a successful cleanup receipt. - Restarting the existing Dot refreshed its newly installed plugin actions. The first Daytona allocation remained creating and hit the acquisition deadline; a bounded normal retry acquired a sandbox and launched through authenticated provider ingress. The earlier allocation still rejects deletion while creating (HTTP 409), so its failed-run cleanup remains pending. - After deployment, use a real Dot to open the consent page without board credentials, pair, confirm task events, and finish an assigned task through the managed Runner. ## Risks The operator binding response has two new required fields. Server and UI must deploy together. The recovery exception only applies to a valid Dot consent pathname and tenant-session 401 responses. It does not expose protected APIs or suppress archived-stack recovery. No schema migration or agent permission change is required. ## Model Used OpenAI GPT-6 through Codex. Used reasoning, repository tools, test execution, and browser control. The runtime does not expose a more specific model ID or context window size. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
165 lines
11 KiB
TypeScript
165 lines
11 KiB
TypeScript
import { Link } from "@/lib/router";
|
||
import { ArrowLeft, Check, Circle, CircleAlert, Loader2 } from "lucide-react";
|
||
import { AdapterMark } from "../AdapterMark";
|
||
import { AgentSetupPrompt } from "../AgentSetupPrompt";
|
||
import { Button } from "../ui/button";
|
||
import { DialogDescription, DialogTitle } from "../ui/dialog";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
export type ExternalAgentPreset = "dot" | "hermes" | "other";
|
||
export type DotConnectionState = {
|
||
phase: "waiting" | "connected" | "subscribed" | "testing" | "finishing" | "ready";
|
||
problem?: "event_timeout" | "prompt_unavailable" | "offline" | "agent_unavailable";
|
||
};
|
||
|
||
const presets = [
|
||
{ id: "dot", name: "Dot", adapter: "openai_dot", description: "Your Dot in ChatGPT" },
|
||
{ id: "hermes", name: "Hermes", adapter: "hermes_gateway", description: "An existing Hermes agent" },
|
||
{ id: "other", name: "Other", adapter: "http", description: "Any agent that can connect to Paperclip" },
|
||
] as const;
|
||
|
||
export function ExternalAgentPresetPicker({ onSelect, dotDisabledReason }: { onSelect: (preset: ExternalAgentPreset) => void; dotDisabledReason?: string }) {
|
||
return <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||
{presets.map(preset => <button
|
||
key={preset.id}
|
||
type="button"
|
||
onClick={() => onSelect(preset.id)}
|
||
disabled={preset.id === "dot" && !!dotDisabledReason}
|
||
title={preset.id === "dot" ? dotDisabledReason : undefined}
|
||
className="flex aspect-square flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card px-3 py-4 text-center transition-colors disabled:opacity-50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<AdapterMark type={preset.adapter} className="size-8" />
|
||
<span className="text-sm font-medium">{preset.name}</span>
|
||
<span className="sr-only">{preset.description}</span>
|
||
</button>)}
|
||
</div>;
|
||
}
|
||
|
||
const checks = [
|
||
{ title: "Connected to Paperclip", description: "Your Dot has connected to this agent." },
|
||
{ title: "Task updates enabled", description: "Your Dot is subscribed to new assignments and messages." },
|
||
{ title: "Test event confirmed", description: "Your Dot received our test and replied to Paperclip." },
|
||
];
|
||
|
||
/** Controlled by server evidence in the invite controller, never by copying a prompt. */
|
||
export function DotConnectionChecks({ state }: { state: DotConnectionState }) {
|
||
const completed = { waiting: 0, connected: 1, subscribed: 2, testing: 2, finishing: 3, ready: 3 }[state.phase];
|
||
const message = state.problem === "agent_unavailable" ? "Connection checks passed, but this agent cannot receive tasks. Resolve the agent’s setup blocker in Paperclip."
|
||
: state.problem === "offline" ? "Connection updates paused. Reconnect to check the latest status."
|
||
: state.problem === "prompt_unavailable" ? "This setup prompt was replaced in another window. Create a fresh prompt to continue."
|
||
: state.problem === "event_timeout" ? "Your Dot connected, but hasn’t confirmed the test event. Ask it to check Paperclip, then retry."
|
||
: state.phase === "finishing" ? "Your Dot confirmed the test event. Paperclip is finishing agent setup."
|
||
: state.phase === "ready" ? "Your Dot is ready for tasks. Messages can travel both ways."
|
||
: state.phase === "waiting" ? "Watching for your Dot. Updates will appear here automatically."
|
||
: state.phase === "connected" ? "Your Dot connected. Waiting for it to enable task updates."
|
||
: "Waiting for your Dot to confirm a test event. No task will be created.";
|
||
return <section aria-label="Dot connection checks" className="space-y-4">
|
||
<ol className="space-y-4">
|
||
{checks.map((check, index) => {
|
||
const done = index < completed;
|
||
const failed = index === completed && state.problem === "event_timeout";
|
||
const active = index === completed && !state.problem;
|
||
const Icon = done ? Check : failed ? CircleAlert : active ? Loader2 : Circle;
|
||
return <li key={check.title} className="flex items-start gap-3" aria-current={active ? "step" : undefined}>
|
||
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full", done ? "bg-accent text-foreground" : failed ? "text-destructive" : "text-muted-foreground")}>
|
||
<Icon className={cn("size-4", active && "animate-spin motion-reduce:animate-none")} />
|
||
</span>
|
||
<div className="min-w-0 pt-0.5">
|
||
<p className={cn("text-sm", done || active || failed ? "font-medium" : "text-muted-foreground")}>
|
||
{check.title}<span className="sr-only">{done ? ": complete" : failed ? ": needs attention" : active ? ": in progress" : ": waiting"}</span>
|
||
</p>
|
||
{done && <p className="mt-1 text-xs leading-relaxed text-muted-foreground">{check.description}</p>}
|
||
</div>
|
||
</li>;
|
||
})}
|
||
</ol>
|
||
<p role={state.problem && state.problem !== "prompt_unavailable" ? "alert" : "status"} aria-live="polite" className={cn("text-sm leading-relaxed", state.problem && state.problem !== "prompt_unavailable" ? "text-destructive" : "text-muted-foreground")}>{message}</p>
|
||
</section>;
|
||
}
|
||
|
||
/** Shared presentation for the invitation flow. The caller owns invitations and watching. */
|
||
export function ExternalAgentInviteContent({
|
||
preset, prompt, companyName, connection, onSelect, onBack, onClose, onCopied, onRetry, onNewPrompt,
|
||
dotDisabledReason, busy = false, error, approvalHref,
|
||
}: {
|
||
dotDisabledReason?: string;
|
||
busy?: boolean;
|
||
error?: string;
|
||
approvalHref?: string;
|
||
preset: ExternalAgentPreset | null;
|
||
prompt: string;
|
||
companyName: string;
|
||
connection: DotConnectionState;
|
||
onSelect: (preset: ExternalAgentPreset) => void;
|
||
onBack: () => void;
|
||
onClose: () => void;
|
||
onCopied: () => void;
|
||
onRetry: () => void;
|
||
onNewPrompt: () => void;
|
||
}) {
|
||
const provider = presets.find(item => item.id === preset);
|
||
const dot = preset === "dot";
|
||
const ready = dot && connection.phase === "ready" && !connection.problem;
|
||
const connecting = dot && connection.phase !== "waiting";
|
||
const progressLabel = connection.problem === "agent_unavailable" ? "Agent unavailable"
|
||
: connection.problem === "offline" ? "Updates paused"
|
||
: connection.problem === "event_timeout" ? "Needs attention"
|
||
: connection.phase === "finishing" ? "Finishing setup…"
|
||
: connection.phase === "connected" ? "Connecting…" : "Confirming connection…";
|
||
return <>
|
||
<div className="min-h-0 space-y-6 overflow-y-auto px-6 pb-6 pt-8 sm:px-8">
|
||
<div className="space-y-3 pr-5">
|
||
{provider && <AdapterMark type={provider.adapter} className="size-10" />}
|
||
<div className="space-y-2">
|
||
<DialogTitle className="text-xl font-semibold tracking-tight">
|
||
{!provider ? "Invite an external agent" : dot ? ready ? "Your Dot is connected" : "Connect your Dot" : preset === "hermes" ? "Invite your Hermes agent" : "Invite your agent"}
|
||
</DialogTitle>
|
||
<DialogDescription className="text-sm leading-relaxed">
|
||
{!provider ? `Bring an agent you already use into ${companyName}.`
|
||
: ready ? `Your Dot can now receive assignments and work with ${companyName}.`
|
||
: connection.problem === "agent_unavailable" ? "Your Dot is connected. This agent must be available in Paperclip before it can receive assignments."
|
||
: connection.phase === "finishing" ? "Your Dot has connected and confirmed task updates. Paperclip is preparing it for assignments."
|
||
: connecting ? "Your Dot has connected. We’re checking that task updates can travel both ways."
|
||
: dot ? "Copy the setup prompt and send it to your Dot in ChatGPT. Your Dot will connect itself; we’ll watch for it here."
|
||
: `Copy the invitation prompt and send it to your ${preset === "hermes" ? "Hermes " : ""}agent. Approve its join request in Paperclip when it’s ready.`}
|
||
</DialogDescription>
|
||
</div>
|
||
</div>
|
||
{error && <div role="alert" className="space-y-3 text-sm text-destructive"><p>{error}</p><Button variant="outline" disabled={busy} onClick={onRetry}>Try again</Button></div>}
|
||
{!provider ? <><ExternalAgentPresetPicker onSelect={onSelect} dotDisabledReason={dotDisabledReason} />
|
||
{dotDisabledReason && <p className="text-sm text-muted-foreground">{dotDisabledReason}</p>}</>
|
||
: approvalHref ? <p className="text-sm text-muted-foreground">An organization admin needs to approve this agent before Dot can connect. This page will update after approval.</p>
|
||
: busy && !prompt && connection.phase === "waiting" ? <p role="status" className="text-sm text-muted-foreground">Preparing your invitation…</p> : dot ? <>
|
||
<div className="border-t pt-6"><DotConnectionChecks state={connection} />
|
||
{connection.phase === "waiting" && <p className="mt-4 text-xs text-muted-foreground">Dot uses your OpenAI account. Provider usage and cost aren’t reported to Paperclip.</p>}</div>
|
||
{connection.problem === "event_timeout" && <Button variant="outline" disabled={busy} onClick={onRetry}>Retry test event</Button>}
|
||
{connection.problem === "offline" && <Button variant="outline" disabled={busy} onClick={onRetry}>Reconnect updates</Button>}
|
||
</> : <p className="text-sm leading-relaxed text-muted-foreground">The invitation includes everything your agent needs to join {companyName}. You can review its request before granting access.</p>}
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3 border-t px-6 py-4 sm:px-8">
|
||
<Button variant="ghost" onClick={provider ? onBack : onClose}>
|
||
{provider && <ArrowLeft className="size-4" />}{provider ? "Back" : "Cancel"}
|
||
</Button>
|
||
{provider && (approvalHref ? <Button asChild><Link to={approvalHref} onClick={onClose}>Review approval</Link></Button>
|
||
: ready ? <Button onClick={onClose}>Done</Button>
|
||
: connecting ? <Button disabled aria-live="polite">
|
||
{!connection.problem && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
|
||
{progressLabel}
|
||
</Button>
|
||
: dot && connection.problem === "prompt_unavailable" ? <Button disabled={busy} onClick={onNewPrompt}>Create a new prompt</Button>
|
||
: !prompt || busy ? <Button disabled>{busy ? "Preparing…" : "Waiting for Dot…"}</Button>
|
||
: <AgentSetupPrompt
|
||
key={preset}
|
||
prompt={prompt}
|
||
label={dot ? "Copy setup prompt" : "Copy invitation prompt"}
|
||
title={dot ? "Connect your Dot" : "Invite your agent"}
|
||
description={dot ? "Send this whole prompt to your Dot in ChatGPT." : "Send this whole prompt to your agent."}
|
||
agent={dot ? { name: "Dot", src: "/brands/adapters/openai-dot.svg" } : undefined}
|
||
align="end"
|
||
className="min-w-0 shrink"
|
||
onCopied={onCopied}
|
||
/>)}
|
||
</div>
|
||
</>;
|
||
}
|