diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index bfe65754..afeb5551 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -3,205 +3,98 @@ import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { - asWorkspaceApiError, + forgetWorkspaceSecret, getWorkspace, - importWorkspace, + getWorkspaceRuntimeConfiguration, listWorkspaces, - publishWorkspace, - validateWorkspace, - type CanonicalWorkspace, + saveWorkspaceSecrets, } from "./workspaces"; const workspace = canonicalWorkspaceFixture("psd-clinical"); const revision = workspaceRevisionFixture("psd-clinical"); -const readySummary = workspaceSummaryFixture("psd-clinical", { - displayName: "PSD Clinical", - description: "Clinical workspace", +const runtimeConfiguration = { + workspaceId: "psd-clinical", revision, -}); - -const evidenceWorkspace = { - ...workspace, - evidence: { - source: { - type: "filesystem", - uri: "psd-clinical/evidence", - patterns: ["**/*.md"], - max_bytes: 10 * 1024 * 1024, - }, - policy: { max_chunk_chars: 4_000, retain_published_generations: 3 }, - }, -} satisfies CanonicalWorkspace; - -test("decodes catalog-driven workspace summaries with exact root descriptor paths", async () => { - server.use(http.get("/api/workspaces", () => HttpResponse.json([ - readySummary, - workspaceSummaryFixture("bootstrap-slot", { - displayName: "Bootstrap slot", - description: "Needs configuration", - configurationState: "configuration_required", - }), - ]))); - - await expect(listWorkspaces()).resolves.toEqual([ - readySummary, - workspaceSummaryFixture("bootstrap-slot", { - displayName: "Bootstrap slot", - description: "Needs configuration", - configurationState: "configuration_required", - }), - ]); -}); - -test.each([ - ["a summary with the removed language field", { ...readySummary, language: "en" }], - ["a non-canonical descriptor path", { ...readySummary, file: "psd-clinical.yaml" }], - ["a ready summary without a revision", { ...readySummary, revision: undefined }], - ["a configuration_required summary with a revision", { - ...workspaceSummaryFixture("bootstrap-slot", { - displayName: "Bootstrap slot", - configurationState: "configuration_required", - }), - revision, + configurationState: "configuration_required", + requirements: [{ + id: "dwh.password", + connector: "dwh", + label: "Data warehouse password", + description: "Password used by the selected data warehouse connection.", + input: "password", + required: true, + configured: false, }], -])("rejects %s", async (_case, malformedSummary) => { - server.use(http.get("/api/workspaces", () => HttpResponse.json([malformedSummary]))); +} as const; - await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary"); +test("decodes read-only workspace summaries with a revision in every readiness state", async () => { + const ready = workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + revision, + }); + const needsSecrets = { + ...ready, + configurationState: "configuration_required" as const, + }; + server.use(http.get("/api/workspaces", () => HttpResponse.json([ready, needsSecrets]))); + + await expect(listWorkspaces()).resolves.toEqual([ready, needsSecrets]); }); -test("uploads a workspace bundle without JSON content type", async () => { - let contentType: string | null = null; - server.use(http.post("/api/workspaces/import", ({ request }) => { - contentType = request.headers.get("content-type"); - return HttpResponse.json({ draft: { workspace } }); - })); - - await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip", { type: "application/zip" })); - - expect(contentType ?? "").not.toMatch(/application\/json/i); -}); - -test("sanitizes imported filesystem Evidence only when it uses the workspace directory root", async () => { - server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ - draft: { workspace: evidenceWorkspace, contract: { variables: [] } }, - }))); - - const result = await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip")); - - expect(result.draft.workspace.evidence).toEqual(evidenceWorkspace.evidence); - expect(result.draft.workspace).not.toBe(evidenceWorkspace); -}); - -test("rejects imported filesystem Evidence that still points at workspace-content", async () => { - server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ - draft: { - workspace: { - ...evidenceWorkspace, - evidence: { - ...evidenceWorkspace.evidence, - source: { - ...evidenceWorkspace.evidence.source, - uri: "workspace-content/psd-clinical/evidence", - }, - }, - }, - contract: { variables: [] }, - }, - }))); - - await expect(importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip"))) - .rejects.toThrow("invalid imported workspace draft"); -}); - -test("accepts the atomic schema-v3 workspace revision contract without historical state", async () => { +test("accepts the immutable workspace revision contract", async () => { server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision }))); await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision }); }); -test("accepts a Qdrant collection using the canonical hyphenated workspace name", async () => { - const hyphenatedCollection = { - ...workspace, - semantic_index: { - ...workspace.semantic_index, - vector_store: { ...workspace.semantic_index.vector_store, collection: "psd-clinical" }, - }, - } satisfies CanonicalWorkspace; - server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ - workspace: hyphenatedCollection, - revision, - }))); +test("decodes runtime requirements but rejects any secret value returned by the server", async () => { + server.use(http.get( + "/api/workspaces/psd-clinical/runtime-configuration", + () => HttpResponse.json(runtimeConfiguration), + )); + await expect(getWorkspaceRuntimeConfiguration("psd-clinical")) + .resolves.toEqual(runtimeConfiguration); - await expect(getWorkspace("psd-clinical")).resolves.toEqual({ - workspace: hyphenatedCollection, - revision, - }); + server.use(http.get( + "/api/workspaces/psd-clinical/runtime-configuration", + () => HttpResponse.json({ + ...runtimeConfiguration, + requirements: [{ ...runtimeConfiguration.requirements[0], value: "leaked-secret" }], + }), + )); + await expect(getWorkspaceRuntimeConfiguration("psd-clinical")) + .rejects.toThrow("invalid runtime configuration"); }); -test("sanitizes read and validate responses while preserving directory-based Evidence", async () => { - server.use( - http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, revision })), - http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: evidenceWorkspace, contract: {} })), - ); - - const read = await getWorkspace("psd-clinical"); - const validated = await validateWorkspace(evidenceWorkspace); - - expect(read.workspace.evidence).toEqual(evidenceWorkspace.evidence); - expect(read.workspace).not.toBe(evidenceWorkspace); - expect(validated.workspace.evidence).toEqual(evidenceWorkspace.evidence); -}); - -test("publishes only bootstrap create requests", async () => { - let sent: unknown; - server.use(http.post("/api/workspaces/publish", async ({ request }) => { - sent = await request.json(); - return HttpResponse.json({ revision }); +test("blind secret replacement sends values once and returns status only", async () => { + let requestBody: unknown; + server.use(http.put("/api/workspaces/psd-clinical/secrets", async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ + ...runtimeConfiguration, + configurationState: "ready", + requirements: [{ ...runtimeConfiguration.requirements[0], configured: true }], + }); })); - await expect(publishWorkspace({ - action: "create", - workspace: evidenceWorkspace, - baseCommit: revision.commit, - })).resolves.toEqual({ revision }); - - expect(sent).toEqual({ - action: "create", - workspace: { ...evidenceWorkspace }, - baseCommit: revision.commit, + const response = await saveWorkspaceSecrets("psd-clinical", { + "dwh.password": "one-time-value", }); - expect(sent).not.toHaveProperty("baseBlob"); + + expect(requestBody).toEqual({ values: { "dwh.password": "one-time-value" } }); + expect(response.configurationState).toBe("ready"); + expect(JSON.stringify(response)).not.toContain("one-time-value"); }); -test("rejects a publish response with a malformed revision", async () => { - server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - revision: { ...revision, commit: "not-a-commit" }, - }))); +test("forget targets one declared requirement", async () => { + let called = false; + server.use(http.delete("/api/workspaces/psd-clinical/secrets/dwh.password", () => { + called = true; + return HttpResponse.json(runtimeConfiguration); + })); - await expect(publishWorkspace({ - action: "create", - workspace: evidenceWorkspace, - baseCommit: revision.commit, - })).rejects.toThrow("invalid workspace revision"); -}); - -test("decodes workspace_curator_owned safely without conflict fields", async () => { - server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - code: "workspace_curator_owned", - message: "Existing descriptors are curator-owned.", - }, { status: 409 }))); - - const error = await publishWorkspace({ - action: "create", - workspace, - baseCommit: revision.commit, - }).catch((cause: unknown) => cause); - - expect(asWorkspaceApiError(error)).toEqual({ - status: 409, - code: "workspace_curator_owned", - message: "Existing descriptors are curator-owned.", - }); + await expect(forgetWorkspaceSecret("psd-clinical", "dwh.password")) + .resolves.toEqual(runtimeConfiguration); + expect(called).toBe(true); }); diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 14a2dcdc..f868b502 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -1,10 +1,10 @@ -import { ApiError, apiFetch, apiFetchBlob } from "./client"; +import { ApiError, apiFetch } from "./client"; import { sanitizeCanonicalWorkspace } from "../workspaces/drafts"; export type WorkspaceErrorCode = | "workspace_invalid" | "binding_missing" | "workspace_not_activatable" - | "workspace_stale" | "workspace_conflict" | "workspace_curator_owned" | "git_unavailable" - | "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected" + | "workspace_stale" | "git_unavailable" + | "git_auth_failed" | "git_non_fast_forward" | "connector_unavailable" | "semantic_index_incompatible"; export interface RestDiagnosticRequest { @@ -124,6 +124,11 @@ export interface WorkspaceRegistryStatus { behind: number; degraded: boolean; lastError?: WorkspaceErrorCode; + repository?: { + host: string; + repository: string; + transport: "https" | "ssh" | "local"; + }; } export interface WorkspaceDiagnostic { @@ -138,10 +143,21 @@ export interface WorkspaceDiagnostics { diagnostics: WorkspaceDiagnostic[]; } -export interface PublishWorkspaceRequest { - action: "create"; - workspace: CanonicalWorkspace; - baseCommit: string; +export interface WorkspaceSecretRequirement { + id: string; + connector: "dwh" | "evidence"; + label: string; + description: string; + input: "password" | "textarea"; + required: boolean; + configured: boolean; +} + +export interface WorkspaceRuntimeConfiguration { + workspaceId: string; + revision: WorkspaceRevision; + configurationState: "ready" | "configuration_required"; + requirements: WorkspaceSecretRequirement[]; } export interface WorkspaceApiError { @@ -153,8 +169,7 @@ export interface WorkspaceApiError { const workspaceErrorCodes = new Set([ "workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale", - "workspace_conflict", "workspace_curator_owned", "git_unavailable", "git_auth_failed", - "git_non_fast_forward", "git_push_rejected", "connector_unavailable", + "git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable", "semantic_index_incompatible", ]); @@ -204,9 +219,7 @@ function workspaceSummary(value: unknown): WorkspaceSummary | undefined { ? undefined : workspaceRevision(source.revision, id); if ( - (configurationState === "ready" && !revision) - || (configurationState === "configuration_required" && source.revision !== undefined) - || (source.revision !== undefined && !revision) + !revision ) return undefined; return { id, @@ -215,7 +228,7 @@ function workspaceSummary(value: unknown): WorkspaceSummary | undefined { displayName: displayName as string, ...(description === undefined ? {} : { description: description as string }), configurationState, - ...(revision ? { revision } : {}), + revision, }; } @@ -246,38 +259,6 @@ function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace { return workspace; } -function requireImportedWorkspace(value: unknown): CanonicalWorkspace { - const direct = sanitizeCanonicalWorkspace(value); - if (direct) return direct; - const source = object(value); - const metadata = object(source?.workspace); - const evidence = object(source?.evidence); - const evidenceSource = object(evidence?.source); - const workspaceId = typeof metadata?.id === "string" ? metadata.id : undefined; - const uri = typeof evidenceSource?.uri === "string" ? evidenceSource.uri : undefined; - if ( - workspaceId - && /^[a-z][a-z0-9-]{2,62}$/.test(workspaceId) - && evidenceSource?.type === "filesystem" - && typeof uri === "string" - && /^[a-z][a-z0-9-]{2,62}\/evidence$/.test(uri) - ) { - const repaired = { - ...source, - evidence: { - ...evidence, - source: { - ...evidenceSource, - uri: `${workspaceId}/evidence`, - }, - }, - }; - const sanitized = sanitizeCanonicalWorkspace(repaired); - if (sanitized) return sanitized; - } - throw new Error("Workspace API returned an invalid imported workspace draft"); -} - export const listWorkspaces = async (): Promise => { const response = await apiFetch("/workspaces"); if (!Array.isArray(response)) throw new Error("Workspace API returned an invalid workspace summary"); @@ -309,43 +290,61 @@ export const validateWorkspace = async (workspace: CanonicalWorkspace) => { }); const source = object(response); if (!source) throw new Error("Workspace API returned an invalid validation result"); - return { workspace: requireImportedWorkspace(source.workspace), contract: source.contract }; + return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract }; }; export const testWorkspace = (id: string) => apiFetch(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }); -export const publishWorkspace = async (request: PublishWorkspaceRequest) => { - const safeRequest: PublishWorkspaceRequest = { ...request, workspace: requireCanonicalWorkspace(request.workspace) }; - const response = await apiFetch("/workspaces/publish", { - method: "POST", body: JSON.stringify(safeRequest), +function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration { + const source = exactObject(value, [ + "workspaceId", "revision", "configurationState", "requirements", + ]); + if ( + !source + || source.workspaceId !== expectedId + || (source.configurationState !== "ready" && source.configurationState !== "configuration_required") + || !Array.isArray(source.requirements) + ) throw new Error("Workspace API returned an invalid runtime configuration"); + const requirements = source.requirements.map((value) => { + const requirement = exactObject(value, [ + "id", "connector", "label", "description", "input", "required", "configured", + ]); + if ( + !requirement + || typeof requirement.id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,127}$/.test(requirement.id) + || (requirement.connector !== "dwh" && requirement.connector !== "evidence") + || typeof requirement.label !== "string" || requirement.label.length === 0 + || typeof requirement.description !== "string" || requirement.description.length === 0 + || (requirement.input !== "password" && requirement.input !== "textarea") + || typeof requirement.required !== "boolean" + || typeof requirement.configured !== "boolean" + ) throw new Error("Workspace API returned an invalid runtime configuration"); + return requirement as unknown as WorkspaceSecretRequirement; }); - if (response === undefined) return undefined; - const source = exactObject(response, ["revision"]); - if (!source) throw new Error("Workspace API returned an invalid publish result"); - return { revision: requireWorkspaceRevision(source.revision, safeRequest.workspace.workspace.id) }; -}; - -export const exportWorkspace = (id: string) => - apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`); - -export const importWorkspace = async (bundle: File) => { - const body = new FormData(); - body.set("bundle", bundle); - const response = await apiFetch("/workspaces/import", { method: "POST", body }); - const source = exactObject(response, ["draft"]); - const draft = exactObject(source?.draft, ["workspace", "contract"]); - if (!source || !draft) throw new Error("Workspace API returned an invalid imported workspace draft"); - let workspace: CanonicalWorkspace; - try { - workspace = requireImportedWorkspace(draft.workspace); - } catch { - throw new Error("Workspace API returned an invalid imported workspace draft"); - } return { - draft: { - workspace, - ...(draft.contract === undefined ? {} : { contract: draft.contract }), - }, + workspaceId: expectedId, + revision: requireWorkspaceRevision(source.revision, expectedId), + configurationState: source.configurationState, + requirements, }; -}; +} + +export const getWorkspaceRuntimeConfiguration = async (id: string) => runtimeConfiguration( + await apiFetch(`/workspaces/${encodeURIComponent(id)}/runtime-configuration`), + id, +); + +export const saveWorkspaceSecrets = async (id: string, values: Readonly>) => ( + runtimeConfiguration(await apiFetch(`/workspaces/${encodeURIComponent(id)}/secrets`, { + method: "PUT", + body: JSON.stringify({ values }), + }), id) +); + +export const forgetWorkspaceSecret = async (id: string, requirementId: string) => ( + runtimeConfiguration(await apiFetch( + `/workspaces/${encodeURIComponent(id)}/secrets/${encodeURIComponent(requirementId)}`, + { method: "DELETE" }, + ), id) +); diff --git a/frontend/src/shell/WorkspaceEditor.test.tsx b/frontend/src/shell/WorkspaceEditor.test.tsx deleted file mode 100644 index d577dbc5..00000000 --- a/frontend/src/shell/WorkspaceEditor.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { expect, test, vi } from "vitest"; -import type { CanonicalWorkspace } from "../api/workspaces"; -import type { WorkspaceBootstrapDraft } from "../workspaces/drafts"; -import { workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; -import { WorkspaceEditor } from "./WorkspaceEditor"; - -const workspace: CanonicalWorkspace = { - workspace: { schema_version: 3, id: "bootstrap-slot", name: "Bootstrap slot", description: "Needs configuration", language: "en" }, - dwh: { - engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432, - supported_transports: ["postgres_direct"], - }, - semantic_index: { - vector_store: { - engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine", - }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, -}; - -const evidenceWorkspace: CanonicalWorkspace = { - ...workspace, - workspace: { ...workspace.workspace, id: "psd-clinical", name: "PSD Clinical", description: "Clinical workspace" }, - evidence: { - source: { - type: "filesystem", - uri: "psd-clinical/evidence", - patterns: ["documents/**/*.pdf"], - max_bytes: 12_000_000, - }, - policy: { max_chunk_chars: 8_000, retain_published_generations: 5 }, - }, -}; - -const draft: WorkspaceBootstrapDraft = { - workspaceId: "bootstrap-slot", - baseCommit: "a".repeat(40), - workspace, - updatedAt: "2026-08-04T10:00:00.000Z", -}; - -test("bootstrap mode locks catalog metadata and saves only the local bootstrap draft", async () => { - const user = userEvent.setup(); - const onSaveDraft = vi.fn(); - render( - , - ); - - expect(screen.getByLabelText("Workspace ID")).toBeDisabled(); - expect(screen.getByLabelText("Workspace name")).toHaveValue("Bootstrap slot"); - expect(screen.getByLabelText("Workspace name")).toBeDisabled(); - expect(screen.getByLabelText("Description")).toHaveValue("Needs configuration"); - expect(screen.getByLabelText("Description")).toBeDisabled(); - - await user.clear(screen.getByLabelText("Vector collection")); - await user.type(screen.getByLabelText("Vector collection"), "research-docs"); - await user.click(screen.getByRole("button", { name: "Save draft" })); - - expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({ - baseCommit: "a".repeat(40), - workspaceId: "bootstrap-slot", - workspace: expect.objectContaining({ - semantic_index: expect.objectContaining({ - vector_store: expect.objectContaining({ collection: "research-docs" }), - }), - }), - })); - expect(onSaveDraft.mock.calls[0]?.[0]).not.toHaveProperty("baseBlob"); -}); - -test("read-only mode shows evidence and curator git guidance without mutation actions", () => { - render( - , - ); - - expect(screen.getByLabelText("DWH database")).toHaveValue("clinical"); - expect(screen.getByLabelText("DWH database")).toHaveAttribute("readonly"); - const summary = screen.getByRole("region", { name: "Evidence" }); - expect(summary).toHaveTextContent("filesystem"); - expect(summary).toHaveTextContent("psd-clinical/evidence"); - expect(summary).toHaveTextContent("8,000"); - expect(summary).toHaveTextContent("5"); - expect(screen.getByText("Curator workflow")).toBeVisible(); - expect(screen.getByText(/edit psd-clinical\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible(); - expect(screen.queryByRole("button", { name: "Save draft" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /create workspace|publish/i })).not.toBeInTheDocument(); -}); diff --git a/frontend/src/shell/WorkspaceEditor.tsx b/frontend/src/shell/WorkspaceEditor.tsx deleted file mode 100644 index 510b6d53..00000000 --- a/frontend/src/shell/WorkspaceEditor.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { useEffect, useId, useMemo, useState, type ChangeEvent, type ReactNode } from "react"; -import type { CanonicalWorkspace, PublishWorkspaceRequest, WorkspaceRecord, WorkspaceSummary } from "../api/workspaces"; -import type { WorkspaceBootstrapDraft } from "../workspaces/drafts"; -import { Button } from "../components/ui/button"; - -type FieldErrors = Record; - -export type WorkspaceEditorMode = - | { kind: "bootstrap"; catalog: WorkspaceSummary; draft: WorkspaceBootstrapDraft } - | { kind: "read_only"; catalog: WorkspaceSummary; record: WorkspaceRecord }; - -type BootstrapEditorProps = { - mode: Extract; - onSaveDraft?: (draft: WorkspaceBootstrapDraft) => void; - onRequestCreate?: (request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) => void; -}; - -type ReadOnlyEditorProps = { - mode: Extract; - /** Absent in read-only mode; declared so the union is uniformly addressable. */ - onSaveDraft?: never; - onRequestCreate?: never; -}; - -export type WorkspaceEditorProps = BootstrapEditorProps | ReadOnlyEditorProps; - -const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive"; - -function positiveInteger(value: number | undefined, label: string, max = Number.MAX_SAFE_INTEGER): string | undefined { - if (value === undefined) return undefined; - if (!Number.isInteger(value) || value < 1 || value > max) { - return max === 65_535 ? "Port must be between 1 and 65535" : `${label} must be a positive whole number`; - } - return undefined; -} - -function validate(workspace: CanonicalWorkspace): FieldErrors { - const errors: FieldErrors = {}; - if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.workspace.id)) { - errors["workspace.id"] = "Use 3–63 lowercase letters, numbers, or hyphens; start with a letter"; - } - if (!workspace.workspace.name.trim()) errors["workspace.name"] = "Workspace name is required"; - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.database)) errors["dwh.database"] = "Use a database identifier"; - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.schema)) errors["dwh.schema"] = "Use a schema identifier"; - if (!workspace.dwh.supported_transports.length) errors["dwh.transport"] = "Choose at least one DWH transport"; - const dwhPort = positiveInteger(workspace.dwh.port, "DWH port", 65_535); - if (dwhPort) errors["dwh.port"] = dwhPort; - const dwhTimeout = positiveInteger(workspace.dwh.timeout_ms, "DWH timeout"); - if (dwhTimeout) errors["dwh.timeout"] = dwhTimeout; - if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a canonical collection name"; - if (!workspace.llm_policy.allowed.length || workspace.llm_policy.allowed.some((model) => !/^[^/\s]+\/[^/\s]+$/.test(model))) { - errors["llm.allowed"] = "Use provider/model entries separated by commas"; - } - if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) { - errors["llm.default"] = "Default model must be in the allowlist"; - } - return errors; -} - -function selectedValues(event: ChangeEvent): string[] { - return Array.from(event.currentTarget.selectedOptions, (option) => option.value); -} - -function numberOrUndefined(value: string): number | undefined { - return value.trim() === "" ? undefined : Number(value); -} - -function coerceCatalogMetadata(workspace: CanonicalWorkspace, catalog: WorkspaceSummary): CanonicalWorkspace { - return { - ...workspace, - workspace: { - ...workspace.workspace, - id: catalog.id, - name: catalog.displayName, - description: catalog.description, - }, - }; -} - -function Field({ - label, - error, - hint, - children, -}: { - label: string; - error?: string; - hint?: string; - children: (props: { id: string; describedBy?: string; invalid: boolean }) => ReactNode; -}) { - const id = useId(); - const errorId = `${id}-error`; - const hintId = `${id}-hint`; - const describedBy = [hint ? hintId : undefined, error ? errorId : undefined].filter(Boolean).join(" ") || undefined; - return ( -
- - {children({ id, describedBy, invalid: Boolean(error) })} - {hint &&

{hint}

} - {error && } -
- ); -} - -function Section({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

-
{children}
-
- ); -} - -function EvidenceSummary({ evidence }: { evidence: NonNullable }) { - const sourceIdentity = evidence.source.type === "http" - ? `${evidence.source.uris.length} canonical URI${evidence.source.uris.length === 1 ? "" : "s"}` - : evidence.source.uri; - return ( -
-
-
Source type
{evidence.source.type}
-
Source
{sourceIdentity}
-
Chunk size
{evidence.policy.max_chunk_chars.toLocaleString("en-US")}
-
Retention
{evidence.policy.retain_published_generations.toLocaleString("en-US")}
-
-

Evidence summary is read-only. Curate source files or URIs in Git.

-
- ); -} - -export function WorkspaceEditor(props: WorkspaceEditorProps) { - const bootstrapMode = props.mode.kind === "bootstrap"; - const initialWorkspace = props.mode.kind === "bootstrap" - ? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog) - : props.mode.record.workspace; - const [workspace, setWorkspace] = useState(initialWorkspace); - const [errors, setErrors] = useState({}); - const readOnly = !bootstrapMode; - const allowedModels = useMemo(() => workspace.llm_policy.allowed.join(", "), [workspace.llm_policy.allowed]); - - useEffect(() => { - setWorkspace(props.mode.kind === "bootstrap" - ? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog) - : props.mode.record.workspace); - setErrors({}); - }, [bootstrapMode, props.mode]); - - function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) { - if (readOnly) return; - setWorkspace((previous) => { - const next = coerceCatalogMetadata(change(previous), props.mode.catalog); - setErrors(validate(next)); - return next; - }); - } - - function currentDraft(): WorkspaceBootstrapDraft { - if (props.mode.kind !== "bootstrap") throw new Error("Read-only workspaces cannot create drafts"); - return { - workspaceId: props.mode.catalog.id, - baseCommit: props.mode.draft.baseCommit, - workspace: coerceCatalogMetadata(workspace, props.mode.catalog), - updatedAt: new Date().toISOString(), - }; - } - - function saveDraft() { - if (props.mode.kind !== "bootstrap") return; - const nextErrors = validate(workspace); - setErrors(nextErrors); - if (Object.keys(nextErrors).length > 0) return; - props.onSaveDraft?.(currentDraft()); - } - - function requestCreate() { - if (props.mode.kind !== "bootstrap") return; - const nextErrors = validate(workspace); - setErrors(nextErrors); - if (Object.keys(nextErrors).length > 0) return; - const draft = currentDraft(); - props.onRequestCreate?.({ action: "create", workspace: draft.workspace, baseCommit: draft.baseCommit }, draft); - } - - return ( -
{ event.preventDefault(); saveDraft(); }} noValidate> -
- - {({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, id: event.target.value } }))} />} - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, name: event.target.value } }))} />} - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />} - - - {({ id, describedBy, invalid }) => } - -
- -
- - {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, database: event.target.value } }))} />} - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, schema: event.target.value } }))} />} - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />} - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />} - -
- -
- - {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />} - - - {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => } - -
- -
- - {({ id, describedBy, invalid }) => update((value) => { - const allowed = event.target.value.split(",").map((model) => model.trim()).filter(Boolean) as `${string}/${string}`[]; - return { ...value, llm_policy: { allowed, ...(value.llm_policy.default && allowed.includes(value.llm_policy.default) ? { default: value.llm_policy.default } : {}) } }; - })} />} - - - {({ id, describedBy, invalid }) => } - -
- - {workspace.evidence && } - -
-

Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in browser drafts.

-
- - {readOnly ? ( -
-

To change this workspace, edit {workspace.workspace.id}/workspace.yaml, commit/push, then Pull.

-
- ) : ( -
- - {"onRequestCreate" in props && props.onRequestCreate && } -
- )} - - ); -} diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx index c8febed1..91987b8d 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -1,213 +1,165 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { http, HttpResponse } from "msw"; -import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; import { server } from "../test/msw"; -import { workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; +import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { WorkspaceManager } from "./WorkspaceManager"; -const readyWorkspace = { - workspace: { - schema_version: 3, - id: "psd-clinical", - name: "PSD Clinical", - description: "Clinical data", - language: "en" as const, - }, - dwh: { - engine: "postgres" as const, - database: "clinical", - schema: "datawarehouse", - port: 5432, - supported_transports: ["postgres_direct"] as const, - }, - semantic_index: { - vector_store: { engine: "qdrant" as const, collection: "clinical", dimensions: 1024 as const, distance: "cosine" as const }, - embedding: { provider: "ollama_internal" as const, model: "qwen3-embedding:0.6b" as const, dimensions: 1024 as const }, - }, - llm_policy: { default: "zai/glm-5.2" as const, allowed: ["zai/glm-5.2"] as const }, - evidence: { - source: { - type: "filesystem" as const, - uri: "psd-clinical/evidence", - patterns: ["documents/**/*.pdf"], - max_bytes: 12_000_000, - }, - policy: { max_chunk_chars: 8_000, retain_published_generations: 5 }, - }, +const workspace = canonicalWorkspaceFixture("psd-clinical"); +const revision = workspaceRevisionFixture("psd-clinical"); +const requirement = { + id: "dwh.password", + connector: "dwh", + label: "Data warehouse password", + description: "Password used by the selected data warehouse connection.", + input: "password", + required: true, + configured: false, }; -const bootstrapWorkspace = { - ...readyWorkspace, - workspace: { - ...readyWorkspace.workspace, - id: "bootstrap-slot", - name: "Bootstrap slot", - description: "Needs configuration", - }, - semantic_index: { - ...readyWorkspace.semantic_index, - vector_store: { ...readyWorkspace.semantic_index.vector_store, collection: "bootstrap-slot" }, - }, -}; +function runtimeConfiguration(configured = false) { + return { + workspaceId: "psd-clinical", + revision, + configurationState: configured ? "ready" : "configuration_required", + requirements: [{ ...requirement, configured }], + }; +} -function renderManager() { +function renderManager(onClose = vi.fn()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( undefined} />); + return { + onClose, + ...render( + + + , + ), + }; } beforeEach(() => { localStorage.clear(); server.use( - http.get("/api/workspace-registry/status", () => - HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })), + http.get("/api/workspace-registry/status", () => HttpResponse.json({ + branch: "main", + head: "a".repeat(40), + ahead: 0, + behind: 0, + degraded: false, + repository: { + host: "git.example.test", + repository: "analytics/thoth-workspaces", + transport: "ssh", + }, + })), http.get("/api/workspaces", () => HttpResponse.json([ - workspaceSummaryFixture("bootstrap-slot", { - displayName: "Bootstrap slot", - description: "Needs configuration", - configurationState: "configuration_required", - }), workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", description: "Clinical data", - revision: workspaceRevisionFixture("psd-clinical"), + configurationState: "configuration_required", + revision, }), ])), - http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ - workspace: readyWorkspace, - revision: workspaceRevisionFixture("psd-clinical"), - })), + http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision })), + http.get("/api/workspaces/psd-clinical/runtime-configuration", () => ( + HttpResponse.json(runtimeConfiguration()) + )), ); }); -afterEach(() => vi.unstubAllGlobals()); +test("uses at least sixty percent of the viewport on desktop", () => { + renderManager(); -test("renders catalog slots in order and opens a bootstrap form for configuration_required entries", async () => { + expect(screen.getByRole("dialog")).toHaveClass( + "h-[70vh]", + "w-[94vw]", + "sm:w-[70vw]", + "max-w-[94vw]", + ); +}); + +test("level one explains the read-only Git sequence and the repository update button", async () => { const user = userEvent.setup(); + server.use(http.post("/api/workspace-registry/pull", () => HttpResponse.json({ + branch: "main", head: "b".repeat(40), ahead: 0, behind: 0, degraded: false, + }))); renderManager(); expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Bootstrap slot" })).toBeVisible(); - expect(screen.getByRole("button", { name: "PSD Clinical" })).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Bootstrap slot" })); + const overview = screen.getByTestId("workspace-overview"); + expect(within(overview).getByText(/Git server such as GitHub, GitLab, or Gitea/i)).toBeVisible(); + expect(within(overview).getByText(/configured during ThothII installation/i)).toBeVisible(); + expect(within(overview).getAllByText(/managed read-only checkout/i)).toHaveLength(2); + expect(within(overview).getByText(/current active revision remains unchanged/i)).toBeVisible(); + expect(within(overview).getByText(/No workspace selection is required/i)).toBeVisible(); + expect(await within(overview).findByText("git.example.test/analytics/thoth-workspaces")).toBeVisible(); - expect(await screen.findByLabelText("Workspace ID")).toHaveValue("bootstrap-slot"); - expect(screen.getByLabelText("Workspace ID")).toBeDisabled(); - expect(screen.getByLabelText("Workspace name")).toHaveValue("Bootstrap slot"); - expect(screen.getByLabelText("Workspace name")).toBeDisabled(); - expect(screen.getByLabelText("Description")).toHaveValue("Needs configuration"); - expect(screen.getByRole("button", { name: "Save draft" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Create workspace" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Update workspace repository" })); + expect(await screen.findByText("Workspace repository updated and validated.")).toBeVisible(); + expect(screen.queryByText(/import|export|bundle|create a local workspace/i)).not.toBeInTheDocument(); }); -test("saves a bootstrap draft locally for a configuration_required slot", async () => { +test("workspace-specific commands remain isolated until a workspace is selected", async () => { const user = userEvent.setup(); renderManager(); - await user.click(await screen.findByRole("button", { name: "Bootstrap slot" })); - await user.clear(screen.getByLabelText("Vector collection")); - await user.type(screen.getByLabelText("Vector collection"), "bootstrap-docs"); - await user.click(screen.getByRole("button", { name: "Save draft" })); - - await waitFor(() => expect(screen.getByText("Draft saved in this browser.")).toBeVisible()); - expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).toContain('"baseCommit"'); - expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).not.toContain("baseBlob"); -}); - -test("successful create discards the bootstrap draft and reloads the workspace as read-only", async () => { - const user = userEvent.setup(); - let workspacesCalls = 0; - server.use( - http.get("/api/workspaces", () => { - workspacesCalls += 1; - return HttpResponse.json(workspacesCalls === 1 ? [ - workspaceSummaryFixture("bootstrap-slot", { - displayName: "Bootstrap slot", - description: "Needs configuration", - configurationState: "configuration_required", - }), - ] : [ - workspaceSummaryFixture("bootstrap-slot", { - displayName: "Bootstrap slot", - description: "Needs configuration", - revision: workspaceRevisionFixture("bootstrap-slot"), - }), - ]); - }), - http.get("/api/workspaces/bootstrap-slot", () => HttpResponse.json({ - workspace: bootstrapWorkspace, - revision: workspaceRevisionFixture("bootstrap-slot"), - })), - http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: bootstrapWorkspace, contract: {} })), - http.post("/api/workspaces/publish", () => HttpResponse.json({ revision: workspaceRevisionFixture("bootstrap-slot") })), - ); - localStorage.setItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot", JSON.stringify({ - workspaceId: "bootstrap-slot", - baseCommit: "a".repeat(40), - workspace: bootstrapWorkspace, - updatedAt: "2026-08-04T10:00:00.000Z", - })); - renderManager(); - - await user.click(await screen.findByRole("button", { name: "Bootstrap slot" })); - await user.click(screen.getByRole("button", { name: "Create workspace" })); - await user.click(screen.getByRole("button", { name: "Validate draft" })); - await user.click(await screen.findByRole("button", { name: "Create workspace" })); - await user.click(screen.getByRole("button", { name: "Confirm create" })); - - await waitFor(() => expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).toBeNull()); - expect(await screen.findByText(/edit bootstrap-slot\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible(); - expect(screen.queryByRole("button", { name: "Save draft" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Create workspace" })).not.toBeInTheDocument(); -}); - -test("ready workspaces stay read-only while keeping pull, export, validate, and installation test actions", async () => { - const user = userEvent.setup(); - const createObjectURL = vi.fn(() => "blob:workspace-bundle"); - const revokeObjectURL = vi.fn(); - class DownloadUrl extends URL { - static createObjectURL = createObjectURL; - static revokeObjectURL = revokeObjectURL; - } - vi.stubGlobal("URL", DownloadUrl); - vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); - server.use( - http.post("/api/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })), - http.get("/api/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))), - http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: readyWorkspace, contract: {} })), - http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({ - activatable: false, - diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }], - })), - ); - localStorage.setItem("thothii.workspace-registry.v1.draft.psd-clinical", JSON.stringify({ foo: "bar" })); - renderManager(); - + expect(screen.queryByRole("heading", { name: "Workspace-specific actions" })).not.toBeInTheDocument(); await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); - expect(await screen.findByText(/edit psd-clinical\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible(); - expect(screen.queryByRole("button", { name: /duplicate workspace|delete workspace|publish draft/i })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Pull latest registry" })); - expect(await screen.findByText("Registry updated. Reload a workspace to review its latest revision.")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Export workspace bundle" })); - await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1)); - await user.click(screen.getByRole("button", { name: "Validate workspace" })); - expect(await screen.findByText("Workspace definition is valid.")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Test on this installation" })); - expect(await screen.findByText("binding_missing: DWH binding is not configured")).toBeVisible(); - expect(within(screen.getByTestId("workspace-diagnostics")).queryByText(/password|token|secret/i)).not.toBeInTheDocument(); + + expect(await screen.findByRole("heading", { name: "Workspace-specific actions" })).toBeVisible(); + expect(screen.getByText(/reads this revision without modifying or publishing it/i)).toBeVisible(); + expect(screen.getByText(/checks workspace.yaml and the required workspace directories/i)).toBeVisible(); + expect(screen.getByText(/temporary decrypted credentials/i)).toBeVisible(); + expect(screen.getByRole("button", { name: "Validate workspace source" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Test workspace connections" })).toBeVisible(); }); -test("import populates only a matching configuration_required slot and refuses existing workspaces", async () => { +test("secret fields are write-only, clear after blind save, and may be forgotten", async () => { const user = userEvent.setup(); + let savedBody: unknown; + server.use( + http.put("/api/workspaces/psd-clinical/secrets", async ({ request }) => { + savedBody = await request.json(); + return HttpResponse.json(runtimeConfiguration(true)); + }), + http.delete("/api/workspaces/psd-clinical/secrets/dwh.password", () => ( + HttpResponse.json(runtimeConfiguration(false)) + )), + ); renderManager(); + await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); - server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace: readyWorkspace, contract: {} } }))); - await user.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" })); - expect(await screen.findByText(/workspace_invalid: Imported bundle can only bootstrap a matching catalog slot/i)).toBeVisible(); + const input = await screen.findByLabelText("Data warehouse password"); + expect(input).toHaveValue(""); + expect(input).toHaveAttribute("type", "password"); + expect(screen.getByText("Not configured")).toBeVisible(); + await user.type(input, "one-time-password"); + await user.click(screen.getByRole("button", { name: "Save entered secrets" })); - server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace: bootstrapWorkspace, contract: {} } }))); - await user.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" })); - expect(await screen.findByText("Imported bootstrap draft saved in this browser. Validate it before creating the descriptor.")).toBeVisible(); - expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).not.toBeNull(); + await waitFor(() => expect(savedBody).toEqual({ + values: { "dwh.password": "one-time-password" }, + })); + expect(input).toHaveValue(""); + expect(await screen.findByText("Configured")).toBeVisible(); + expect(screen.queryByDisplayValue("one-time-password")).not.toBeInTheDocument(); + expect(localStorage.length).toBe(0); + + await user.click(screen.getByRole("button", { name: "Forget stored Data warehouse password" })); + expect(await screen.findByText("Not configured")).toBeVisible(); +}); + +test("closing clears unsaved secret fields", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderManager(onClose); + await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); + await user.type(await screen.findByLabelText("Data warehouse password"), "unsaved-value"); + + await user.click(screen.getByRole("button", { name: "Close workspace management" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.queryByDisplayValue("unsaved-value")).not.toBeInTheDocument(); }); diff --git a/frontend/src/shell/WorkspaceManager.tsx b/frontend/src/shell/WorkspaceManager.tsx index caa493d7..f8e42ff9 100644 --- a/frontend/src/shell/WorkspaceManager.tsx +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -1,28 +1,37 @@ import { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Upload, X } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + CheckCircle2, + ClipboardCheck, + FlaskConical, + GitPullRequest, + KeyRound, + Trash2, + X, +} from "lucide-react"; + import { asWorkspaceApiError, - exportWorkspace, + forgetWorkspaceSecret, getWorkspace, getWorkspaceRegistryStatus, - importWorkspace, + getWorkspaceRuntimeConfiguration, listWorkspaces, pullWorkspaceRegistry, + saveWorkspaceSecrets, testWorkspace, validateWorkspace, - type CanonicalWorkspace, - type PublishWorkspaceRequest, - type WorkspaceRecord, - type WorkspaceSummary, + type WorkspaceRuntimeConfiguration, } from "../api/workspaces"; -import { workspaceBootstrapDrafts, type WorkspaceBootstrapDraft } from "../workspaces/drafts"; import { Button } from "../components/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; -import { WorkspaceEditor } from "./WorkspaceEditor"; -import { WorkspacePublishDialog } from "./WorkspacePublishDialog"; - -const EMPTY_COMMIT = "0".repeat(40); +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; function QueryError({ name, message, retryLabel, onRetry }: { name: string; @@ -30,278 +39,375 @@ function QueryError({ name, message, retryLabel, onRetry }: { retryLabel: string; onRetry: () => void; }) { - return

{message}

; + return ( +
+

{message}

+
+
+ ); } -function defaultBootstrapWorkspace(summary: WorkspaceSummary): CanonicalWorkspace { - return { - workspace: { - schema_version: 3, - id: summary.id, - name: summary.displayName, - ...(summary.description ? { description: summary.description } : {}), - language: "en", - }, - dwh: { - engine: "postgres", - database: "database", - schema: "public", - supported_transports: ["postgres_direct"], - }, - semantic_index: { - vector_store: { - engine: "qdrant", - collection: summary.id.replaceAll("-", "_"), - dimensions: 1024, - distance: "cosine", - }, - embedding: { - provider: "ollama_internal", - model: "qwen3-embedding:0.6b", - dimensions: 1024, - }, - }, - llm_policy: { allowed: ["zai/glm-5.2"], default: "zai/glm-5.2" }, - }; +function publicError(error: unknown, fallback: string): string { + const safe = asWorkspaceApiError(error); + return safe ? `${safe.code}: ${safe.message}` : fallback; } -function bootstrapDraftFor(summary: WorkspaceSummary, baseCommit: string): WorkspaceBootstrapDraft { - return { - workspaceId: summary.id, - baseCommit, - workspace: defaultBootstrapWorkspace(summary), - updatedAt: new Date().toISOString(), - }; +function stateLabel(state: "ready" | "configuration_required"): string { + return state === "ready" ? "Ready" : "Runtime configuration required"; } export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) { + const queryClient = useQueryClient(); const [selectedId, setSelectedId] = useState(); - const [localDraft, setLocalDraft] = useState(); + const [secretValues, setSecretValues] = useState>({}); const [notice, setNotice] = useState(); const [diagnostics, setDiagnostics] = useState([]); - const [publishRequest, setPublishRequest] = useState(); - const [transferring, setTransferring] = useState(false); + const [busyAction, setBusyAction] = useState(); - const statusQuery = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open }); - const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open }); + const statusQuery = useQuery({ + queryKey: ["workspace-repository-status"], + queryFn: getWorkspaceRegistryStatus, + enabled: open, + }); + const workspacesQuery = useQuery({ + queryKey: ["workspaces"], + queryFn: listWorkspaces, + enabled: open, + }); const workspaces = workspacesQuery.data ?? []; - const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]); + const selectedSummary = useMemo( + () => workspaces.find(({ id }) => id === selectedId), + [selectedId, workspaces], + ); const detailQuery = useQuery({ queryKey: ["workspace", selectedId], queryFn: () => getWorkspace(selectedId!), - enabled: Boolean(open && selectedId && selectedSummary?.configurationState === "ready"), + enabled: Boolean(open && selectedId), + }); + const runtimeQuery = useQuery({ + queryKey: ["workspace-runtime-configuration", selectedId], + queryFn: () => getWorkspaceRuntimeConfiguration(selectedId!), + enabled: Boolean(open && selectedId), }); - const record = detailQuery.data; - const activeBootstrapDraft = selectedSummary?.configurationState === "configuration_required" - ? (localDraft?.workspaceId === selectedSummary.id ? localDraft : workspaceBootstrapDrafts.load(selectedSummary.id) ?? bootstrapDraftFor(selectedSummary, statusQuery.data?.head ?? EMPTY_COMMIT)) - : undefined; - - function resetTransientState() { + const clearMessages = () => { setNotice(undefined); setDiagnostics([]); - setPublishRequest(undefined); - } + }; - function selectWorkspace(id: string) { + const close = () => { + setSecretValues({}); + clearMessages(); + onClose(); + }; + + const selectWorkspace = (id: string) => { setSelectedId(id); - setLocalDraft(undefined); - resetTransientState(); - } + setSecretValues({}); + clearMessages(); + }; - function saveDraft(draft: WorkspaceBootstrapDraft) { - workspaceBootstrapDrafts.save(draft); - setLocalDraft(draft); - setSelectedId(draft.workspaceId); - setNotice("Draft saved in this browser."); - setDiagnostics([]); - } - - function requestCreate(request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) { - workspaceBootstrapDrafts.save(draft); - setLocalDraft(draft); - setSelectedId(draft.workspaceId); - setNotice(undefined); - setDiagnostics([]); - setPublishRequest(request); - } - - async function pullLatest() { - setNotice(undefined); - setDiagnostics([]); + async function updateRepository() { + setBusyAction("repository"); + clearMessages(); try { await pullWorkspaceRegistry(); - await Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]); - setNotice("Registry updated. Reload a workspace to review its latest revision."); + await Promise.all([ + statusQuery.refetch(), + workspacesQuery.refetch(), + selectedId ? detailQuery.refetch() : Promise.resolve(), + selectedId ? runtimeQuery.refetch() : Promise.resolve(), + ]); + setNotice("Workspace repository updated and validated."); } catch (error) { - const safe = asWorkspaceApiError(error); - setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "git_unavailable: Registry pull could not be completed"]); + setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]); + } finally { + setBusyAction(undefined); } } - async function validateSelectedWorkspace() { - if (!record) return; - setNotice(undefined); - setDiagnostics([]); + async function validateSource() { + if (!detailQuery.data) return; + setBusyAction("validate"); + clearMessages(); try { - await validateWorkspace(record.workspace); - setNotice("Workspace definition is valid."); + await validateWorkspace(detailQuery.data.workspace); + setNotice("Workspace source is valid."); } catch (error) { - const safe = asWorkspaceApiError(error); - setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]); + setDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]); + } finally { + setBusyAction(undefined); } } - async function testSelectedWorkspace() { - if (!record) return; - setNotice(undefined); - setDiagnostics([]); + async function testConnections() { + if (!selectedId) return; + setBusyAction("test"); + clearMessages(); try { - const result = await testWorkspace(record.workspace.workspace.id); - setDiagnostics(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`)); + const result = await testWorkspace(selectedId); + setDiagnostics(result.diagnostics.map(({ code, message }) => `${code}: ${message}`)); if (result.diagnostics.length === 0) { - setNotice(result.activatable ? "Installation test passed." : "Installation test completed."); + setNotice(result.activatable + ? "Workspace connections are valid." + : "Workspace connection test completed."); } } catch (error) { - const safe = asWorkspaceApiError(error); - setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "connector_unavailable: Installation test could not be completed"]); - } - } - - async function downloadBundle() { - if (!record) return; - setTransferring(true); - setNotice(undefined); - setDiagnostics([]); - try { - const bundle = await exportWorkspace(record.workspace.workspace.id); - const url = URL.createObjectURL(bundle); - const link = document.createElement("a"); - link.href = url; - link.download = `${record.workspace.workspace.id}.zip`; - link.click(); - URL.revokeObjectURL(url); - setNotice("Workspace bundle downloaded."); - } catch (error) { - const safe = asWorkspaceApiError(error); - setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be exported"]); + setDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]); } finally { - setTransferring(false); + setBusyAction(undefined); } } - async function importBundle(file: File | undefined) { - if (!file) return; - setTransferring(true); - setNotice(undefined); - setDiagnostics([]); + async function saveSecrets() { + if (!selectedId) 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 result = await importWorkspace(file); - const importedId = result.draft.workspace.workspace.id; - const catalog = workspaces.length > 0 ? workspaces : ((await workspacesQuery.refetch()).data ?? []); - const matchingSummary = catalog.find((workspace) => workspace.id === importedId && workspace.configurationState === "configuration_required"); - if (!matchingSummary) { - setDiagnostics(["workspace_invalid: Imported bundle can only bootstrap a matching catalog slot"]); - return; - } - const draft: WorkspaceBootstrapDraft = { - workspaceId: importedId, - baseCommit: statusQuery.data?.head ?? EMPTY_COMMIT, - workspace: result.draft.workspace, - updatedAt: new Date().toISOString(), - }; - workspaceBootstrapDrafts.save(draft); - setNotice("Imported bootstrap draft saved in this browser. Validate it before creating the descriptor."); + const configuration = await saveWorkspaceSecrets(selectedId, values); + queryClient.setQueryData( + ["workspace-runtime-configuration", selectedId], + configuration, + ); + setSecretValues({}); + await workspacesQuery.refetch(); + setNotice("Runtime secrets saved. Stored values remain hidden."); } catch (error) { - const safe = asWorkspaceApiError(error); - setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]); + setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]); } finally { - setTransferring(false); + setBusyAction(undefined); } } - function published() { - if (publishRequest) { - workspaceBootstrapDrafts.discard(publishRequest.workspace.workspace.id); - setSelectedId(publishRequest.workspace.workspace.id); - setNotice(`To change this workspace, edit ${publishRequest.workspace.workspace.id}/workspace.yaml, commit/push, then Pull.`); - } else { - setNotice("Workspace created."); + async function forgetSecret(requirementId: string) { + if (!selectedId) return; + setBusyAction(`forget:${requirementId}`); + clearMessages(); + try { + const configuration = await forgetWorkspaceSecret(selectedId, requirementId); + queryClient.setQueryData( + ["workspace-runtime-configuration", selectedId], + configuration, + ); + setSecretValues((current) => ({ ...current, [requirementId]: "" })); + await workspacesQuery.refetch(); + setNotice("Stored secret forgotten."); + } catch (error) { + setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]); + } finally { + setBusyAction(undefined); } - setLocalDraft(undefined); - setPublishRequest(undefined); - setDiagnostics([]); - void Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]).then(() => detailQuery.refetch()); } - const titleReady = workspacesQuery.isSuccess || workspacesQuery.isError; + 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) onClose(); }}> - - - {titleReady ? "Workspace management" : ""} - {titleReady ? "Draft bootstrap-only workspace definitions locally. Existing published descriptors stay read-only." : ""} + { 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}

)}
} +
+ {notice && ( +

+ {notice} +

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

+ {diagnostic} +

+ ))} +
+ )} - {!selectedSummary && !workspacesQuery.isLoading && !workspacesQuery.isError &&

Select a workspace

Review an existing definition or bootstrap a configuration-required slot.

} - - {selectedSummary?.configurationState === "configuration_required" && activeBootstrapDraft && ( - <> -
-

Bootstrap workspace

-

{selectedSummary.displayName}

+ {!selectedSummary ? ( +
+
+

Level 1 · Repository

+

How workspaces reach ThothII

+
+
    +
  1. Prepare the workspace source in its own directory. It must contain workspace.yaml and every required subdirectory, including any versioned Evidence files.
  2. +
  3. Publish that source by committing and pushing it to a repository hosted by a Git server such as GitHub, GitLab, or Gitea.
  4. +
  5. The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads {repositoryLabel} on branch {statusQuery.data?.branch ?? "main"}.
  6. +
  7. 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.
  8. +
+
+
+
+

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.

+
+ +
+
+

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

+
+ ) : ( +
+
+

Level 2 · Selected workspace

+

{selectedSummary.displayName}

{selectedSummary.id}

- - - )} - {selectedSummary?.configurationState === "ready" && ( - detailQuery.isError ? { void detailQuery.refetch(); }} /> : detailQuery.isLoading || !record ?

Loading workspace definition…

: ( - <> -
+ {(detailQuery.isLoading || runtimeQuery.isLoading) &&

Loading workspace configuration…

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

Workspace definition

-

{record.workspace.workspace.name}

-

{record.workspace.workspace.id}

+

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}
+
Data warehouse
{detailQuery.data.workspace.dwh.engine} · {detailQuery.data.workspace.dwh.database}/{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.

+ +
+
+

Test workspace connections

+

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

+ +
-
- - - ) + +
+
+ +
+

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