feat: complete catalog-driven preprocessing
Publish documentation / publish (push) Successful in 2m12s

This commit is contained in:
Codex
2026-09-06 17:49:35 +02:00
parent 8707ae1d46
commit cffa60772e
141 changed files with 5898 additions and 3015 deletions
+52
View File
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "./test/msw";
import { App } from "./App";
@@ -50,3 +51,54 @@ test("trusted upstream authentication keeps identity but omits ThothII logout",
expect(await screen.findByText("Portal user")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
});
test("a local login always enters the core activity", async () => {
let signedIn = false;
localStorage.setItem("thothii:app-navigation", JSON.stringify({
surface: "database-management",
panel: null,
}));
server.use(
http.get("/api/auth/config", () => HttpResponse.json({
mode: "local",
localLogin: true,
oidcLogin: false,
})),
http.get("/api/me", () => signedIn
? HttpResponse.json({
issuer: "local",
subject: "admin-user",
displayName: "Admin user",
roles: ["admin"],
permissions: ["session.use", "database.manage"],
isAdmin: true,
csrfToken: "c".repeat(43),
session: null,
})
: new HttpResponse(null, { status: 401 })),
http.post("/api/auth/local/login", () => {
signedIn = true;
return HttpResponse.json({});
}),
http.get("/api/settings", () => HttpResponse.json({
workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium",
})),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/sessions", () => HttpResponse.json([])),
http.get("/api/workspace-registry/status", () => HttpResponse.json({
branch: "main", ahead: 0, behind: 0, degraded: false,
})),
);
render(<App />);
await screen.findByRole("heading", { name: /sign in to thothii/i });
await userEvent.type(screen.getByLabelText(/username/i), "admin");
await userEvent.type(screen.getByLabelText(/^password$/i), "correct-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("button", { name: "New session" }))
.toHaveAttribute("aria-current", "page");
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
});
+6 -4
View File
@@ -247,7 +247,7 @@ export interface CatalogMetadataDeleteCounts {
relationships: number;
}
export type CatalogDescriptionTarget = "tables" | "columns" | "database_columns";
export type CatalogDescriptionTarget = "tables" | "columns" | "database" | "database_columns";
export interface CatalogDescriptionConsolidationCounts {
copied: number;
@@ -559,11 +559,11 @@ export const deleteCatalogTableMetadata = (
export function consolidateCatalogDescriptions(
databaseId: string,
target: "database_columns",
target: Extract<CatalogDescriptionTarget, "database" | "database_columns">,
): Promise<CatalogDescriptionConsolidationCounts>;
export function consolidateCatalogDescriptions(
databaseId: string,
target: Exclude<CatalogDescriptionTarget, "database_columns">,
target: Exclude<CatalogDescriptionTarget, "database" | "database_columns">,
targetIds: string[],
): Promise<CatalogDescriptionConsolidationCounts>;
export function consolidateCatalogDescriptions(
@@ -575,7 +575,9 @@ export function consolidateCatalogDescriptions(
`/catalog/databases/${encodeURIComponent(databaseId)}/descriptions/consolidate`,
{
method: "POST",
body: JSON.stringify(target === "database_columns" ? { target } : { target, targetIds }),
body: JSON.stringify(
target === "database" || target === "database_columns" ? { target } : { target, targetIds },
),
},
);
}
+4
View File
@@ -38,6 +38,7 @@ const safeErrorCodes = new Set([
"relationship_not_found", "relationship_duplicate", "relationship_target_not_unique",
"relationship_type_incompatible", "relationship_read_only", "relationship_request_invalid",
"relationship_operation_failed", "relationship_schema_stale", "column_not_found",
"preprocessing_request_invalid", "preprocessing_blocked", "preprocessing_run_failed",
]);
type SafeErrorPayload = {
@@ -116,6 +117,9 @@ const localCodeMessages: Record<string, string> = {
relationship_operation_failed: "The relationship operation failed.",
relationship_schema_stale: "Synchronize the current database schema before managing logical relationships.",
column_not_found: "The selected catalog column was not found. Refresh and try again.",
preprocessing_request_invalid: "The preprocessing request is invalid.",
preprocessing_blocked: "Preprocessing is blocked by a current workspace prerequisite.",
preprocessing_run_failed: "Preprocessing failed. Review the latest diagnostic and retry.",
};
const localStatusMessages: Record<number, string> = {
@@ -0,0 +1,50 @@
import { apiFetch } from "./client";
export type WorkspacePreprocessingState =
| "ready"
| "required"
| "running"
| "blocked"
| "failed";
export interface WorkspacePreprocessingStatus {
schemaVersion: 1;
workspaceId: string;
state: WorkspacePreprocessingState;
actionable: boolean;
clearable: boolean;
detail: string;
reason?: string;
nextStep?: string;
metadataRevision?: number;
preprocessedMetadataRevision?: number;
startedAt?: string;
finishedAt?: string;
progress?: {
stage: "catalog_snapshot" | "schema_index" | "evidence" | "finalizing";
step: number;
totalSteps: 4;
};
lastFailure?: {
stage: string;
errorCode: string;
finishedAt: string;
};
}
export const getWorkspacePreprocessingStatus = (workspaceId: string) =>
apiFetch<WorkspacePreprocessingStatus>(
`/workspaces/${encodeURIComponent(workspaceId)}/preprocessing`,
);
export const runWorkspacePreprocessing = (workspaceId: string) =>
apiFetch<WorkspacePreprocessingStatus>(
`/workspaces/${encodeURIComponent(workspaceId)}/preprocessing`,
{ method: "POST" },
);
export const clearWorkspacePreprocessing = (workspaceId: string) =>
apiFetch<WorkspacePreprocessingStatus>(
`/workspaces/${encodeURIComponent(workspaceId)}/preprocessing`,
{ method: "DELETE" },
);
+2 -1
View File
@@ -69,7 +69,8 @@ export interface CanonicalWorkspace {
description?: string;
language: "en" | "it";
};
dwh: {
/** Generated runtime descriptors may contain this block; authored workspace YAML never does. */
dwh?: {
engine: "postgres";
database: string;
schema: string;
+50
View File
@@ -7,6 +7,7 @@ import { AppShell } from "./AppShell";
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState";
import { server } from "../test/msw";
import { useSessionStore } from "../store/sessionStore";
import { workspacePreferences } from "../workspaces/preferences";
function renderShell(user: {
subject: string;
@@ -31,6 +32,7 @@ function KeyedAuthenticatedShell() {
beforeEach(() => {
clearAuthState();
workspacePreferences.reset();
useSessionStore.getState().resetSession();
server.use(
http.get("/api/sessions", () => HttpResponse.json([])),
@@ -45,6 +47,52 @@ beforeEach(() => {
});
describe("authenticated shell permissions", () => {
test("does not use the legacy installation default as a preprocessing workspace", async () => {
const requestedWorkspaceIds: string[] = [];
server.use(
http.get("/api/settings", () => HttpResponse.json({
workspace: "local", provider: "test", model: "test", thinking: "low",
})),
http.get("/api/workspaces", () => HttpResponse.json([{
id: "psd-clinical",
name: "psd-clinical",
file: "psd-clinical/workspace.yaml",
displayName: "Policlinico San Donato",
configurationState: "ready",
revision: {
id: "psd-clinical",
commit: "a".repeat(40),
blob: "b".repeat(40),
snapshotPath: "/tmp/psd-clinical.yaml",
},
}])),
http.get("/api/workspaces/:workspaceId/preprocessing", ({ params }) => {
requestedWorkspaceIds.push(String(params.workspaceId));
return HttpResponse.json({
schemaVersion: 1,
workspaceId: String(params.workspaceId),
state: "ready",
actionable: true,
clearable: true,
detail: "Workspace preprocessing is current.",
});
}),
);
const user = userEvent.setup();
renderShell({
subject: "admin-1",
isAdmin: true,
roles: ["admin"],
permissions: ["session.use", "database.manage"],
});
const administration = await screen.findByRole("button", { name: "Administration" });
await user.click(administration);
await waitFor(() => expect(requestedWorkspaceIds).toContain("psd-clinical"));
expect(requestedWorkspaceIds).not.toContain("local");
});
test("hides administrative navigation from a session user", async () => {
renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] });
@@ -80,10 +128,12 @@ describe("authenticated shell permissions", () => {
const separator = within(panel).getByRole("separator");
const workspace = within(panel).getByRole("button", { name: "Workspace management" });
const pi = within(panel).getByRole("button", { name: "Pi management" });
const preprocessing = within(panel).getByRole("region", { name: "Workspace preprocessing" });
expect(panel.children[0]).toBe(database);
expect(panel.children[1]).toBe(separator);
expect(panel.children[2]).toBe(workspace);
expect(panel.children[3]).toBe(pi);
expect(panel.children[4]).toBe(preprocessing);
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
await user.keyboard(" ");
@@ -94,6 +94,23 @@ test("uses Fleet Ledger by default and gates the legacy fallback to non-producti
})).toBe("fleet");
});
test("starts on the core activity after a hard refresh even if metadata management was open", async () => {
localStorage.setItem("thothii:app-navigation", JSON.stringify({
surface: "database-management",
panel: null,
}));
renderShell();
await expandAdministration();
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
expect(within(sessionNavigation).getByRole("button", { name: "New session" }))
.toHaveAttribute("aria-current", "page");
expect(within(sessionNavigation).getByRole("button", { name: "Database management" }))
.not.toHaveAttribute("aria-current");
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
});
test("replaces the core conversation while keeping the session navigation", async () => {
renderShell();
await expandAdministration();
@@ -20,6 +20,7 @@ beforeEach(() => {
issuer: "test", subject: "test", roles: ["user"], permissions: ["session.use"],
isAdmin: false, csrfToken: null, session: null,
});
workspacePreferences.reset();
localStorage.clear();
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
@@ -29,7 +30,19 @@ beforeEach(() => {
http.get("/api/sessions", () => HttpResponse.json([])),
http.get("/api/settings", () =>
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/workspaces", () => HttpResponse.json([{
id: "default",
name: "default",
file: "default/workspace.yaml",
displayName: "Default",
configurationState: "ready",
revision: {
id: "default",
commit: "a".repeat(40),
blob: "b".repeat(40),
snapshotPath: "/data/workspaces/default.yaml",
},
}])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
);
});
@@ -54,6 +67,26 @@ test("New session starts prewarm without delaying composer focus", async () => {
expect(composer).toHaveClass("thot-awaiting-input");
});
test("a known preprocessing requirement disables New session", async () => {
server.use(
http.get("/api/workspaces/default/preprocessing", () => HttpResponse.json({
schemaVersion: 1,
workspaceId: "default",
state: "required",
actionable: true,
clearable: true,
detail: "Catalog revision 18 is not indexed.",
metadataRevision: 18,
})),
);
renderShell();
const newSession = screen.getByRole("button", { name: "New session" });
await waitFor(() => expect(newSession).toBeDisabled());
expect(newSession).toHaveAttribute("data-navigation-state", "unavailable");
expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed.");
});
test("records the prompt without central duplication, then opens the live log", async () => {
let releaseCreate!: () => void;
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
+50 -26
View File
@@ -17,6 +17,7 @@ import { StopConfirmDialog } from "./StopConfirmDialog";
import { SteerInput, ComposerFooter } from "./SteerInput";
import { WorkflowBar } from "./WorkflowBar";
import { DatabaseManagementPage } from "./DatabaseManagementPage";
import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl";
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import { Button, buttonVariants } from "../components/ui/button";
@@ -37,6 +38,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
import { getSettings } from "../api/settings";
import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences";
import { getWorkspacePreprocessingStatus } from "../api/workspace-preprocessing";
interface AppShellProps {
canLogout: boolean;
@@ -45,23 +49,6 @@ interface AppShellProps {
type ActiveSurface = "core" | "database-management";
type ActiveManagementPanel = "workspace" | "pi" | null;
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
const APP_NAVIGATION_STORAGE_KEY = "thothii:app-navigation";
function readPersistedNavigation(): { surface: ActiveSurface; panel: ActiveManagementPanel } {
if (import.meta.env.MODE === "test") return { surface: "core", panel: null };
try {
const parsed = JSON.parse(window.localStorage.getItem(APP_NAVIGATION_STORAGE_KEY) ?? "null") as {
surface?: unknown;
panel?: unknown;
} | null;
return {
surface: parsed?.surface === "database-management" ? "database-management" : "core",
panel: parsed?.panel === "workspace" || parsed?.panel === "pi" ? parsed.panel : null,
};
} catch {
return { surface: "core", panel: null };
}
}
function sessionScopeTabClass(selected: boolean): string {
const base = "relative -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-2 px-2 text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
@@ -168,6 +155,33 @@ export function AppShell({ canLogout }: AppShellProps) {
const canManageDatabase = permissions.includes("database.manage");
const canManagePi = permissions.includes("pi.manage");
const authGeneration = useAuthGeneration();
const { data: installationSettings } = useQuery({
queryKey: ["settings"],
queryFn: getSettings,
});
const [workspacePreference, setWorkspacePreference] = useState<WorkspacePreference>(
() => workspacePreferences.load(),
);
useEffect(() => {
const unsubscribe = workspacePreferences.subscribe(setWorkspacePreference);
// Child effects may seed the singleton before this parent effect subscribes.
setWorkspacePreference(workspacePreferences.load());
return unsubscribe;
}, []);
// Only an ID selected from the active Workspace Registry may address workspace-scoped APIs.
// Installation settings are a legacy hint and can name a removed/non-catalog workspace.
const selectedWorkspaceId = workspacePreference.workspaceId;
const preprocessingQuery = useQuery({
queryKey: ["workspace-preprocessing", selectedWorkspaceId],
queryFn: () => getWorkspacePreprocessingStatus(selectedWorkspaceId!),
enabled: Boolean(authenticatedUser && selectedWorkspaceId),
refetchInterval: (query) => query.state.data?.state === "running" ? 1_000 : 5_000,
});
const preprocessingBlocksNewSession = Boolean(
selectedWorkspaceId
&& preprocessingQuery.data
&& preprocessingQuery.data.state !== "ready",
);
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope],
queryFn: async () => {
@@ -183,8 +197,7 @@ export function AppShell({ canLogout }: AppShellProps) {
const queryClient = useQueryClient();
const [showActivity, setShowActivity] = useState(false);
const persistedNavigation = readPersistedNavigation();
const [activeSurface, setActiveSurface] = useState<ActiveSurface>(persistedNavigation.surface);
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
isDevelopment: import.meta.env.DEV,
mode: import.meta.env.MODE,
@@ -195,7 +208,7 @@ export function AppShell({ canLogout }: AppShellProps) {
const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
databaseNavigationRef.current = state;
}, []);
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(persistedNavigation.panel);
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
@@ -206,10 +219,6 @@ export function AppShell({ canLogout }: AppShellProps) {
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
const operationEpochRef = useRef(0);
useEffect(() => {
if (import.meta.env.MODE === "test") return;
window.localStorage.setItem(APP_NAVIGATION_STORAGE_KEY, JSON.stringify({ surface: activeSurface, panel: activeManagementPanel }));
}, [activeManagementPanel, activeSurface]);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
const groups = useMemo(
@@ -610,6 +619,10 @@ export function AppShell({ canLogout }: AppShellProps) {
}, [lastSystemEvent]);
function startNewSession() {
if (preprocessingBlocksNewSession) {
toast.warning(preprocessingQuery.data?.detail ?? "Workspace preprocessing is required.");
return;
}
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel(null);
@@ -841,7 +854,7 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Right session rail */}
{(activeSurface === "database-management" || !showActivity) && (
<aside aria-label="Session navigation" className="flex w-[15vw] shrink-0 flex-col bg-sidebar">
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
<div className="relative px-4 pb-3 pt-5 text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
@@ -871,7 +884,11 @@ export function AppShell({ canLogout }: AppShellProps) {
size="sm"
className="w-full"
aria-current={currentNavigation === "core" ? "page" : undefined}
data-navigation-state={currentNavigation === "core" ? "current" : "available"}
data-navigation-state={preprocessingBlocksNewSession
? "unavailable"
: currentNavigation === "core" ? "current" : "available"}
disabled={preprocessingBlocksNewSession}
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
onClick={startNewSession}
>
New session
@@ -949,6 +966,13 @@ export function AppShell({ canLogout }: AppShellProps) {
>
Pi management
</Button>
<WorkspacePreprocessingControl
workspaceId={selectedWorkspaceId}
status={preprocessingQuery.data}
loading={preprocessingQuery.isLoading}
unavailable={preprocessingQuery.isError}
canManage={canManageDatabase}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
@@ -838,7 +838,7 @@ test("keeps Fleet Generate missing descriptions behind the source-data disclosur
expect(generationStarts).toBe(0);
});
test("copies generated descriptions to every database column after explicit confirmation", async () => {
test("copies generated descriptions to every database table and column after explicit confirmation", async () => {
const user = userEvent.setup();
let consolidationBody: unknown;
server.use(
@@ -864,20 +864,23 @@ test("copies generated descriptions to every database column after explicit conf
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.selectOptions(
screen.getByRole("combobox", { name: "Batch action" }),
"consolidate-columns",
"consolidate-descriptions",
);
expect(screen.getByRole("option", { name: "Copy generated description to descriptions" }))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(screen.getByText(
"Copy generated descriptions to all columns in Policlinico San Donato?",
"Copy generated descriptions to Description fields in Policlinico San Donato?",
)).toBeVisible();
expect(screen.getByText(/replace the current Description value/i)).toBeVisible();
expect(screen.getByText(/table and column description will replace its current Description value/i))
.toBeVisible();
expect(consolidationBody).toBeUndefined();
await user.click(screen.getByRole("button", { name: "Copy to all columns" }));
await user.click(screen.getByRole("button", { name: "Copy to descriptions" }));
await waitFor(() => expect(consolidationBody).toEqual({ target: "database_columns" }));
expect(await screen.findByText("Copied 7 column descriptions; skipped 2")).toBeVisible();
await waitFor(() => expect(consolidationBody).toEqual({ target: "database" }));
expect(await screen.findByText("Copied 7 descriptions; skipped 2")).toBeVisible();
await waitFor(() => {
expect(client.getQueryState(tablesQuery)?.isInvalidated).toBe(true);
expect(client.getQueryState(firstColumnsQuery)?.isInvalidated).toBe(true);
@@ -869,20 +869,20 @@ export function DatabaseManagementPage({
}
}, [rememberDescriptionGenerationRun, selectedMetadataModel]);
const consolidateDatabaseColumnDescriptions = useCallback(async (
const consolidateDatabaseDescriptions = useCallback(async (
selected: CatalogDatabase[],
) => {
const database = selected.length === 1 ? selected[0] : undefined;
if (!database?.id) return;
try {
const result = await consolidateCatalogDescriptions(database.id, "database_columns");
const result = await consolidateCatalogDescriptions(database.id, "database");
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["catalog-tables", database.id] }),
queryClient.invalidateQueries({ queryKey: ["catalog-columns", database.id] }),
invalidateCatalogMetrics(),
]);
toast.success(
`Copied ${result.copied} column description${result.copied === 1 ? "" : "s"}; skipped ${result.skipped}`,
`Copied ${result.copied} description${result.copied === 1 ? "" : "s"}; skipped ${result.skipped}`,
);
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -1268,7 +1268,7 @@ export function DatabaseManagementPage({
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onGenerateDescriptions={generateDatabaseDescriptions}
onConsolidateColumnDescriptions={consolidateDatabaseColumnDescriptions}
onConsolidateDescriptions={consolidateDatabaseDescriptions}
onSuggestSensitive={suggestDatabaseSensitiveFields}
sensitivityAnalysisRuns={sensitivityAnalysisRuns}
onDeleteMetadataSelected={deleteSelectedMetadata}
@@ -1477,7 +1477,7 @@ export function DatabaseManagementPage({
descriptionGenerationActive={descriptionGenerationActive}
sensitivityAnalysisRuns={sensitivityAnalysisRuns}
onGenerateDescriptions={generateDatabaseDescriptions}
onConsolidateColumnDescriptions={consolidateDatabaseColumnDescriptions}
onConsolidateDescriptions={consolidateDatabaseDescriptions}
onSuggestSensitive={suggestDatabaseSensitiveFields}
onDeleteMetadataSelected={deleteSelectedMetadata}
/>
+11 -4
View File
@@ -188,18 +188,25 @@ export function ComposerFooter() {
const models = modelsData?.models ?? [];
const tokenUsage = useSessionStore((state) => state.tokenUsage);
const [preferences, setPreferences] = useState<WorkspacePreference>(() => workspacePreferences.load());
const selectableWorkspaces = workspaces.filter((workspace) => workspace.revision);
const registryDefaultWorkspaceId = settings?.workspace
&& selectableWorkspaces.some((workspace) => workspace.id === settings.workspace)
? settings.workspace
: selectableWorkspaces.length === 1
? selectableWorkspaces[0]?.id
: undefined;
useEffect(() => {
if (!settings) return;
if (!settings || workspacesLoading || workspaceSummariesError) return;
setPreferences(workspacePreferences.migrate({
workspaceId: settings.workspace,
workspaceId: registryDefaultWorkspaceId,
provider: settings.provider,
model: settings.model,
thinking: settings.thinking,
}));
}, [settings]);
}, [registryDefaultWorkspaceId, settings, workspaceSummariesError, workspacesLoading]);
const workspace = preferences.workspaceId ?? settings?.workspace ?? "";
const workspace = preferences.workspaceId ?? registryDefaultWorkspaceId ?? "";
const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace);
const selectedWorkspaceHasRevision = Boolean(selectedWorkspace?.revision);
const model = preferences.model ?? settings?.model ?? "";
+4 -6
View File
@@ -181,7 +181,7 @@ test("level one explains workspace files, shared repositories, and the read-only
expect(within(overview).getByRole("heading", { name: "What workspace files tell ThothII" })).toBeVisible();
expect(within(overview).getByText(/A workspace file describes one data environment/i)).toBeVisible();
expect(within(overview).getByText(/one Git repository can contain many workspaces/i)).toBeVisible();
expect(within(overview).getByText(/each workspace has its own directory/i)).toHaveTextContent(/database target/i);
expect(within(overview).getByText(/each workspace has its own directory/i)).not.toHaveTextContent(/database target/i);
expect(within(overview).getByText(/each workspace has its own directory/i)).toHaveTextContent(/Evidence/i);
const repositorySteps = within(overview).getByRole("list");
expect(repositorySteps).toHaveClass("gap-0");
@@ -245,11 +245,9 @@ test("workspace-specific commands remain isolated until a workspace is selected"
expect(screen.getByText(/database configured in Database Management/i)).toBeVisible();
expect(screen.getByText(/Evidence source and the installation semantic services/i)).toBeVisible();
expect(screen.getByText(/result is informational/i)).toBeVisible();
const databaseField = screen.getByText("Database").parentElement;
expect(databaseField).not.toBeNull();
expect(databaseField).toHaveTextContent("engine: postgres");
expect(databaseField).toHaveTextContent("database: database");
expect(databaseField).toHaveTextContent("schema: public");
const languageField = screen.getByText("Workspace language").parentElement;
expect(languageField).not.toBeNull();
expect(languageField).toHaveTextContent("en");
expect(screen.getByRole("button", { name: "Validate workspace source" })).toBeVisible();
expect(screen.getByRole("button", { name: "Test workspace connections" })).toBeVisible();
});
+5 -12
View File
@@ -460,10 +460,10 @@ export function WorkspaceManager({
</div>
<div className="grid gap-3 text-sm leading-6 text-muted-foreground">
<p>
A workspace file describes one data environment. Its <code>workspace.yaml</code> gives ThothII the workspace identity and language, the database target and schema it may query, the Evidence sources that support analysis, and the processing settings to apply. Runtime credentials are stored separately from Git.
A workspace file describes one data environment. Its <code>workspace.yaml</code> 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.
</p>
<p>
One Git repository can contain many workspaces. At its root, <code>thoth-workspaces.yaml</code> lists them, and each workspace has its own directory, <code>workspace.yaml</code>, database target and access method, and Evidence sources. This keeps separate data environments and supporting material distinct while versioning them together.
One Git repository can contain many workspaces. At its root, <code>thoth-workspaces.yaml</code> lists them, and each workspace has its own directory and <code>workspace.yaml</code>. This keeps workspace identity and supporting Evidence distinct while versioning them together.
</p>
</div>
<div>
@@ -501,7 +501,7 @@ export function WorkspaceManager({
You can inspect workspaces. Workspace updates, validation, and connection tests require workspace management permission.
</p>
)}
<p className="text-sm leading-6 text-muted-foreground">Selecting a workspace from the left opens its own revision, database and Evidence configuration, runtime requirements, validation, and connection checks.</p>
<p className="text-sm leading-6 text-muted-foreground">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.</p>
</section>
) : (
<section className="mx-auto max-w-4xl space-y-5">
@@ -521,7 +521,7 @@ export function WorkspaceManager({
<div className="max-w-3xl">
<h3 className="font-heading text-lg font-semibold">About this workspace</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
<span className="font-medium text-foreground">{selectedSummary.displayName}</span> is one workspace in the shared repository. Its workspace.yaml defines the database target, processing settings, and Evidence sources for this data environment. Other workspaces in the same repository can use different databases and keep their Evidence separate.
<span className="font-medium text-foreground">{selectedSummary.displayName}</span> 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.
</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
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.
@@ -531,14 +531,7 @@ export function WorkspaceManager({
<dl className="grid gap-3 rounded-lg border border-border bg-muted/20 p-4 text-sm sm:grid-cols-2">
<div><dt className="text-xs text-muted-foreground">Source file</dt><dd className="font-mono">{selectedSummary.file}</dd></div>
<div><dt className="text-xs text-muted-foreground">Active revision</dt><dd className="truncate font-mono">{detailQuery.data.revision.commit}</dd></div>
<div>
<dt className="text-xs text-muted-foreground">Database</dt>
<dd className="grid gap-0.5 font-mono text-xs">
<span>engine: {detailQuery.data.workspace.dwh.engine}</span>
<span>database: {detailQuery.data.workspace.dwh.database}</span>
<span>schema: {detailQuery.data.workspace.dwh.schema}</span>
</dd>
</div>
<div><dt className="text-xs text-muted-foreground">Workspace language</dt><dd className="font-mono text-xs">{detailQuery.data.workspace.workspace.language}</dd></div>
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
</dl>
@@ -0,0 +1,174 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import type { WorkspacePreprocessingStatus } from "../api/workspace-preprocessing";
import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl";
function renderControl(status: WorkspacePreprocessingStatus) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<WorkspacePreprocessingControl
workspaceId="catalog-workspace"
status={status}
loading={false}
unavailable={false}
canManage
/>
</QueryClientProvider>,
);
}
test("failed state shows the reason and only the latest run diagnostic", async () => {
renderControl({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "failed",
actionable: true,
clearable: true,
detail: "The schema index could not be rebuilt.",
reason: "The schema indexing worker stopped before publication.",
nextStep: "Check the core service log, then retry.",
lastFailure: {
stage: "schema_index",
errorCode: "schema_index_failed",
finishedAt: "2026-09-05T10:04:00.000Z",
},
});
expect(screen.getByText("The schema indexing worker stopped before publication.")).toBeVisible();
await userEvent.click(screen.getByText("Last run details"));
expect(screen.getByText("schema_index", { selector: "dd" })).toBeVisible();
expect(screen.getByText("schema_index_failed", { selector: "dd" })).toBeVisible();
expect(screen.getByText(/docker compose logs core/)).toBeVisible();
expect(screen.queryByText(/history/i)).not.toBeInTheDocument();
});
test("blocked state gives the current prerequisite and no run details", () => {
renderControl({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "blocked",
actionable: false,
clearable: true,
detail: "Catalog synchronization is required.",
reason: "Database configuration v4 is newer than Catalog synchronization v3.",
nextStep: "Open Database management and run Synchronize schema.",
});
expect(screen.getByText("Database configuration v4 is newer than Catalog synchronization v3.")).toBeVisible();
expect(screen.getByText("No preprocessing log is available because a run did not start.")).toBeVisible();
expect(screen.queryByText("Last run details")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Run" })).toBeDisabled();
});
test("running state shows the current durable phase instead of only a spinner", () => {
renderControl({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "running",
actionable: false,
clearable: false,
detail: "Indexing Evidence.",
progress: { stage: "evidence", step: 3, totalSteps: 4 },
});
expect(screen.getByText("Indexing Evidence")).toBeVisible();
expect(screen.getByText("3 / 4")).toBeVisible();
expect(screen.getByRole("progressbar", { name: "Preprocessing progress" })).toHaveAttribute(
"aria-valuetext",
"Indexing Evidence, stage 3 of 4",
);
expect(screen.getByRole("button", { name: "Running" })).toBeDisabled();
});
test("ready state offers an explicit rerun from the GUI", async () => {
let calls = 0;
server.use(http.post("/api/workspaces/catalog-workspace/preprocessing", () => {
calls += 1;
return HttpResponse.json({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "ready",
actionable: true,
clearable: true,
detail: "Catalog revision 18 is indexed.",
});
}));
renderControl({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "ready",
actionable: true,
clearable: true,
detail: "Catalog revision 18 is indexed.",
});
await userEvent.click(screen.getByRole("button", { name: "Run again" }));
await waitFor(() => expect(calls).toBe(1));
});
test("Retry invokes the single preprocessing endpoint", async () => {
let calls = 0;
server.use(http.post("/api/workspaces/catalog-workspace/preprocessing", () => {
calls += 1;
return HttpResponse.json({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "ready",
actionable: true,
clearable: true,
detail: "Catalog revision 18 is indexed.",
});
}));
renderControl({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "failed",
actionable: true,
clearable: true,
detail: "Preprocessing did not complete.",
reason: "The worker stopped.",
nextStep: "Retry.",
lastFailure: {
stage: "preprocessing",
errorCode: "preprocessing_failed",
finishedAt: "2026-09-05T10:04:00.000Z",
},
});
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(calls).toBe(1));
});
test("Clear uses the approved inline review and preserves memory in its scope copy", async () => {
let calls = 0;
server.use(http.delete("/api/workspaces/catalog-workspace/preprocessing", () => {
calls += 1;
return HttpResponse.json({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "required",
actionable: true,
clearable: false,
detail: "Reference vectors and LSH are empty. Memory is preserved.",
});
}));
renderControl({
schemaVersion: 1,
workspaceId: "catalog-workspace",
state: "ready",
actionable: true,
clearable: true,
detail: "Catalog revision 18 is indexed.",
});
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
expect(screen.getByText("Clear derived data?")).toBeVisible();
expect(screen.getByText("Schema/Evidence vectors, LSH and derived checkpoints")).toBeVisible();
expect(screen.getByText("Memory and solved questions")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Clear derived data" }));
await waitFor(() => expect(calls).toBe(1));
});
@@ -0,0 +1,327 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, CircleAlert, Clock3, LoaderCircle, ShieldCheck, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { apiErrorMessage } from "../api/client";
import {
clearWorkspacePreprocessing,
runWorkspacePreprocessing,
type WorkspacePreprocessingState,
type WorkspacePreprocessingStatus,
} from "../api/workspace-preprocessing";
const TITLES: Record<WorkspacePreprocessingState, string> = {
ready: "Complete",
required: "Required",
running: "Running",
blocked: "Blocked",
failed: "Failed",
};
type PreprocessingProgress = NonNullable<WorkspacePreprocessingStatus["progress"]>;
const PROGRESS_LABELS: Record<PreprocessingProgress["stage"], string> = {
catalog_snapshot: "Preparing Catalog snapshot",
schema_index: "Building schema and LSH",
evidence: "Indexing Evidence",
finalizing: "Finalizing",
};
const INITIAL_PROGRESS: PreprocessingProgress = {
stage: "catalog_snapshot",
step: 1,
totalSteps: 4,
};
function stateClasses(state: WorkspacePreprocessingState | undefined): string {
switch (state) {
case "ready": return "text-emerald-700";
case "failed": return "text-destructive";
case "required":
case "running":
case "blocked": return "text-amber-700";
default: return "text-muted-foreground";
}
}
function StatusIcon({ state }: { state?: WorkspacePreprocessingState }) {
if (state === "running") {
return <LoaderCircle aria-hidden="true" className="size-3.5 animate-spin motion-reduce:animate-none" />;
}
if (state === "ready") return <Check aria-hidden="true" className="size-3.5" />;
if (state === "required") return <Clock3 aria-hidden="true" className="size-3.5" />;
return <CircleAlert aria-hidden="true" className="size-3.5" />;
}
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
return (
<div className="col-span-2 mt-1 grid gap-1">
<div className="flex items-center justify-between gap-2 text-[9px] leading-none">
<span className="min-w-0 truncate font-medium text-foreground">
{PROGRESS_LABELS[progress.stage]}
</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{step} / {progress.totalSteps}
</span>
</div>
<div
role="progressbar"
aria-label="Preprocessing progress"
aria-valuemin={1}
aria-valuemax={progress.totalSteps}
aria-valuenow={step}
aria-valuetext={`${PROGRESS_LABELS[progress.stage]}, stage ${step} of ${progress.totalSteps}`}
className="grid grid-cols-4 gap-1"
>
{Array.from({ length: progress.totalSteps }, (_, index) => (
<span
key={index}
aria-hidden="true"
className={[
"h-1 rounded-full",
index < step - 1
? "bg-emerald-600/70"
: index === step - 1
? "bg-amber-500"
: "bg-muted",
].join(" ")}
/>
))}
</div>
</div>
);
}
function formatFinishedAt(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "Unavailable";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
}
function Diagnostic({ status }: { status: WorkspacePreprocessingStatus }) {
if (status.state !== "blocked" && status.state !== "failed") return null;
return (
<div
className={[
"mt-2 grid gap-2 rounded-md border p-2.5 text-[10px] leading-[1.45]",
status.state === "failed"
? "border-destructive/25 bg-destructive/[0.055]"
: "border-amber-500/35 bg-amber-500/[0.07]",
].join(" ")}
>
<div className="grid gap-0.5">
<strong className="font-semibold text-foreground">Why this happened</strong>
<span className="text-muted-foreground">{status.reason}</span>
</div>
<div className="grid gap-0.5">
<strong className="font-semibold text-foreground">Next step</strong>
<span className="text-muted-foreground">{status.nextStep}</span>
</div>
{status.state === "failed" && status.lastFailure ? (
<details className="overflow-hidden rounded-md border border-border bg-card">
<summary className="cursor-pointer px-2 py-1.5 font-semibold text-foreground">
Last run details
</summary>
<dl className="grid gap-1.5 border-t border-border px-2 py-2">
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Failed stage</dt>
<dd className="min-w-0 break-all font-mono font-medium text-foreground">
{status.lastFailure.stage}
</dd>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Error code</dt>
<dd className="min-w-0 break-all font-mono font-medium text-foreground">
{status.lastFailure.errorCode}
</dd>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Finished</dt>
<dd className="font-medium text-foreground">
{formatFinishedAt(status.lastFailure.finishedAt)}
</dd>
</div>
</dl>
<p className="border-t border-border px-2 py-1.5 text-muted-foreground">
Only the latest diagnostic is retained. Service log: <code>docker compose logs core</code>.
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-muted-foreground">
No preprocessing log is available because a run did not start.
</p>
)}
</div>
);
}
export function WorkspacePreprocessingControl({
workspaceId,
status,
loading,
unavailable,
canManage,
}: {
workspaceId?: string;
status?: WorkspacePreprocessingStatus;
loading: boolean;
unavailable: boolean;
canManage: boolean;
}) {
const queryClient = useQueryClient();
const [confirmingClear, setConfirmingClear] = useState(false);
const mutation = useMutation({
mutationFn: async () => {
if (!workspaceId) throw new Error("workspace unavailable");
return await runWorkspacePreprocessing(workspaceId);
},
onMutate: () => {
setConfirmingClear(false);
if (!workspaceId || !status) return;
queryClient.setQueryData<WorkspacePreprocessingStatus>(
["workspace-preprocessing", workspaceId],
{
...status,
state: "running",
actionable: false,
detail: "Preparing the PostgreSQL Catalog snapshot.",
progress: INITIAL_PROGRESS,
},
);
},
onError: (error) => toast.error(apiErrorMessage(error)),
onSettled: async () => {
if (!workspaceId) return;
await queryClient.invalidateQueries({ queryKey: ["workspace-preprocessing", workspaceId] });
},
});
const clearMutation = useMutation({
mutationFn: async () => {
if (!workspaceId) throw new Error("workspace unavailable");
return await clearWorkspacePreprocessing(workspaceId);
},
onSuccess: () => {
setConfirmingClear(false);
toast.success("Reference vectors and LSH cleared. Memory preserved.");
},
onError: (error) => toast.error(apiErrorMessage(error)),
onSettled: async () => {
if (!workspaceId) return;
await queryClient.invalidateQueries({ queryKey: ["workspace-preprocessing", workspaceId] });
},
});
const state = mutation.isPending ? "running" : status?.state;
const progress = state === "running" ? status?.progress ?? INITIAL_PROGRESS : undefined;
const title = state ? TITLES[state] : unavailable ? "Status unavailable" : "Checking preprocessing";
const detail = status?.detail
?? (workspaceId ? "Reading the current workspace state." : "Select a workspace first.");
const actionable = Boolean(
workspaceId
&& status?.actionable
&& canManage
&& !mutation.isPending,
);
const clearable = Boolean(
workspaceId
&& status?.clearable
&& canManage
&& !mutation.isPending
&& !clearMutation.isPending,
);
const actionLabel = state === "ready" ? "Run again" : state === "failed" ? "Retry" : "Run";
return (
<section
aria-label="Workspace preprocessing"
aria-busy={state === "running" || loading || clearMutation.isPending}
className="mt-0.5 border-t border-border/90 px-0.5 pt-2"
>
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
Preprocessing
</p>
{confirmingClear ? (
<div className="rounded-lg border border-destructive/25 bg-destructive/[0.045] p-2.5">
<div className="grid grid-cols-[1.5rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-6 place-items-center rounded-md bg-destructive/10 text-destructive">
<AlertTriangle aria-hidden="true" className="size-3.5" />
</span>
<span className="grid gap-0.5">
<strong className="text-[10px] font-semibold text-foreground">Clear derived data?</strong>
<small className="text-[8px] leading-tight text-muted-foreground">
The workspace will require preprocessing.
</small>
</span>
</div>
<div className="my-2 grid gap-1.5 border-y border-destructive/15 py-2 text-[8px]">
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-[18px] place-items-center rounded bg-destructive/10 text-destructive">
<Trash2 aria-hidden="true" className="size-3" />
</span>
<span><strong className="block text-foreground">Clear</strong><small className="text-muted-foreground">Schema/Evidence vectors, LSH and derived checkpoints</small></span>
</div>
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-[18px] place-items-center rounded bg-emerald-600/10 text-emerald-700">
<ShieldCheck aria-hidden="true" className="size-3" />
</span>
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
</div>
</div>
<div className="grid grid-cols-[0.78fr_1.22fr] gap-1.5">
<Button type="button" variant="outline" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
Cancel
</Button>
<Button type="button" variant="destructive" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5">
<span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}>
<StatusIcon state={state} />
</span>
<div className="grid min-w-0 gap-0.5" role="status" aria-live="polite">
<strong className="text-[11px] font-semibold leading-tight text-foreground">{title}</strong>
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
</div>
{progress && <PhaseProgress progress={progress} />}
<div className="col-span-2 mt-0.5 grid grid-cols-[0.78fr_1.22fr] gap-1.5">
<Button
type="button"
variant="outline"
size="xs"
className="h-7 px-2 text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
disabled={!clearable}
title={!canManage ? "Database management permission is required" : undefined}
onClick={() => setConfirmingClear(true)}
>
<Trash2 aria-hidden="true" /> Clear
</Button>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 px-2 text-[10px]"
disabled={!actionable}
title={!canManage ? "Database management permission is required" : undefined}
onClick={() => mutation.mutate()}
>
{state === "running" && <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
{state === "running" ? "Running" : actionLabel}
</Button>
</div>
</div>
{status && <Diagnostic status={status} />}
</>
)}
</section>
);
}
@@ -50,7 +50,7 @@ interface DatabaseGridProps {
rows: CatalogDatabase[],
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
) => Promise<void>;
onConsolidateColumnDescriptions: (rows: CatalogDatabase[]) => Promise<void>;
onConsolidateDescriptions: (rows: CatalogDatabase[]) => Promise<void>;
onSuggestSensitive: (rows: CatalogDatabase[]) => Promise<void>;
onDeleteMetadataSelected: (
rows: CatalogDatabase[],
@@ -78,7 +78,7 @@ type DatabaseFleetAction =
| "sync-all"
| "generate-all"
| "generate-missing"
| "consolidate-columns"
| "consolidate-descriptions"
| "suggest-sensitive"
| "clear-tables"
| "clear-relationships";
@@ -341,7 +341,7 @@ export function DatabaseGrid({
descriptionGenerationActive,
sensitivityAnalysisRuns = [],
onGenerateDescriptions,
onConsolidateColumnDescriptions,
onConsolidateDescriptions,
onSuggestSensitive,
onDeleteMetadataSelected,
onRefresh,
@@ -435,7 +435,7 @@ export function DatabaseGrid({
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canConsolidateColumnDescriptions = canManage
const canConsolidateDescriptions = canManage
&& selectedRows.length === 1
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
@@ -490,13 +490,13 @@ export function DatabaseGrid({
{ id: "sync-all", label: "Synchronize all", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: "Generate missing descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: "Generate all descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: "Review generation" },
{ id: "consolidate-columns", label: "Copy generated descriptions to all columns", group: "Descriptions", disabled: !canConsolidateColumnDescriptions, disabledReason: consolidationReason, runLabel: "Review copy" },
{ id: "consolidate-descriptions", label: "Copy generated description to descriptions", group: "Descriptions", disabled: !canConsolidateDescriptions, disabledReason: consolidationReason, runLabel: "Review copy" },
{ id: "suggest-sensitive", label: "Analyze sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
{ id: "clear-tables", label: "Clear catalog tables and relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "clear-relationships", label: "Clear catalog relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
];
}, [
canConsolidateColumnDescriptions,
canConsolidateDescriptions,
canDeleteMetadataSelection,
canGenerateDescriptions,
canManage,
@@ -569,7 +569,7 @@ export function DatabaseGrid({
setPendingDelete(selectedAction === "clear-tables" ? "tables" : "relationships");
return;
}
if (selectedAction === "consolidate-columns") {
if (selectedAction === "consolidate-descriptions") {
setPendingConsolidation(true);
return;
}
@@ -652,10 +652,10 @@ export function DatabaseGrid({
>
<div className="mr-auto min-w-56">
<p id="database-consolidation-confirmation" className="text-sm font-semibold">
Copy generated descriptions to all columns in {selectedRows[0]?.workspaceName}?
Copy generated descriptions to Description fields in {selectedRows[0]?.workspaceName}?
</p>
<p className="text-xs text-muted-foreground">
Every non-empty AI-generated column description will replace the current Description value. Generated descriptions are retained.
Every non-empty AI-generated table and column description will replace its current Description value. Generated descriptions are retained.
</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeConsolidationConfirmation}>Cancel</Button>
@@ -666,10 +666,10 @@ export function DatabaseGrid({
disabled={action !== null}
onClick={() => void perform(
"consolidate",
() => onConsolidateColumnDescriptions(selectedRows),
() => onConsolidateDescriptions(selectedRows),
)}
>
Copy to all columns
Copy to descriptions
</Button>
</div>
) : pendingDelete ? (
@@ -760,10 +760,10 @@ export function DatabaseGrid({
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canConsolidateColumnDescriptions}
disabled={!canConsolidateDescriptions}
onClick={() => setPendingConsolidation(true)}
>
Copy generated descriptions to all columns
Copy generated description to descriptions
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
@@ -103,7 +103,7 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => {
eventReads += 1;
return HttpResponse.json(eventReads === 1 ? [event(1, "Run queued")] : [
event(2, "First description saved"),
event(2, 'Generated description for Column "patients.birth_date".'),
event(3, "Polling recovered this event"),
]);
}),
@@ -127,11 +127,20 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
logHeight = 200;
act(() => {
FakeEventSource.instances[0].emitNamed("log", event(2, "First description saved"), "2");
FakeEventSource.instances[0].emitNamed("log", event(2, "First description saved"), "2");
FakeEventSource.instances[0].emitNamed(
"log",
event(2, 'Generated description for Column "patients.birth_date".'),
"2",
);
FakeEventSource.instances[0].emitNamed(
"log",
event(2, 'Generated description for Column "patients.birth_date".'),
"2",
);
FakeEventSource.instances[0].onerror?.();
});
await waitFor(() => expect(log.scrollTop).toBe(log.scrollHeight));
expect(within(log).getByText('Generated description for Column "patients.birth_date".')).toBeVisible();
logHeight = 300;
expect(await within(log).findByText("Polling recovered this event")).toBeVisible();
@@ -154,7 +163,9 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
FakeEventSource.instances[1].emitNamed("log", event(4, "Replay continued in order"), "4");
});
await waitFor(() => expect(within(log).getAllByText("First description saved")).toHaveLength(1));
await waitFor(() => expect(
within(log).getAllByText('Generated description for Column "patients.birth_date".'),
).toHaveLength(1));
expect(within(log).getAllByText("Polling recovered this event")).toHaveLength(1);
expect(within(log).getByText("Replay continued in order")).toBeVisible();
expect(log.scrollTop).toBe(0);
@@ -162,11 +173,11 @@ test("replays ordered SSE events, reconnects from the latest sequence, and keeps
expect(log.scrollTop).toBe(log.scrollHeight);
expect(screen.queryByRole("button", { name: "Jump to latest" })).not.toBeInTheDocument();
expect(log.textContent!.indexOf("Run queued")).toBeLessThan(
log.textContent!.indexOf("First description saved"),
);
expect(log.textContent!.indexOf("First description saved")).toBeLessThan(
log.textContent!.indexOf("Polling recovered this event"),
log.textContent!.indexOf('Generated description for Column "patients.birth_date".'),
);
expect(
log.textContent!.indexOf('Generated description for Column "patients.birth_date".'),
).toBeLessThan(log.textContent!.indexOf("Polling recovered this event"));
});
test("stops queued or running work without hiding earlier results or events", async () => {
+11
View File
@@ -1,6 +1,7 @@
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import type { CatalogMetrics } from "../api/catalog-databases";
import type { WorkspacePreprocessingStatus } from "../api/workspace-preprocessing";
export const emptyCatalogMetricsFixture = {
scope: "global",
@@ -30,4 +31,14 @@ export const server = setupServer(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [], default: null })),
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([])),
http.get("/api/catalog/sensitive-data-suggestion-runs", () => HttpResponse.json([])),
http.get("/api/workspaces/:workspaceId/preprocessing", ({ params }) => HttpResponse.json({
schemaVersion: 1,
workspaceId: String(params.workspaceId),
state: "ready",
actionable: true,
clearable: true,
detail: "Catalog revision 1 is indexed.",
metadataRevision: 1,
preprocessedMetadataRevision: 1,
} satisfies WorkspacePreprocessingStatus)),
);
+14 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, expect, test } from "vitest";
import { beforeEach, expect, test, vi } from "vitest";
import { workspacePreferences } from "./preferences";
beforeEach(() => {
@@ -37,3 +37,16 @@ test("seeds memory from backend settings once and returns defensive copies", ()
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
});
});
test("notifies shell consumers when the selected workspace changes", () => {
const listener = vi.fn();
const unsubscribe = workspacePreferences.subscribe(listener);
workspacePreferences.save({ workspaceId: "catalog-one" });
workspacePreferences.save({ workspaceId: "catalog-two" });
unsubscribe();
workspacePreferences.save({ workspaceId: "catalog-three" });
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({ workspaceId: "catalog-two" });
});
+17 -1
View File
@@ -6,11 +6,17 @@ export interface WorkspacePreference {
}
let current: WorkspacePreference | undefined;
const listeners = new Set<(value: WorkspacePreference) => void>();
function snapshot(value: WorkspacePreference | undefined): WorkspacePreference {
return value === undefined ? {} : { ...value };
}
function notify(): void {
const value = snapshot(current);
for (const listener of listeners) listener(value);
}
/** Ephemeral workspace choices for the current application process. */
export const workspacePreferences = {
load(): WorkspacePreference {
@@ -19,17 +25,27 @@ export const workspacePreferences = {
save(value: WorkspacePreference): WorkspacePreference {
current = { ...value };
notify();
return snapshot(current);
},
/** Seed current memory from backend installation settings only before the first local choice. */
migrate(settings: WorkspacePreference): WorkspacePreference {
if (current === undefined) current = { ...settings };
if (current === undefined) {
current = { ...settings };
notify();
}
return snapshot(current);
},
subscribe(listener: (value: WorkspacePreference) => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
},
/** Clear ephemeral state on application/test lifecycle reset. */
reset(): void {
current = undefined;
notify();
},
};