mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Add Muse invitation, connection readiness and scoped operator controls.
Keep connection state tied to the current company, person, agent and binding. Surface failed reads and invalidate stale cleanup observations; require current authority and exact readback. Preserve Dot/Grok setup, source-trust limits and existing design tokens.
Carry actual merged backend e6dcc6b095 through a normal forward merge. Preserve its project-write authority fix, existing migration bytes and upstream idle-serving behavior; no new UI behavior is authored by this source carry. Current-head CI and substantive review remain merge gates; final canonical and deployed-provider qualification remain separate.
Refs #16450.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
92 lines
6.5 KiB
TypeScript
92 lines
6.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { MuseConnection, MuseBinding, MusePairing, MusePairingInput, MuseAttestStopInput, MuseDetectorCleanupAttestationInput } from "@paperclipai/shared";
|
|
import { museInvitationsApi } from "@/api/museInvitations";
|
|
import { useAccountIdentity } from "@/api/companies-query";
|
|
import type { MuseConnectionState } from "@/components/new-agent/ExternalAgentInviteContent";
|
|
|
|
export function museConnectionState(connection: MuseConnection | undefined,
|
|
{ offline = false, promptUnavailable = false }: { offline?: boolean; promptUnavailable?: boolean } = {}): MuseConnectionState {
|
|
const b = connection?.binding;
|
|
const passed = !!b?.paired && b.receiverDetected && b.backgroundReplyVerified;
|
|
const finishing = connection?.canConfigureConnection && ["preparing", "verifying"].includes(connection.agentLifecycleState);
|
|
const ready = passed && b.credentialState === "active" && b.status === "ready" && connection?.agentLifecycleState === "ready"
|
|
&& connection.canConfigureConnection && connection.enabled && connection.agentStatus !== "pending_approval";
|
|
const testing = !!b?.challengeExpiresAt && Date.parse(b.challengeExpiresAt) > Date.now();
|
|
const expiredTest = !!b?.challengeExpiresAt && Date.parse(b.challengeExpiresAt) <= Date.now();
|
|
return {
|
|
paired: b?.paired ?? false, receiverDetected: b?.receiverDetected ?? false,
|
|
backgroundReplyVerified: b?.backgroundReplyVerified ?? false, ready, finishing, testing,
|
|
problem: offline ? "offline" : b?.status === "revoked" ? "disconnected" : b?.credentialState === "expired" ? "connection_expired" : b?.credentialState === "revoked" ? "disconnected" : connection && !connection.enabled ? "disabled" : promptUnavailable && !b?.paired ? "prompt_unavailable"
|
|
: passed && !ready && !finishing ? "agent_unavailable" : expiredTest ? "no_recent_response" : undefined,
|
|
};
|
|
}
|
|
|
|
/** Expire the current test even when polling returns unchanged historical evidence. */
|
|
export function useMuseConnectionState(connection: MuseConnection | undefined,
|
|
options: { offline?: boolean; promptUnavailable?: boolean } = {}) {
|
|
const [, updateClock] = useState(0);
|
|
const expiresAt = connection?.binding?.challengeExpiresAt;
|
|
useEffect(() => {
|
|
if (!expiresAt) return;
|
|
const delay = Date.parse(expiresAt) - Date.now();
|
|
if (!Number.isFinite(delay) || delay <= 0) return;
|
|
const timer = window.setTimeout(() => updateClock(value => value + 1), delay);
|
|
return () => window.clearTimeout(timer);
|
|
}, [expiresAt]);
|
|
return museConnectionState(connection, options);
|
|
}
|
|
|
|
export function museBindingRevision(binding: MuseBinding) {
|
|
return { bindingId: binding.id, generation: binding.generation, expectedRevision: binding.revision };
|
|
}
|
|
|
|
/** Read queries contain only public evidence. Pairing results live in this mounted hook. */
|
|
export function useMuseConnection(companyId: string | undefined, agentId: string | undefined, enabled = true) {
|
|
const cache = useQueryClient();
|
|
const identity = useAccountIdentity();
|
|
const key = ["muse-binding", companyId, identity.userId, agentId];
|
|
const scope = `${companyId}:${agentId}:${identity.userId}`;
|
|
const currentScope = useRef(scope); currentScope.current = scope;
|
|
const [pairing, setPairing] = useState<MusePairing | null>(null);
|
|
const state = useQuery({ queryKey: key, enabled: enabled && identity.settled && !!companyId && !!agentId,
|
|
queryFn: ({ signal }) => museInvitationsApi.connection(companyId!, agentId!, signal), retry: false, staleTime: 0,
|
|
refetchInterval: query => query.state.error ? false
|
|
: query.state.data?.binding || query.state.data?.agentStatus === "pending_approval" ? 5000 : false });
|
|
const refresh = () => cache.invalidateQueries({ queryKey: key });
|
|
const pair = useMutation({ mutationFn: async (input: MusePairingInput) => {
|
|
const requestScope = scope;
|
|
const result = await museInvitationsApi.pair(companyId!, agentId!, input);
|
|
if (currentScope.current === requestScope) setPairing(result);
|
|
// Returning void prevents the transient capability entering the mutation cache.
|
|
}, onSuccess: () => { void refresh(); }, onError: () => { setPairing(null); void state.refetch(); }, gcTime: 0 });
|
|
const verify = useMutation({ mutationFn: async (binding: MuseBinding) => {
|
|
await museInvitationsApi.verify(companyId!, agentId!, museBindingRevision(binding));
|
|
}, onSuccess: () => { void refresh(); }, onError: () => { void state.refetch(); } });
|
|
const revoke = useMutation({ mutationFn: async (binding: MuseBinding) => {
|
|
await museInvitationsApi.revoke(companyId!, agentId!, museBindingRevision(binding));
|
|
}, onSuccess: () => { setPairing(null); void refresh(); } });
|
|
const attest = useMutation({ mutationFn: (input: MuseAttestStopInput) => museInvitationsApi.attestStop(companyId!, agentId!, input),
|
|
onSuccess: () => { void refresh(); }, onError: () => { void state.refetch(); } });
|
|
const attestDetector = useMutation({ mutationFn: async (input: MuseDetectorCleanupAttestationInput) => {
|
|
const requestScope = scope;
|
|
const current = await state.refetch({ throwOnError: true });
|
|
const b = current.data?.binding;
|
|
if (currentScope.current !== requestScope || !b || !b.cleanup.attestationEligible || b.id !== input.bindingId || b.generation !== input.generation || b.revision !== input.expectedRevision || (b.qualification?.id ?? null) !== input.qualificationId || b.cleanup.hookScript !== input.hookScript) throw new Error("Connection changed. Refresh before recording detector cleanup.");
|
|
await museInvitationsApi.attestDetectorCleanup(companyId!, agentId!, input);
|
|
await state.refetch({ throwOnError: true });
|
|
}, onError: () => { void state.refetch(); } });
|
|
useEffect(() => { setPairing(null); }, [companyId, agentId, identity.userId]);
|
|
useEffect(() => {
|
|
if (!pairing) return;
|
|
const timer = window.setTimeout(() => setPairing(null), Math.max(0, Date.parse(pairing.expiresAt) - Date.now()));
|
|
return () => window.clearTimeout(timer);
|
|
}, [pairing]);
|
|
const binding = state.data?.binding;
|
|
const currentPairing = pairing && pairing.bindingId === binding?.id && pairing.generation === binding.generation
|
|
&& pairing.revision === binding.revision && !binding.paired ? pairing : null;
|
|
return { state, binding, pairing: currentPairing, pair, verify, revoke, attest, attestDetector,
|
|
repair: () => pair.mutate(binding && binding.status !== "revoked" ? { replaceBindingId: binding.id, expectedRevision: binding.revision } : {}),
|
|
key, identity, refresh };
|
|
}
|