diff --git a/.superpowers/sdd/2026-08-03-git-workspace-registry/task-9-report.md b/.superpowers/sdd/2026-08-03-git-workspace-registry/task-9-report.md new file mode 100644 index 00000000..7fd1deba --- /dev/null +++ b/.superpowers/sdd/2026-08-03-git-workspace-registry/task-9-report.md @@ -0,0 +1,40 @@ +# Task 9 report — Workspace Management CRUD page + +## Delivered + +- Added the Workspace management dialog, launched from the persistent right sidebar and the + Model activity header without touching live-session/SSE state. +- Added a workspace list/detail editor for General, DWH, Semantic index, LLM policy, + Installation requirements, and Git status/history. +- Added browser-only New, Edit, Duplicate, Save draft, and Delete-draft workflows. A deletion + draft stores only ID and immutable revision references; publication remains a Task 10 action. +- Used closed native controls for languages, engines, transports, distance metrics, embedding + providers, and selectable default models. Free values have client-side, accessible errors. +- Made semantic-index dimensions atomic: one editor field always writes the same value to the + vector-store and embedding contracts. +- Added Validate and Test-on-this-installation actions. They display sanitized code/message + diagnostics only; neither action exposes or stores credentials, secrets, or raw response bodies. +- Explicitly excluded publish, pull, import, and export user flows from this task. + +## TDD evidence + +- RED: `npx vitest run src/shell/WorkspaceManager.test.tsx src/shell/WorkspaceEditor.test.tsx` + failed because the manager and editor modules did not exist. +- GREEN: focused manager/editor/AppShell coverage passed after the implementation. +- RED: a deletion-draft persistence regression failed with + `Cannot read properties of undefined (reading 'save')` before the sanitized draft store was added. +- GREEN: the draft-store and manager tests passed once deletion intent persisted locally. + +## Verification + +Executed from `frontend/`: + +```text +npx vitest run +50 test files passed, 358 tests passed +npx tsc -b +exit 0 +``` + +`git diff --check` passed before commit. No workspace secret value, secret-file path, raw +diagnostic body, publish call, import flow, or export flow was introduced. diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index 407c434a..375e9b83 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -18,6 +18,7 @@ beforeEach(() => { (globalThis as any).EventSource = FakeEventSource; useSessionStore.getState().resetSession(); server.use( + http.get("http://localhost:8787/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })), http.get("http://localhost:8787/sessions", () => HttpResponse.json([])), http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })), @@ -148,6 +149,19 @@ test("model selector shows the three Pi-enabled models and stores the selected p })); }); +test("opens Workspace management from the right sidebar without interrupting the shell", async () => { + server.use( + http.get("http://localhost:8787/workspace-registry/status", () => + HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })), + ); + renderShell(); + + await userEvent.click(screen.getByRole("button", { name: "Workspace management" })); + + expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible(); + expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed"); +}); + test("marks the composer as awaiting input for a pending freetext gate", () => { useSessionStore.setState({ diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 6a35646d..295a1dcc 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -3,6 +3,7 @@ import { useSessionStore } from "../store/sessionStore"; import { WidgetHost } from "./WidgetHost"; import { CentralStatus } from "./CentralStatus"; import { ModelActivityPanel } from "./ModelActivityPanel"; +import { WorkspaceManager } from "./WorkspaceManager"; import { useActivityPanelResize } from "./useActivityPanelResize"; import { useSessionPanelResize } from "./useSessionPanelResize"; import { NavSessions } from "./NavSessions"; @@ -99,6 +100,7 @@ export function AppShell() { const queryClient = useQueryClient(); const [showActivity, setShowActivity] = useState(false); + const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false); const [activeOpen, setActiveOpen] = useState(true); const [archiveOpen, setArchiveOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); @@ -492,7 +494,7 @@ export function AppShell() { )} - {showActivity && setShowActivity(false)} />} + {showActivity && setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} />} {showActivity && desktopSplit && (
New session +
{principal?.isAdmin && ( @@ -758,6 +768,7 @@ export function AppShell() {
)} + setWorkspaceManagerOpen(false)} /> diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index da5f39d1..08db885e 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -1,5 +1,5 @@ import { useLayoutEffect, useRef } from "react"; -import { X } from "lucide-react"; +import { Settings2, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { ActivityEntry, ActivityKind } from "../api/types"; @@ -61,9 +61,11 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) { export function ModelActivityPanel({ desktopSplit = false, onClose, + onOpenWorkspaceManager, }: { desktopSplit?: boolean; onClose: () => void; + onOpenWorkspaceManager?: () => void; }) { const activityLog = useSessionStore((s) => s.activityLog); const visibleActivity = activityLog.filter(isVisibleModelActivity); @@ -82,11 +84,16 @@ export function ModelActivityPanel({ ? "static z-auto flex min-w-0 w-[var(--activity-panel-width)] shrink-0 flex-col border-r-0 bg-sidebar" : "fixed inset-y-0 left-0 z-30 flex min-w-0 w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar" }> -
+

Model activity

- +
+ + +
{ + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("listbox", { name: "DWH transport" })).toHaveTextContent("postgres_direct"); + await user.clear(screen.getByLabelText("DWH port")); + await user.type(screen.getByLabelText("DWH port"), "70000"); + await user.click(screen.getByRole("button", { name: "Save draft" })); + + expect(screen.getByRole("alert")).toHaveTextContent("Port must be between 1 and 65535"); + expect(screen.getByLabelText("DWH port")).toHaveAttribute("aria-invalid", "true"); +}); + +test("keeps vector dimensions and embedding dimensions atomic in a draft", async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(); + render(); + + await user.clear(screen.getByLabelText("Semantic index dimensions")); + await user.type(screen.getByLabelText("Semantic index dimensions"), "1024"); + await user.click(screen.getByRole("button", { name: "Save draft" })); + + expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({ + workspace: expect.objectContaining({ + semantic_index: expect.objectContaining({ + vector_store: expect.objectContaining({ dimensions: 1024 }), + embedding: expect.objectContaining({ dimensions: 1024 }), + }), + }), + })); +}); + +test("uses native closed selects for each workspace enum and embedding provider", () => { + render(); + + expect(screen.getByRole("combobox", { name: "Workspace language" })).toHaveValue("en"); + expect(screen.getByRole("combobox", { name: "Vector distance" })).toHaveValue("cosine"); + expect(screen.getByRole("combobox", { name: "Embedding provider" })).toHaveValue("ollama_compatible"); + expect(screen.getByRole("listbox", { name: "DWH transport" })).toHaveProperty("multiple", true); + expect(screen.getByRole("listbox", { name: "Vector transport" })).toHaveProperty("multiple", true); +}); diff --git a/frontend/src/shell/WorkspaceEditor.tsx b/frontend/src/shell/WorkspaceEditor.tsx new file mode 100644 index 00000000..97b1de12 --- /dev/null +++ b/frontend/src/shell/WorkspaceEditor.tsx @@ -0,0 +1,234 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { CanonicalWorkspace, PublishWorkspaceRequest } from "../api/workspaces"; +import type { WorkspaceDraft } from "../workspaces/drafts"; +import { Button } from "../components/ui/button"; + +type FieldErrors = Record; + +export interface WorkspaceEditorProps { + draft?: WorkspaceDraft; + onSaveDraft: (draft: WorkspaceDraft) => void; + /** Reserved for Task 10; saving a draft never publishes it. */ + onPublish: (request: PublishWorkspaceRequest) => Promise; + idLocked?: boolean; +} + +const EMPTY_COMMIT = "0".repeat(40); + +function emptyWorkspace(): CanonicalWorkspace { + return { + workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" }, + dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] }, + semantic_index: { + vector_store: { + engine: "pgvector", database: "vectors", schema: "public", collection: "documents", + dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"], + }, + embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 }, + }, + llm_policy: { allowed: ["zai/glm-5.2"] }, + }; +} + +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; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.database)) errors["vector.database"] = "Use a database identifier"; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.schema)) errors["vector.schema"] = "Use a schema identifier"; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a collection identifier"; + if (!workspace.semantic_index.vector_store.supported_transports.length) errors["vector.transport"] = "Choose at least one vector transport"; + const vectorPort = positiveInteger(workspace.semantic_index.vector_store.port, "Vector port", 65_535); + if (vectorPort) errors["vector.port"] = vectorPort; + const dimensions = positiveInteger(workspace.semantic_index.vector_store.dimensions, "Dimensions", 32_768); + if (dimensions) errors["semantic.dimensions"] = dimensions; + if (workspace.semantic_index.embedding.dimensions !== workspace.semantic_index.vector_store.dimensions) { + errors["semantic.dimensions"] = "Vector and embedding dimensions must match"; + } + if (!workspace.semantic_index.embedding.model.trim()) errors["embedding.model"] = "Embedding model is required"; + 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: React.ChangeEvent): string[] { + return Array.from(event.currentTarget.selectedOptions, (option) => option.value); +} + +function numberOrUndefined(value: string): number | undefined { + return value.trim() === "" ? undefined : Number(value); +} + +function Field({ + label, error, children, hint, +}: { + label: string; + error?: string; + hint?: string; + children: (props: { id: string; describedBy?: string; invalid: boolean }) => React.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: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +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"; + +export function WorkspaceEditor({ draft, onSaveDraft, onPublish: _onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) { + const [workspace, setWorkspace] = useState(draft?.workspace ?? emptyWorkspace()); + const [errors, setErrors] = useState({}); + const allowedModels = useMemo(() => workspace.llm_policy.allowed.join(", "), [workspace.llm_policy.allowed]); + + useEffect(() => { + setWorkspace(draft?.workspace ?? emptyWorkspace()); + setErrors({}); + }, [draft]); + + function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) { + setWorkspace((previous) => { + const next = change(previous); + setErrors(validate(next)); + return next; + }); + } + + function saveDraft() { + const nextErrors = validate(workspace); + setErrors(nextErrors); + if (Object.keys(nextErrors).length) return; + onSaveDraft({ + workspaceId: workspace.workspace.id, + baseCommit: draft?.baseCommit ?? EMPTY_COMMIT, + ...(draft?.baseBlob ? { baseBlob: draft.baseBlob } : {}), + workspace, + updatedAt: new Date().toISOString(), + }); + } + + 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 }) => } + + + {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, database: event.target.value } } }))} />} + + + {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, schema: event.target.value } } }))} />} + + + {({ 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 }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, port: numberOrUndefined(event.target.value) } } }))} />} + + + {({ id, describedBy, invalid }) => } + + + {({ id, describedBy, invalid }) => update((value) => { const dimensions = numberOrUndefined(event.target.value) ?? 0; return { ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, dimensions }, embedding: { ...value.semantic_index.embedding, dimensions } } }; })} />} + + + {({ id, describedBy, invalid }) => } + + + {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, model: event.target.value } } }))} />} + +
+ +
+ + {({ 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 }) => } + +
+ +
+

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

