import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, ArrowLeft, CheckCircle2, ClipboardCheck, FlaskConical, GitPullRequest, KeyRound, Trash2, X, } from "lucide-react"; import { asWorkspaceApiError, forgetWorkspaceSecret, getWorkspace, getWorkspaceRegistryStatus, getWorkspaceRuntimeConfiguration, listWorkspaces, pullWorkspaceRegistry, saveWorkspaceSecrets, testWorkspace, validateWorkspace, type WorkspaceRuntimeConfiguration, } from "../api/workspaces"; import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../components/ui/dialog"; function QueryError({ name, message, retryLabel, onRetry }: { name: string; message: string; retryLabel: string; onRetry: () => void; }) { return (

{message}

); } function publicError(error: unknown, fallback: string): string { const safe = asWorkspaceApiError(error); return safe ? `${safe.code}: ${safe.message}` : fallback; } function stateLabel(state: "ready" | "configuration_required"): string { return state === "ready" ? "Ready" : "Runtime configuration required"; } const workspaceAuthoringGuideUrl = "https://github.com/mptyl/ThothII/blob/main/docs/install/local-workspace-registry.md#prepare-and-publish-a-workspace-source"; export function WorkspaceManager({ open, onClose, canManageWorkspace = false, canManageSecrets = false, }: { open: boolean; onClose: () => void; canManageWorkspace?: boolean; canManageSecrets?: boolean; }) { const queryClient = useQueryClient(); const [selectedId, setSelectedId] = useState(); const [secretValues, setSecretValues] = useState>({}); const [notice, setNotice] = useState(); const [diagnostics, setDiagnostics] = useState([]); const [validationNotice, setValidationNotice] = useState(); const [validationDiagnostics, setValidationDiagnostics] = useState([]); const [connectionNotice, setConnectionNotice] = useState(); const [connectionDiagnostics, setConnectionDiagnostics] = useState([]); const [busyAction, setBusyAction] = useState(); const operationEpochRef = useRef(0); const selectedIdRef = useRef(selectedId); selectedIdRef.current = selectedId; useEffect(() => () => { operationEpochRef.current += 1; }, []); async function guardedQuery(request: () => Promise, targetId?: string): Promise { const guard = captureAuthOperation({ sessionId: targetId ?? null, disposalEpoch: operationEpochRef.current, }); if (!guard) throw new StaleAuthOperationError(); const result = await request(); const currentSessionId = targetId === undefined ? null : selectedIdRef.current; if (!isAuthOperationCurrent(guard, { sessionId: currentSessionId, disposalEpoch: operationEpochRef.current, })) throw new StaleAuthOperationError(); return result; } const statusQuery = useQuery({ queryKey: ["workspace-repository-status"], queryFn: () => guardedQuery(getWorkspaceRegistryStatus), enabled: open, }); const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: () => guardedQuery(listWorkspaces), enabled: open, }); const workspaces = workspacesQuery.data ?? []; const selectedSummary = useMemo( () => workspaces.find(({ id }) => id === selectedId), [selectedId, workspaces], ); const detailQuery = useQuery({ queryKey: ["workspace", selectedId], queryFn: () => guardedQuery(() => getWorkspace(selectedId!), selectedId), enabled: Boolean(open && selectedId), }); const runtimeQuery = useQuery({ queryKey: ["workspace-runtime-configuration", selectedId], queryFn: () => guardedQuery(() => getWorkspaceRuntimeConfiguration(selectedId!), selectedId), enabled: Boolean(open && selectedId), }); const clearMessages = () => { setNotice(undefined); setDiagnostics([]); setValidationNotice(undefined); setValidationDiagnostics([]); setConnectionNotice(undefined); setConnectionDiagnostics([]); }; const clearGlobalMessages = () => { setNotice(undefined); setDiagnostics([]); }; const close = () => { setSecretValues({}); clearMessages(); onClose(); }; const selectWorkspace = (id: string) => { setSelectedId(id); setSecretValues({}); clearMessages(); }; const showLevelOne = () => { setSelectedId(undefined); setSecretValues({}); clearMessages(); }; async function updateRepository() { const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current }); if (!guard) return; setBusyAction("repository"); clearMessages(); try { await pullWorkspaceRegistry(); if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return; await Promise.all([ statusQuery.refetch(), workspacesQuery.refetch(), selectedId ? detailQuery.refetch() : Promise.resolve(), selectedId ? runtimeQuery.refetch() : Promise.resolve(), ]); if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return; setNotice("Workspace repository updated and validated."); } catch (error) { if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) { setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]); } } finally { if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) setBusyAction(undefined); } } async function validateSource() { if (!detailQuery.data) return; const guard = captureAuthOperation({ sessionId: selectedId, disposalEpoch: operationEpochRef.current }); if (!guard) return; setBusyAction("validate"); clearGlobalMessages(); setValidationNotice(undefined); setValidationDiagnostics([]); try { await validateWorkspace(detailQuery.data.workspace); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; setValidationNotice("Workspace source is valid."); } catch (error) { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) { setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]); } } finally { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined); } } async function testConnections() { if (!selectedId) return; const targetId = selectedId; const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current }); if (!guard) return; setBusyAction("test"); clearGlobalMessages(); setConnectionNotice(undefined); setConnectionDiagnostics([]); try { const result = await testWorkspace(selectedId); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; const issues = result.diagnostics.filter(({ level }) => level !== "info"); const informational = result.diagnostics.find(({ level }) => level === "info"); setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`)); if (issues.length === 0) { setConnectionNotice(result.activatable ? informational ? `${informational.code}: ${informational.message}` : "Workspace connections are valid." : "Workspace connection test completed."); } } catch (error) { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) { setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]); } } finally { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined); } } async function saveSecrets() { if (!selectedId) return; const targetId = selectedId; const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current }); if (!guard) return; const values = Object.fromEntries( Object.entries(secretValues).filter(([, value]) => value.length > 0), ); if (Object.keys(values).length === 0) return; setBusyAction("save-secrets"); clearMessages(); try { const configuration = await saveWorkspaceSecrets(targetId, values); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; queryClient.setQueryData( ["workspace-runtime-configuration", targetId], configuration, ); setSecretValues({}); await workspacesQuery.refetch(); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; setNotice("Runtime secrets saved. Stored values remain hidden."); } catch (error) { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) { setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]); } } finally { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined); } } async function forgetSecret(requirementId: string) { if (!selectedId) return; const targetId = selectedId; const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current }); if (!guard) return; setBusyAction(`forget:${requirementId}`); clearMessages(); try { const configuration = await forgetWorkspaceSecret(targetId, requirementId); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; queryClient.setQueryData( ["workspace-runtime-configuration", targetId], configuration, ); setSecretValues((current) => ({ ...current, [requirementId]: "" })); await workspacesQuery.refetch(); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; setNotice("Stored secret forgotten."); } catch (error) { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) { setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]); } } finally { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined); } } const repository = statusQuery.data?.repository; const repositoryLabel = repository ? `${repository.host}/${repository.repository}` : "the repository configured for this ThothII installation"; const runtime = runtimeQuery.data; const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0); return ( { if (!nextOpen) close(); }}> Workspace management Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.
{notice && (

{notice}

)} {diagnostics.length > 0 && (
{diagnostics.map((diagnostic) => (

{diagnostic}

))}
)} {!selectedSummary ? (

Level 1 · Repository

How workspaces reach ThothII

  1. Create a workspace repository in any local directory you choose. Add one directory for each workspace you want ThothII to manage. At the repository root, thoth-workspaces.yaml lists those workspaces; each workspace directory contains its own workspace.yaml, which declares the database connection, the Evidence sources, and the ThothII vector-database collection used during the process.
  2. Publish that source by committing and pushing it to a repository hosted by a Git server such as GitHub, GitLab, or Gitea.
  3. The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads {repositoryLabel} on branch {statusQuery.data?.branch ?? "main"}.
  4. ThothII fetches the configured branch into its managed read-only checkout, validates the complete candidate revision, and activates it only when validation succeeds. It never edits, commits, pushes, or publishes workspace source.

Follow the workspace authoring instructions on GitHub for the required layout and validation rules.

{canManageWorkspace &&

Update workspace repository

Fetches the configured branch directly into the managed read-only checkout and validates it. No workspace selection is required. If candidate validation fails, the current active revision remains unchanged.

} {!canManageWorkspace && (

You can inspect workspaces. Workspace updates, validation, and connection tests require workspace management permission.

)}

Select a workspace from the left only for workspace-specific validation, runtime credentials, and connection tests.

) : (

Level 2 · Selected workspace

{selectedSummary.displayName}

{selectedSummary.id}

{(detailQuery.isLoading || runtimeQuery.isLoading) &&

Loading workspace configuration…

} {(detailQuery.isError || runtimeQuery.isError) && ( { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} /> )} {detailQuery.data && runtime && ( <>

Workspace-specific actions

The actions below apply only to {selectedSummary.displayName}. ThothII reads this revision without modifying or publishing it.

Source file
{selectedSummary.file}
Active revision
{detailQuery.data.revision.commit}
Database
engine: {detailQuery.data.workspace.dwh.engine} database: {detailQuery.data.workspace.dwh.database} schema: {detailQuery.data.workspace.dwh.schema}
Runtime status
{stateLabel(runtime.configurationState)}

Validate workspace source

Checks workspace.yaml and the required workspace directories against the supported workspace schema. No source file is changed.

{validationNotice && (

{validationNotice}

)} {validationDiagnostics.length > 0 && (
{validationDiagnostics.map((diagnostic) => (

{diagnostic}

))}
)} {canManageWorkspace && }

Test workspace connections

Uses temporary decrypted credentials to verify the configured data warehouse and Evidence source. Temporary files are deleted after the test.

{connectionNotice && (

{connectionNotice}

)} {connectionDiagnostics.length > 0 && (
{connectionDiagnostics.map((diagnostic) => (

{diagnostic}

))}
)} {canManageWorkspace && }
{canManageSecrets &&

Runtime secrets

Enter only new or replacement values. Stored values are never displayed. Saving replaces the selected secret and clears the form field.

{runtime.requirements.length === 0 ? (

This workspace does not require user-provided runtime secrets for its selected connectors.

) : (
{runtime.requirements.map((requirement) => (
{requirement.configured ? "Configured" : "Not configured"}

{requirement.description}{requirement.required ? " Required for this workspace." : " Optional."}

{requirement.input === "textarea" ? (