import { type AccessContext, type PersonalResourceAttachmentIntent, type ResourceAuthorityScope, type SendMessageInput, type Session, type Workspace, } from "@opengeni/sdk"; import type { OpenGeniBrowserClient } from "@opengeni/sdk/browser"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AuthSession } from "../types"; import type { ManagedSelfContext } from "./managed-self-context"; import { hasWorkspacePermission } from "./permissions"; import { buildPersonalResourceAttachmentIntent, isPersonalAttachmentConflict, loadPersonalResourceCatalog, personalSelection, resolvePersonalResourceOwnerScope, type FixedPersonalResources, type PersonalAttachmentMode, type PersonalResourceCatalog, } from "./personal-resource-attachments"; type PersonalResourceAttachmentAttempt = { personalResourceAttachment?: PersonalResourceAttachmentIntent | undefined; }; type SessionAuthorityRecovery = Readonly<{ identityKey: string; expectedAuthorityEpoch: number | undefined; selectedResourceCount: number; status: "pending" | "reload_settled" | "failed"; error: Error | null; }>; export type PersonalResourceNotice = "source_changed" | "reloading" | "reload_failed" | "reloaded"; export type PersonalResourceAttachmentController = Readonly<{ eligible: boolean; loading: boolean; refreshing: boolean; error: Error | null; notice: PersonalResourceNotice | null; sourceLost: boolean; truncated: boolean; catalog: PersonalResourceCatalog | null; selected: ReturnType; mode: PersonalAttachmentMode | null; visibility: "private" | "workspace"; requiresDecision: boolean; intent: PersonalResourceAttachmentIntent | undefined; refresh: () => Promise; onAccepted: ( input: | SendMessageInput | { personalResourceAttachment?: PersonalResourceAttachmentIntent | undefined }, ) => void; onDeliveryError: ( error: Error, input: PersonalResourceAttachmentAttempt, delivery: "send" | "steer" | "create", ) => void; }>; /** * Resolve the ordinary catalog scope for an already-fixed session Rig without * importing the broad @opengeni/react root into the direct-session route. * Failure stays unknown: only a positive `user` classification may turn the * optional Personal catalog into submission UI. */ export function useFixedRigScope( client: OpenGeniBrowserClient, workspaceId: string | null, rigId: string | null, enabled = true, ): ResourceAuthorityScope | null { const identity = !enabled || workspaceId === null || rigId === null ? null : `${workspaceId}:${rigId}`; const [resolved, setResolved] = useState( null, ); useEffect(() => { if (identity === null || workspaceId === null || rigId === null) return; let current = true; void client .getRig(workspaceId, rigId) .then((resource) => resource.scope) .catch(() => null) .then((rigScope) => { if (current) setResolved([identity, rigScope]); }); return () => { current = false; }; }, [client, identity, rigId, workspaceId]); return resolved?.[0] === identity ? resolved[1] : null; } export function usePersonalResourceAttachment(input: { client: OpenGeniBrowserClient; authMode: string; authSession: AuthSession | null; accessSubjectId: string; accessContext: AccessContext | null; managedSelfContext: ManagedSelfContext | null; workspace: Pick | null; session?: Pick | null | undefined; fixed: FixedPersonalResources; personalWorkspaceTarget: boolean; createVisibility?: "private" | "workspace" | undefined; enabled?: boolean | undefined; onReloadSession?: (() => Promise) | undefined; }): PersonalResourceAttachmentController { const onReloadSession = input.onReloadSession; const resolvedScope = input.enabled === false ? null : resolvePersonalResourceOwnerScope({ authMode: input.authMode, authSession: input.authSession, accessSubjectId: input.accessSubjectId, managedSelfContext: input.managedSelfContext, workspace: input.workspace, session: input.session, }); const resolvedIdentityKey = resolvedScope?.identityKey; const resolvedSubjectId = resolvedScope?.subjectId; const resolvedOrganizationId = resolvedScope?.organizationId; const resolvedTargetWorkspaceId = resolvedScope?.targetWorkspaceId; const resolvedPersonalWorkspaceId = resolvedScope?.personalWorkspaceId; // Provider refreshes may replace equivalent projection objects. Preserve the // catalog and current resource-selection identity until the exact authority // identity changes. const scope = useMemo( () => resolvedIdentityKey && resolvedSubjectId && resolvedOrganizationId && resolvedTargetWorkspaceId && resolvedPersonalWorkspaceId ? { identityKey: resolvedIdentityKey, subjectId: resolvedSubjectId, organizationId: resolvedOrganizationId, targetWorkspaceId: resolvedTargetWorkspaceId, personalWorkspaceId: resolvedPersonalWorkspaceId, } : null, [ resolvedIdentityKey, resolvedOrganizationId, resolvedPersonalWorkspaceId, resolvedSubjectId, resolvedTargetWorkspaceId, ], ); const canReadRigs = hasWorkspacePermission( input.accessContext, input.workspace?.id ?? "", "rigs:use", ); const canReadMachines = hasWorkspacePermission( input.accessContext, input.workspace?.id ?? "", "enrollments:read", ); // A permission change is a catalog identity change, including in-flight reads. const scopeKey = `${scope?.identityKey ?? "ineligible"}:${canReadRigs}:${canReadMachines}`; const recoveryIdentityKey = [ resolvedSubjectId ?? "ineligible", resolvedTargetWorkspaceId ?? "ineligible", input.session?.id ?? "new", ].join(":"); const [catalog, setCatalog] = useState(null); const [settledCatalogScopeKey, setSettledCatalogScopeKey] = useState(null); const [loading, setLoading] = useState(scope !== null); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [sourceLost, setSourceLost] = useState(false); const [sessionAuthorityRecovery, setSessionAuthorityRecovery] = useState(null); const loadGeneration = useRef(0); const recoveryGeneration = useRef(0); const acceptedScopeKey = useRef(scopeKey); const load = useCallback( async (refresh: boolean): Promise => { const generation = ++loadGeneration.current; if (!scope) { setCatalog(null); setSettledCatalogScopeKey(null); setLoading(false); setRefreshing(false); setError(null); return; } setSettledCatalogScopeKey(null); if (refresh) setRefreshing(true); else setLoading(true); try { const next = await loadPersonalResourceCatalog(input.client, scope, { rigs: canReadRigs, connectedMachines: canReadMachines, }); if (generation !== loadGeneration.current || acceptedScopeKey.current !== scopeKey) return; setCatalog(next); setError(null); } catch (cause) { if (generation !== loadGeneration.current || acceptedScopeKey.current !== scopeKey) return; // Keep the last positively identified personal-resource catalog on a // refresh failure. Clearing it would misclassify an outage as revoked // authority and would make an unrelated workspace-scoped selection // surface a Personal-resource error. setError(cause instanceof Error ? cause : new Error(String(cause))); } finally { if (generation === loadGeneration.current && acceptedScopeKey.current === scopeKey) { setSettledCatalogScopeKey(scopeKey); setLoading(false); setRefreshing(false); } } }, [input.client, scope, scopeKey, canReadRigs, canReadMachines], ); useEffect(() => { acceptedScopeKey.current = scopeKey; loadGeneration.current += 1; setCatalog(null); setSettledCatalogScopeKey(null); setNotice(null); setSourceLost(false); setError(null); setLoading(scope !== null); setRefreshing(false); void load(false); }, [load, scope, scopeKey]); useEffect(() => { recoveryGeneration.current += 1; setSessionAuthorityRecovery(null); }, [recoveryIdentityKey]); // Eligibility is a synchronous fence. Do not wait for the transition effect // to clear prior owner state before hiding it from a connected-machine send. const currentCatalog = scope && acceptedScopeKey.current === scopeKey ? catalog : null; const selected = personalSelection(currentCatalog, input.fixed); const fixedIdentity = [ input.fixed.rigId ?? "", input.fixed.rigScope ?? "unknown", input.fixed.connectedMachine?.enrollmentId ?? "", ].join(":"); const selectedIdentity = `${fixedIdentity}:${selected.resourceCount}`; const priorSelectionIdentity = useRef(selectedIdentity); const priorFixedIdentity = useRef(fixedIdentity); const priorSelectionScopeKey = useRef(scopeKey); const priorSelectedCount = useRef(selected.resourceCount); useEffect(() => { const scopeChanged = priorSelectionScopeKey.current !== scopeKey; if (!scopeChanged && priorSelectionIdentity.current === selectedIdentity) return; const fixedChanged = priorFixedIdentity.current !== fixedIdentity; const lost = !scopeChanged && !fixedChanged && priorSelectedCount.current > 0 && selected.resourceCount === 0; priorSelectionIdentity.current = selectedIdentity; priorFixedIdentity.current = fixedIdentity; priorSelectionScopeKey.current = scopeKey; priorSelectedCount.current = selected.resourceCount; setSourceLost(lost); setNotice(lost ? "source_changed" : null); }, [fixedIdentity, scopeKey, selected.resourceCount, selectedIdentity]); const visibility = input.session?.tenancy ? input.session.tenancy.visibility === "workspace" ? "workspace" : "private" : input.personalWorkspaceTarget ? "private" : (input.createVisibility ?? "workspace"); const expectedAuthorityEpoch = input.session?.tenancy?.authorityEpoch; // Explicit attachment is consent for the owner's work and its continuations. // Admission still verifies the selected closure, owner, and session epoch. const mode: PersonalAttachmentMode | null = selected.resourceCount > 0 ? "session" : null; const acknowledged = visibility === "workspace" && selected.resourceCount > 0; const fixedResourceCount = Number(input.fixed.rigId !== null) + Number(input.fixed.connectedMachine !== null); const positivelyPersonal = sourceLost || (sessionAuthorityRecovery?.identityKey === recoveryIdentityKey && sessionAuthorityRecovery.selectedResourceCount > 0) || selected.personalResourceCount > 0 || (input.fixed.rigId !== null && input.fixed.rigScope === "user") || input.fixed.connectedMachine !== null; const closureError = selected.closureUnverified ? new Error( "The selected personal-resource authority closure could not be verified. Retry or choose a non-personal resource.", ) : null; const activeSessionAuthorityRecovery = sessionAuthorityRecovery?.identityKey === recoveryIdentityKey ? sessionAuthorityRecovery : null; const sessionAuthorityRecoveryError = activeSessionAuthorityRecovery?.status === "failed" ? activeSessionAuthorityRecovery.error : null; const effectiveError = positivelyPersonal ? (sessionAuthorityRecoveryError ?? error ?? closureError) : null; const intent = scope ? buildPersonalResourceAttachmentIntent({ mode, visibility, acknowledged, expectedAuthorityEpoch, resourceCount: selected.closureUnverified || sourceLost || activeSessionAuthorityRecovery !== null || effectiveError !== null ? 0 : selected.resourceCount, }) : undefined; const requiresDecision = scope !== null && (sourceLost || (positivelyPersonal && fixedResourceCount > 0 && (loading || refreshing || activeSessionAuthorityRecovery !== null || effectiveError !== null))); const recoverSessionAuthority = useCallback( async ( attemptedAuthorityEpoch: number | undefined, selectedResourceCount: number, ): Promise => { const generation = ++recoveryGeneration.current; setSessionAuthorityRecovery({ identityKey: recoveryIdentityKey, expectedAuthorityEpoch: attemptedAuthorityEpoch, selectedResourceCount, status: "pending", error: null, }); setNotice("reloading"); const [, reloadResult] = await Promise.allSettled([ load(true), onReloadSession ? onReloadSession() : Promise.reject(new Error("Session reload is unavailable.")), ]); if (generation !== recoveryGeneration.current) return; if (reloadResult.status === "rejected") { setSessionAuthorityRecovery({ identityKey: recoveryIdentityKey, expectedAuthorityEpoch: attemptedAuthorityEpoch, selectedResourceCount, status: "failed", error: new Error("The session authority could not be refreshed. Retry before sending."), }); setNotice("reload_failed"); return; } // Give the parent session read one render turn to publish its new // authority epoch before deciding whether the reload actually advanced. await new Promise((resolve) => setTimeout(resolve, 0)); if (generation !== recoveryGeneration.current) return; setSessionAuthorityRecovery({ identityKey: recoveryIdentityKey, expectedAuthorityEpoch: attemptedAuthorityEpoch, selectedResourceCount, status: "reload_settled", error: null, }); }, [load, onReloadSession, recoveryIdentityKey], ); useEffect(() => { if ( activeSessionAuthorityRecovery === null || activeSessionAuthorityRecovery.status === "pending" ) { return; } if ( expectedAuthorityEpoch === activeSessionAuthorityRecovery.expectedAuthorityEpoch || expectedAuthorityEpoch === undefined ) { if (activeSessionAuthorityRecovery.status === "reload_settled") { setSessionAuthorityRecovery({ ...activeSessionAuthorityRecovery, status: "failed", error: new Error("The session authority could not be refreshed. Retry before sending."), }); setNotice("reload_failed"); } return; } // The epoch transition changes the catalog scope. Wait for that new-scope // load, not the superseded request launched against the stale epoch. if ( acceptedScopeKey.current !== scopeKey || settledCatalogScopeKey !== scopeKey || loading || refreshing ) { return; } if (selected.resourceCount < activeSessionAuthorityRecovery.selectedResourceCount) { setSessionAuthorityRecovery(null); setSourceLost(true); setNotice("source_changed"); return; } setSessionAuthorityRecovery(null); setNotice("reloaded"); }, [ activeSessionAuthorityRecovery, expectedAuthorityEpoch, loading, refreshing, scopeKey, selected.resourceCount, settledCatalogScopeKey, ]); const refresh = useCallback( async () => activeSessionAuthorityRecovery ? await recoverSessionAuthority( activeSessionAuthorityRecovery.expectedAuthorityEpoch, activeSessionAuthorityRecovery.selectedResourceCount, ) : await load(true), [activeSessionAuthorityRecovery, load, recoverSessionAuthority], ); const onAccepted = useCallback( (acceptedInput: { personalResourceAttachment?: PersonalResourceAttachmentIntent | undefined; }) => { if (!acceptedInput.personalResourceAttachment) return; void load(true); }, [load], ); const onDeliveryError = useCallback( (deliveryError: Error, attemptedInput: PersonalResourceAttachmentAttempt) => { if (!isPersonalAttachmentConflict(deliveryError, attemptedInput)) return; void recoverSessionAuthority( attemptedInput.personalResourceAttachment?.expectedAuthorityEpoch, selected.resourceCount, ); }, [recoverSessionAuthority, selected.resourceCount], ); return { eligible: scope !== null, // Catalog discovery populates the resource pickers in the background. It // becomes submission UI only after the user has actually selected a fixed // personal resource; an unavailable optional catalog must not turn an // ordinary new-session composer into an error state. loading: positivelyPersonal && fixedResourceCount > 0 && loading, refreshing: positivelyPersonal && fixedResourceCount > 0 && (refreshing || activeSessionAuthorityRecovery?.status === "pending" || activeSessionAuthorityRecovery?.status === "reload_settled"), error: positivelyPersonal && fixedResourceCount > 0 ? effectiveError : null, notice, sourceLost, truncated: positivelyPersonal && fixedResourceCount > 0 && (currentCatalog?.truncated ?? false), catalog: currentCatalog, selected, mode, visibility, requiresDecision, intent, refresh, onAccepted, onDeliveryError, }; }