+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx new file mode 100644 index 00000000..58b3f6c6 --- /dev/null +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -0,0 +1,92 @@ +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 { beforeEach, expect, test } from "vitest"; +import { server } from "../test/msw"; +import { WorkspaceManager } from "./WorkspaceManager"; + +const workspace = { + workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" }, + dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432, supported_transports: ["postgres_direct"] }, + semantic_index: { + vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "clinical", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] }, + embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 }, + }, + llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, +} as const; + +function renderManager() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( undefined} />); +} + +beforeEach(() => { + localStorage.clear(); + server.use( + http.get("http://localhost:8787/workspace-registry/status", () => + HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })), + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data", + language: "en", file: "workspaces/psd-clinical.yaml", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, + }])), + http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ + workspace, + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, + })), + ); +}); + +test("lists registry workspaces and saves a new workspace only as a browser draft", async () => { + const user = userEvent.setup(); + renderManager(); + + expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible(); + expect(await screen.findByRole("button", { name: "PSD Clinical" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.clear(screen.getByLabelText("Workspace ID")); + await user.type(screen.getByLabelText("Workspace ID"), "trial-registry"); + 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.v1.draft.trial-registry")).not.toBeNull(); +}); + +test("stages duplicate and delete operations without publishing", async () => { + const user = userEvent.setup(); + let published = false; + server.use(http.post("http://localhost:8787/workspaces/publish", () => { + published = true; + return HttpResponse.json({}); + })); + renderManager(); + + await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); + await user.click(screen.getByRole("button", { name: "Duplicate workspace" })); + expect(screen.getByLabelText("Workspace ID")).not.toBeDisabled(); + await user.click(screen.getByRole("button", { name: "PSD Clinical" })); + await user.click(screen.getByRole("button", { name: "Delete workspace" })); + + expect(screen.getByText("Deletion draft staged locally.")).toBeVisible(); + expect(published).toBe(false); +}); + +test("runs validation and installation test with only sanitized messages", async () => { + const user = userEvent.setup(); + server.use( + http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })), + http.post("http://localhost:8787/workspaces/psd-clinical/test", () => HttpResponse.json({ + activatable: false, + diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }], + })), + ); + renderManager(); + + await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); + 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(); +}); diff --git a/frontend/src/shell/WorkspaceManager.tsx b/frontend/src/shell/WorkspaceManager.tsx new file mode 100644 index 00000000..27efadbe --- /dev/null +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -0,0 +1,200 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle, CheckCircle2, ClipboardCheck, FlaskConical, Plus, Trash2, Copy, X } from "lucide-react"; +import { + asWorkspaceApiError, getWorkspace, getWorkspaceRegistryStatus, listWorkspaces, testWorkspace, + validateWorkspace, type CanonicalWorkspace, type PublishWorkspaceRequest, type WorkspaceRecord, +} from "../api/workspaces"; +import { workspaceDeletionDrafts, workspaceDrafts, type WorkspaceDeletionDraft, type WorkspaceDraft } from "../workspaces/drafts"; +import { Button } from "../components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; +import { WorkspaceEditor } from "./WorkspaceEditor"; + +const EMPTY_COMMIT = "0".repeat(40); + +function newWorkspace(): CanonicalWorkspace { + return { + workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" }, + dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] }, + semantic_index: { + vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "documents", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] }, + embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 }, + }, + llm_policy: { allowed: ["zai/glm-5.2"] }, + }; +} + +function draftFromRecord(record: WorkspaceRecord): WorkspaceDraft { + return { + workspaceId: record.workspace.workspace.id, + baseCommit: record.revision.commit, + baseBlob: record.revision.blob, + workspace: record.workspace, + updatedAt: new Date().toISOString(), + }; +} + +function proposedId(id: string): string { + return `${id}-copy`.slice(0, 63); +} + +export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) { + const [selectedId, setSelectedId] = useState(); + const [localDraft, setLocalDraft] = useState(); + const [notice, setNotice] = useState(); + const [diagnostics, setDiagnostics] = useState([]); + const [deletionDraft, setDeletionDraft] = useState(); + const { data: status } = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open }); + const { data: workspaces = [], isLoading: workspacesLoading } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open }); + const { data: record, isLoading: recordLoading } = useQuery({ + queryKey: ["workspace", selectedId], + queryFn: () => getWorkspace(selectedId!), + enabled: Boolean(open && selectedId && !localDraft), + }); + + const savedDraft = selectedId && !localDraft ? workspaceDrafts.load(selectedId) : undefined; + const savedDeletionDraft = selectedId && !deletionDraft ? workspaceDeletionDrafts.load(selectedId) : undefined; + const currentDraft = localDraft ?? savedDraft ?? (record ? draftFromRecord(record) : undefined); + const activeDeletionDraft = deletionDraft ?? savedDeletionDraft; + const canTest = Boolean(record && currentDraft?.workspaceId === record.workspace.workspace.id); + const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]); + + function selectWorkspace(id: string) { + setSelectedId(id); + setLocalDraft(undefined); + setDeletionDraft(undefined); + setNotice(undefined); + setDiagnostics([]); + } + + function createWorkspace() { + const draft: WorkspaceDraft = { workspaceId: "new-workspace", baseCommit: EMPTY_COMMIT, workspace: newWorkspace(), updatedAt: new Date().toISOString() }; + setSelectedId(draft.workspaceId); + setLocalDraft(draft); + setDeletionDraft(undefined); + setNotice("New draft. Choose its immutable workspace ID before saving."); + setDiagnostics([]); + } + + function duplicateWorkspace() { + if (!currentDraft) return; + const id = proposedId(currentDraft.workspace.workspace.id); + const duplicate: WorkspaceDraft = { + ...currentDraft, + workspaceId: id, + workspace: { ...currentDraft.workspace, workspace: { ...currentDraft.workspace.workspace, id, name: `${currentDraft.workspace.workspace.name} copy` } }, + updatedAt: new Date().toISOString(), + }; + setSelectedId(id); + setLocalDraft(duplicate); + setDeletionDraft(undefined); + setNotice("Duplicate draft. Give it a new immutable workspace ID before publishing."); + } + + function saveDraft(draft: WorkspaceDraft) { + workspaceDrafts.save(draft); + setSelectedId(draft.workspaceId); + setLocalDraft(draft); + setNotice("Draft saved in this browser."); + } + + async function validateCurrent() { + if (!currentDraft) return; + setNotice(undefined); + setDiagnostics([]); + try { + const result = await validateWorkspace(currentDraft.workspace); + setLocalDraft({ ...currentDraft, workspace: result.workspace, updatedAt: new Date().toISOString() }); + setNotice("Workspace definition is valid."); + } catch (error) { + const safe = asWorkspaceApiError(error); + setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]); + } + } + + async function testCurrent() { + if (!record) return; + setNotice(undefined); + setDiagnostics([]); + try { + const result = await testWorkspace(record.workspace.workspace.id); + setDiagnostics(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`)); + if (result.diagnostics.length === 0) setNotice(result.activatable ? "Installation test passed." : "Installation test completed."); + } catch (error) { + const safe = asWorkspaceApiError(error); + setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "connector_unavailable: Installation test could not be completed"]); + } + } + + function stageDeletion() { + if (!currentDraft?.baseBlob || !record || currentDraft.workspaceId !== record.workspace.workspace.id) return; + const draft = { id: currentDraft.workspaceId, baseCommit: currentDraft.baseCommit, baseBlob: currentDraft.baseBlob, updatedAt: new Date().toISOString() }; + workspaceDeletionDrafts.save(draft); + setDeletionDraft(draft); + setNotice("Deletion draft staged locally."); + setDiagnostics([]); + } + + return ( + { if (!nextOpen) onClose(); }}> + + + Workspace management + Draft shared workspace definitions locally. Installation bindings and secrets stay outside this page. + + +
+ + +
+ {!currentDraft && !recordLoading &&

Select a workspace

Review an existing definition or start a browser-only draft.

} + {(currentDraft || recordLoading) && ( + <> + {recordLoading && !currentDraft ?

Loading workspace definition…

: currentDraft && <> +
+
+

Workspace definition

+

{currentDraft.workspace.workspace.name}

+

{currentDraft.workspaceId}

+
+
+ + + + +
+
+ {notice &&

{notice}

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

{diagnostic}

)}
} + {activeDeletionDraft &&

Deletion draft

The published workspace is unchanged. Publishing this staged deletion is a Task 10 action.

} + undefined} /> +
+

Git status & history

+

{status?.degraded ? "Using the last valid local snapshot." : "Registry checkout is current."}

+ {record &&

Revision {record.revision.commit.slice(0, 12)} · {record.revision.state}

} +
+ } + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/workspaces/drafts.test.ts b/frontend/src/workspaces/drafts.test.ts index 6cdf0980..b33cb607 100644 --- a/frontend/src/workspaces/drafts.test.ts +++ b/frontend/src/workspaces/drafts.test.ts @@ -1,6 +1,6 @@ import { beforeEach, expect, test } from "vitest"; import type { CanonicalWorkspace } from "../api/workspaces"; -import { workspaceDrafts, workspacePreferences } from "./drafts"; +import { workspaceDeletionDrafts, workspaceDrafts, workspacePreferences } from "./drafts"; const workspace: CanonicalWorkspace = { workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" }, @@ -42,6 +42,23 @@ test("reloads a canonical workspace draft and discards it by workspace ID", () = expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); }); +test("persists a deletion draft without retaining a workspace definition", () => { + workspaceDeletionDrafts.save({ + id: "psd-clinical", + baseCommit: "a".repeat(40), + baseBlob: "b".repeat(40), + updatedAt: "2026-08-04T10:00:00.000Z", + }); + + expect(workspaceDeletionDrafts.load("psd-clinical")).toEqual({ + id: "psd-clinical", + baseCommit: "a".repeat(40), + baseBlob: "b".repeat(40), + updatedAt: "2026-08-04T10:00:00.000Z", + }); + expect(localStorage.getItem("thothii.workspace-registry.v1.delete.psd-clinical")).not.toContain("PSD Clinical"); +}); + test("keeps valid canonical diagnostic configuration", () => { const configured = { ...workspace, diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index 25b9f649..a2094699 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -9,6 +9,14 @@ export interface WorkspaceDraft { updatedAt: string; } +/** A publishable deletion intent; it deliberately carries no workspace body. */ +export interface WorkspaceDeletionDraft { + id: string; + baseCommit: string; + baseBlob: string; + updatedAt: string; +} + export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry."; export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry."; @@ -114,6 +122,7 @@ export const workspacePolicyGate = { const PREFIX = "thothii.workspace-registry.v1"; const DRAFT_PREFIX = `${PREFIX}.draft.`; +const DELETE_DRAFT_PREFIX = `${PREFIX}.delete.`; function storage(): Storage | undefined { try { return window.localStorage; } catch { return undefined; } @@ -342,6 +351,25 @@ function key(id: string): string { return `${DRAFT_PREFIX}${encodeURIComponent(id)}`; } +function deletionKey(id: string): string { + return `${DELETE_DRAFT_PREFIX}${encodeURIComponent(id)}`; +} + +function normalizeDeletion(value: unknown): WorkspaceDeletionDraft | undefined { + const source = exactRecord(value, ["id", "baseCommit", "baseBlob", "updatedAt"]); + const id = workspaceId(source?.id); + const baseCommit = typeof source?.baseCommit === "string" && /^[0-9a-f]{40}$/.test(source.baseCommit) + ? source.baseCommit + : undefined; + const baseBlob = typeof source?.baseBlob === "string" && /^[0-9a-f]{40}$/.test(source.baseBlob) + ? source.baseBlob + : undefined; + const updatedAt = typeof source?.updatedAt === "string" && Number.isFinite(Date.parse(source.updatedAt)) + ? source.updatedAt + : undefined; + return id && baseCommit && baseBlob && updatedAt ? { id, baseCommit, baseBlob, updatedAt } : undefined; +} + /** Browser-only workspace drafts. Saving or editing one never calls the server. */ export const workspaceDrafts = { load(id: string): WorkspaceDraft | undefined { @@ -363,3 +391,25 @@ export const workspaceDrafts = { try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ } }, }; + +/** Browser-only deletion drafts. Task 10 alone may publish one. */ +export const workspaceDeletionDrafts = { + load(id: string): WorkspaceDeletionDraft | undefined { + try { + const raw = storage()?.getItem(deletionKey(id)); + return raw ? normalizeDeletion(JSON.parse(raw)) : undefined; + } catch { + return undefined; + } + }, + + save(draft: WorkspaceDeletionDraft): void { + const safe = normalizeDeletion(draft); + if (!safe) return; + try { storage()?.setItem(deletionKey(safe.id), JSON.stringify(safe)); } catch { /* storage is optional */ } + }, + + discard(id: string): void { + try { storage()?.removeItem(deletionKey(id)); } catch { /* storage is optional */ } + }, +};