Files
PaperClipAI/ui/src/hooks/useMuseConnection.ts
T
DottaandPaperclip 896f3c7f3b Add personal Muse operator setup and composer controls (#16450)
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>
2026-10-17 23:01:42 -05:00

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 };
}