import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, ArrowLeft, CheckCircle2, ClipboardCheck, FlaskConical, GitPullRequest, KeyRound, Trash2, } from "lucide-react"; import { asWorkspaceApiError, forgetWorkspaceSecret, getWorkspace, getWorkspaceRegistryStatus, getWorkspaceRuntimeConfiguration, listWorkspaces, pullWorkspaceRegistry, saveWorkspaceSecrets, testWorkspace, validateWorkspace, type AuthDiagnostics, type WorkspaceRuntimeConfiguration, } from "../api/workspaces"; import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard, } from "../auth/authOperation"; import { Button } from "../components/ui/button"; import { WorkAreaPanel } from "./WorkAreaPanel"; import "./WorkspaceManager.css"; 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"; type WorkspaceOperationContext = Readonly<{ open: boolean; canManageWorkspace: boolean; canManageSecrets: boolean; }>; type WorkspaceOperationGuard = Readonly<{ operation: AuthOperationGuard; context: WorkspaceOperationContext; }>; 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 [authentication, setAuthentication] = useState(); const [busyAction, setBusyAction] = useState(); const operationEpochRef = useRef(0); const diagnosticEpochRef = useRef(0); const selectedIdRef = useRef(selectedId); const contextRef = useRef({ open, canManageWorkspace, canManageSecrets }); const committedContextRef = useRef(contextRef.current); selectedIdRef.current = selectedId; contextRef.current = { open, canManageWorkspace, canManageSecrets }; const clearWorkspacePresentation = () => { setNotice(undefined); setDiagnostics([]); setValidationNotice(undefined); setValidationDiagnostics([]); setConnectionNotice(undefined); setConnectionDiagnostics([]); setAuthentication(undefined); }; const cancelWorkspaceQueries = () => { void queryClient.cancelQueries({ queryKey: ["workspace-repository-status"] }); void queryClient.cancelQueries({ queryKey: ["workspaces"] }); void queryClient.cancelQueries({ queryKey: ["workspace"] }); void queryClient.cancelQueries({ queryKey: ["workspace-runtime-configuration"] }); }; const clearWorkspaceCaches = () => { queryClient.removeQueries({ queryKey: ["workspace-repository-status"] }); queryClient.removeQueries({ queryKey: ["workspaces"] }); queryClient.removeQueries({ queryKey: ["workspace"] }); queryClient.removeQueries({ queryKey: ["workspace-runtime-configuration"] }); }; // This is the sole invalidation boundary for deferred workspace work. Call it before capturing // an operation guard; doing it after capture would immediately invalidate the new operation. const invalidateWorkspaceContext = ({ clearSecrets = true, clearCaches = false, }: { clearSecrets?: boolean; clearCaches?: boolean } = {}) => { operationEpochRef.current += 1; diagnosticEpochRef.current += 1; cancelWorkspaceQueries(); if (clearCaches) clearWorkspaceCaches(); setBusyAction(undefined); if (clearSecrets) { setSecretValues({}); } clearWorkspacePresentation(); }; useEffect(() => () => { operationEpochRef.current += 1; diagnosticEpochRef.current += 1; }, []); useLayoutEffect(() => { const previous = committedContextRef.current; const current = contextRef.current; const openChanged = previous.open !== current.open; const permissionsChanged = previous.canManageWorkspace !== current.canManageWorkspace || previous.canManageSecrets !== current.canManageSecrets; if (openChanged || permissionsChanged) { invalidateWorkspaceContext({ clearCaches: permissionsChanged }); } committedContextRef.current = current; }, [open, canManageWorkspace, canManageSecrets]); function captureWorkspaceOperation(targetId?: string): WorkspaceOperationGuard | null { const context = contextRef.current; if (!context.open) return null; const operation = captureAuthOperation({ sessionId: targetId ?? null, disposalEpoch: operationEpochRef.current, }); return operation ? { operation, context } : null; } function isWorkspaceOperationCurrent(guard: WorkspaceOperationGuard | null, targetId?: string): boolean { if (!guard) return false; const context = contextRef.current; return context.open === guard.context.open && context.canManageWorkspace === guard.context.canManageWorkspace && context.canManageSecrets === guard.context.canManageSecrets && isAuthOperationCurrent(guard.operation, { sessionId: targetId ?? null, disposalEpoch: operationEpochRef.current, }); } async function guardedQuery(request: () => Promise, targetId?: string): Promise { const guard = captureWorkspaceOperation(targetId); if (!guard) throw new StaleAuthOperationError(); const result = await request(); const currentTargetId = targetId === undefined ? undefined : selectedIdRef.current; if (!isWorkspaceOperationCurrent(guard, currentTargetId)) 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 close = () => { invalidateWorkspaceContext(); onClose(); }; const selectWorkspace = (id: string) => { invalidateWorkspaceContext(); setSelectedId(id); }; const showLevelOne = () => { invalidateWorkspaceContext(); setSelectedId(undefined); }; async function updateRepository() { invalidateWorkspaceContext({ clearCaches: true }); const guard = captureWorkspaceOperation(); if (!guard) return; const targetId = selectedIdRef.current; setBusyAction("repository"); try { await pullWorkspaceRegistry(); if (!isWorkspaceOperationCurrent(guard)) return; await Promise.all([ statusQuery.refetch(), workspacesQuery.refetch(), targetId ? detailQuery.refetch() : Promise.resolve(), targetId ? runtimeQuery.refetch() : Promise.resolve(), ]); if (!isWorkspaceOperationCurrent(guard)) return; setNotice("Workspace repository updated and validated."); } catch (error) { if (isWorkspaceOperationCurrent(guard)) { setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]); } } finally { if (isWorkspaceOperationCurrent(guard)) setBusyAction(undefined); } } async function validateSource() { if (!detailQuery.data) return; const targetId = selectedId; const source = detailQuery.data.workspace; invalidateWorkspaceContext({ clearSecrets: false }); const guard = captureWorkspaceOperation(targetId); if (!guard) return; const diagnosticEpoch = diagnosticEpochRef.current; setBusyAction("validate"); try { const result = await validateWorkspace(source); if (diagnosticEpoch !== diagnosticEpochRef.current || !isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return; setAuthentication(result.authentication); setValidationNotice(result.activatable ? "Workspace source and authentication are valid." : "Workspace source is valid."); } catch (error) { if (diagnosticEpoch === diagnosticEpochRef.current && isWorkspaceOperationCurrent(guard, selectedIdRef.current)) { setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]); } } finally { if (diagnosticEpoch === diagnosticEpochRef.current && isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined); } } async function testConnections() { if (!selectedId) return; const targetId = selectedId; invalidateWorkspaceContext({ clearSecrets: false }); const guard = captureWorkspaceOperation(targetId); if (!guard) return; const diagnosticEpoch = diagnosticEpochRef.current; setBusyAction("test"); try { const result = await testWorkspace(targetId); if (diagnosticEpoch !== diagnosticEpochRef.current || !isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return; setAuthentication(result.authentication); 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 (diagnosticEpoch === diagnosticEpochRef.current && isWorkspaceOperationCurrent(guard, selectedIdRef.current)) { setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]); } } finally { if (diagnosticEpoch === diagnosticEpochRef.current && isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined); } } async function saveSecrets() { if (!selectedId) return; const targetId = selectedId; const values = Object.fromEntries( Object.entries(secretValues).filter(([, value]) => value.length > 0), ); if (Object.keys(values).length === 0) return; invalidateWorkspaceContext({ clearSecrets: false }); const guard = captureWorkspaceOperation(targetId); if (!guard) return; setBusyAction("save-secrets"); try { const configuration = await saveWorkspaceSecrets(targetId, values); if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return; queryClient.setQueryData( ["workspace-runtime-configuration", targetId], configuration, ); setSecretValues({}); await workspacesQuery.refetch(); if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return; setNotice("Runtime secrets saved. Stored values remain hidden."); } catch (error) { if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) { setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]); } } finally { if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined); } } async function forgetSecret(requirementId: string) { if (!selectedId) return; const targetId = selectedId; invalidateWorkspaceContext({ clearSecrets: false }); const guard = captureWorkspaceOperation(targetId); if (!guard) return; setBusyAction(`forget:${requirementId}`); try { const configuration = await forgetWorkspaceSecret(targetId, requirementId); if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return; queryClient.setQueryData( ["workspace-runtime-configuration", targetId], configuration, ); setSecretValues((current) => ({ ...current, [requirementId]: "" })); await workspacesQuery.refetch(); if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return; setNotice("Stored secret forgotten."); } catch (error) { if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) { setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]); } } finally { if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined); } } const repository = statusQuery.data?.repository; const repositoryLabel = repository ? `${repository.host}/${repository.repository}` : "the repository configured for this ThothII installation"; const repositoryWebUrl = repository && repository.transport !== "local" ? `https://${repository.host}/${repository.repository}` : undefined; const runtime = runtimeQuery.data; const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0); return (
{notice && (

{notice}

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

{diagnostic}

))}
)} {!selectedSummary ? (

Level 1 · Repository

What workspace files tell ThothII

A workspace file describes one data environment. Its workspace.yaml gives ThothII the workspace identity and language, plus any Evidence sources that support analysis. Database metadata and connection settings are managed separately in the PostgreSQL Catalog.

One Git repository can contain many workspaces. At its root, thoth-workspaces.yaml lists them, and each workspace has its own directory and workspace.yaml. This keeps workspace identity and supporting Evidence distinct while versioning them together.

How this installation uses the repository

  1. The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads{" "} {repositoryWebUrl ? ( {repositoryLabel} ) : ( {repositoryLabel} )}{" "} on branch {statusQuery.data?.branch ?? "main"}.
  2. ThothII fetches the configured branch into its managed read-only checkout, reads the workspaces listed in the repository, and validates the complete candidate revision before activating it.
  3. The active revision stays read-only. ThothII never edits, commits, pushes, or publishes workspace files; if validation fails, the previous valid revision stays active.

The workspace authoring instructions on GitHub explain the required layout and validation rules for people who maintain the repository.

{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.

)}

Selecting a workspace from the left opens its own revision, Evidence configuration, runtime requirements, validation, and connection checks. Database configuration is available in Database management.

) : (

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 && ( <>

About this workspace

{selectedSummary.displayName} is one workspace in the shared repository. Its workspace.yaml defines identity, language, and optional Evidence sources for this data environment. Its database is configured and described in the PostgreSQL Catalog.

The active revision shown below is the exact version currently activated by ThothII. Reading or checking it does not modify the repository. Source changes are made in Git and become available after a new revision is fetched, validated, and activated.

Source file
{selectedSummary.file}
Active revision
{detailQuery.data.revision.commit}
Workspace language
{detailQuery.data.workspace.workspace.language}
Runtime status
{stateLabel(runtime.configurationState)}

Workspace-specific actions

The validation and connection checks below apply only to {selectedSummary.displayName} and read its active revision without changing or publishing it.

Validate workspace source

Confirms that workspace.yaml and the directories belonging to this workspace follow the supported format. This identifies source problems before activation, without changing repository files.

{validationNotice && (

{validationNotice}

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

{diagnostic}

))}
)} {canManageWorkspace && }

Test workspace connections

Verifies the database configured in Database Management, plus this workspace's Evidence source and the installation semantic services. The result is informational.

{connectionNotice && (

{connectionNotice}

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

{diagnostic}

))}
)} {canManageWorkspace && }
{authentication &&

Authentication

{authentication.ready ? "Passed" : "Failed"}
{authentication.checks.some(({ level }) => level === "error") &&
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (

{code}: {field === undefined ? message : `${field} — ${message}`}

))}
}
} {canManageSecrets &&

Runtime secrets

Some connectors need credentials at runtime. ThothII stores these values separately from Git and never displays stored values. Enter a value only when adding or replacing a credential for this workspace.

{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" ? (