diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index 2b547930..89e5ec1d 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -1,7 +1,17 @@ import { expect, test } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; -import { importWorkspace } from "./workspaces"; +import { asWorkspaceConflict, importWorkspace, publishWorkspace, type CanonicalWorkspace } from "./workspaces"; + +const workspace: CanonicalWorkspace = { + workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" }, + dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", 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: { allowed: ["zai/glm-5.2"] }, +}; test("uploads a workspace bundle without JSON content type", async () => { let contentType: string | null = null; @@ -17,3 +27,14 @@ test("uploads a workspace bundle without JSON content type", async () => { // that header untouched; a real browser adds multipart/form-data + boundary. expect(contentType ?? "").not.toMatch(/application\/json/i); }); + +test("rejects a conflict payload that attempts to surface a secret field", async () => { + server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ + code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"], + base: { ...workspace, dwh: { ...workspace.dwh, password: "secret" } }, local: workspace, remote: workspace, + }, { status: 409 }))); + + const error = await publishWorkspace({ action: "update", workspace, baseCommit: "a".repeat(40), baseBlob: "b".repeat(40) }).catch((cause: unknown) => cause); + + expect(asWorkspaceConflict(error)).toBeUndefined(); +}); diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index db4067a1..735d586d 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -1,4 +1,5 @@ import { ApiError, apiFetch, apiFetchBlob } from "./client"; +import { sanitizeCanonicalWorkspace } from "../workspaces/drafts"; export type WorkspaceErrorCode = | "workspace_invalid" | "binding_missing" | "workspace_not_activatable" @@ -126,6 +127,16 @@ export interface WorkspaceApiError { fields?: string[]; } +const conflictFields = new Set([ + "workspace.name", "workspace.description", "workspace.language", + "dwh.database", "dwh.schema", "dwh.port", "dwh.timeout_ms", "dwh.supported_transports", + "semantic_index.vector_store.database", "semantic_index.vector_store.schema", "semantic_index.vector_store.collection", + "semantic_index.vector_store.dimensions", "semantic_index.vector_store.distance", "semantic_index.vector_store.port", + "semantic_index.vector_store.timeout_ms", "semantic_index.vector_store.supported_transports", + "semantic_index.embedding.provider", "semantic_index.embedding.model", "semantic_index.embedding.dimensions", + "semantic_index.embedding.timeout_ms", "semantic_index.vector_writer", "llm_policy.default", "llm_policy.allowed", +]); + const workspaceErrorCodes = new Set([ "workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale", "workspace_conflict", "git_unavailable", "git_auth_failed", "git_non_fast_forward", @@ -159,13 +170,16 @@ export function asWorkspaceConflict(error: unknown): WorkspaceConflict | undefin if (safe?.code !== "workspace_conflict") return undefined; const payload = object((error as ApiError).payload); const fields = safe.fields; - if (!payload || !fields || !object(payload.base) || !object(payload.local) || !object(payload.remote)) return undefined; + const base = payload && sanitizeCanonicalWorkspace(payload.base); + const local = payload && sanitizeCanonicalWorkspace(payload.local); + const remote = payload && sanitizeCanonicalWorkspace(payload.remote); + if (!payload || !fields || !fields.every((field) => conflictFields.has(field)) || !base || !local || !remote) return undefined; return { code: "workspace_conflict", fields, - base: payload.base as CanonicalWorkspace, - local: payload.local as CanonicalWorkspace, - remote: payload.remote as CanonicalWorkspace, + base, + local, + remote, }; } diff --git a/frontend/src/shell/WorkspaceEditor.tsx b/frontend/src/shell/WorkspaceEditor.tsx index 32b29d41..7f540e5c 100644 --- a/frontend/src/shell/WorkspaceEditor.tsx +++ b/frontend/src/shell/WorkspaceEditor.tsx @@ -113,7 +113,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode 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) { +export function WorkspaceEditor({ draft, onSaveDraft, 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]); @@ -144,6 +144,17 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish: _onPublish, idL }); } + function publishDraft() { + const nextErrors = validate(workspace); + setErrors(nextErrors); + if (Object.keys(nextErrors).length) return; + const baseCommit = draft?.baseCommit ?? EMPTY_COMMIT; + const request: PublishWorkspaceRequest = draft?.baseBlob + ? { action: "update", workspace, baseCommit, baseBlob: draft.baseBlob } + : { action: "create", workspace, baseCommit }; + void onPublish(request); + } + return (
{ event.preventDefault(); saveDraft(); }} noValidate>
@@ -228,8 +239,9 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish: _onPublish, idL

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 index 549134ce..c3c7d8fe 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -2,7 +2,7 @@ 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 { afterEach, beforeEach, expect, test, vi } from "vitest"; import { server } from "../test/msw"; import { WorkspaceManager } from "./WorkspaceManager"; @@ -38,6 +38,8 @@ beforeEach(() => { ); }); +afterEach(() => vi.unstubAllGlobals()); + test("lists registry workspaces and saves a new workspace only as a browser draft", async () => { const user = userEvent.setup(); renderManager(); @@ -53,6 +55,56 @@ test("lists registry workspaces and saves a new workspace only as a browser draf expect(localStorage.getItem("thothii.workspace-registry.v1.draft.trial-registry")).not.toBeNull(); }); +test("imports a bundle as a local draft and never publishes it automatically", async () => { + const user = userEvent.setup(); + const publishSpy = vi.fn(); + server.use( + http.post("http://localhost:8787/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })), + http.post("http://localhost:8787/workspaces/publish", () => { + publishSpy(); + return HttpResponse.json({}); + }), + ); + renderManager(); + + await user.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" })); + + expect(await screen.findByText("Imported draft saved in this browser. Validate it before publishing.")).toBeVisible(); + expect(publishSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).not.toBeNull(); +}); + +test("pulls and exports only when the manager explicitly requests each action", async () => { + const user = userEvent.setup(); + const publishSpy = vi.fn(); + 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("http://localhost:8787/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })), + http.get("http://localhost:8787/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))), + http.post("http://localhost:8787/workspaces/publish", () => { + publishSpy(); + return HttpResponse.json({}); + }), + ); + renderManager(); + + await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); + 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)); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:workspace-bundle"); + expect(publishSpy).not.toHaveBeenCalled(); +}); + test("stages duplicate and delete operations without publishing", async () => { const user = userEvent.setup(); let published = false; diff --git a/frontend/src/shell/WorkspaceManager.tsx b/frontend/src/shell/WorkspaceManager.tsx index f436edd2..0218b7b6 100644 --- a/frontend/src/shell/WorkspaceManager.tsx +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -1,14 +1,16 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, CheckCircle2, ClipboardCheck, FlaskConical, Plus, Trash2, Copy, X } from "lucide-react"; +import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Plus, Trash2, Copy, Upload, X } from "lucide-react"; import { - asWorkspaceApiError, getWorkspace, getWorkspaceRegistryStatus, listWorkspaces, testWorkspace, - validateWorkspace, type CanonicalWorkspace, type PublishWorkspaceRequest, type WorkspaceRecord, + asWorkspaceApiError, exportWorkspace, getWorkspace, getWorkspaceRegistryStatus, importWorkspace, + listWorkspaces, pullWorkspaceRegistry, testWorkspace, validateWorkspace, type CanonicalWorkspace, + type PublishWorkspaceRequest, type WorkspaceRecord, type WorkspaceRevision, } 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"; +import { WorkspacePublishDialog } from "./WorkspacePublishDialog"; const EMPTY_COMMIT = "0".repeat(40); @@ -55,6 +57,8 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () const [notice, setNotice] = useState(); const [diagnostics, setDiagnostics] = useState([]); const [deletionDraft, setDeletionDraft] = useState(); + const [publishRequest, setPublishRequest] = useState(); + const [transferring, setTransferring] = useState(false); const statusQuery = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open }); const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open }); const detailQuery = useQuery({ @@ -79,6 +83,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () setDeletionDraft(undefined); setNotice(undefined); setDiagnostics([]); + setPublishRequest(undefined); } function createWorkspace() { @@ -88,6 +93,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () setDeletionDraft(undefined); setNotice("New draft. Choose its immutable workspace ID before saving."); setDiagnostics([]); + setPublishRequest(undefined); } function duplicateWorkspace() { @@ -103,6 +109,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () setLocalDraft(duplicate); setDeletionDraft(undefined); setNotice("Duplicate draft. Give it a new immutable workspace ID before publishing."); + setPublishRequest(undefined); } function saveDraft(draft: WorkspaceDraft) { @@ -112,6 +119,121 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () setNotice("Draft saved in this browser."); } + function requestPublish(request: PublishWorkspaceRequest) { + if (request.action !== "delete") { + const nextDraft: WorkspaceDraft = { + workspaceId: request.workspace.workspace.id, + baseCommit: request.baseCommit, + ...(request.action === "update" ? { baseBlob: request.baseBlob } : {}), + workspace: request.workspace, + updatedAt: new Date().toISOString(), + }; + workspaceDrafts.save(nextDraft); + setLocalDraft(nextDraft); + setSelectedId(nextDraft.workspaceId); + } + setNotice(undefined); + setDiagnostics([]); + setPublishRequest(request); + } + + function requestDeletionPublish() { + if (!activeDeletionDraft) return; + requestPublish({ action: "delete", id: activeDeletionDraft.id, baseCommit: activeDeletionDraft.baseCommit, baseBlob: activeDeletionDraft.baseBlob }); + } + + async function pullLatest() { + setNotice(undefined); + setDiagnostics([]); + try { + await pullWorkspaceRegistry(); + await Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]); + setNotice("Registry updated. Reload a workspace to review its latest revision."); + } catch (error) { + const safe = asWorkspaceApiError(error); + setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "git_unavailable: Registry pull could not be completed"]); + throw error; + } + } + + function reloadWorkspace() { + if (selectedId) { + workspaceDrafts.discard(selectedId); + workspaceDeletionDrafts.discard(selectedId); + } + setLocalDraft(undefined); + setDeletionDraft(undefined); + setPublishRequest(undefined); + setNotice("Workspace reloaded from the registry. Your prior browser draft was discarded."); + setDiagnostics([]); + void detailQuery.refetch(); + } + + async function importBundle(file: File | undefined) { + if (!file) return; + setTransferring(true); + setNotice(undefined); + setDiagnostics([]); + try { + const result = await importWorkspace(file); + const imported: WorkspaceDraft = { + workspaceId: result.draft.workspace.workspace.id, + baseCommit: EMPTY_COMMIT, + workspace: result.draft.workspace, + updatedAt: new Date().toISOString(), + }; + workspaceDrafts.save(imported); + setSelectedId(imported.workspaceId); + setLocalDraft(imported); + setDeletionDraft(undefined); + setPublishRequest(undefined); + setNotice("Imported draft saved in this browser. Validate it before publishing."); + } catch (error) { + const safe = asWorkspaceApiError(error); + setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]); + } finally { + setTransferring(false); + } + } + + 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"]); + } finally { + setTransferring(false); + } + } + + function published(revision: WorkspaceRevision | undefined) { + const publishedRequest = publishRequest; + if (publishedRequest?.action === "delete") { + workspaceDeletionDrafts.discard(publishedRequest.id); + setSelectedId(undefined); + } else if (publishedRequest) { + workspaceDrafts.discard(publishedRequest.workspace.workspace.id); + setSelectedId(publishedRequest.workspace.workspace.id); + } + setLocalDraft(undefined); + setDeletionDraft(undefined); + setPublishRequest(undefined); + setNotice(revision ? `Published revision ${revision.commit.slice(0, 12)}.` : "Workspace published."); + void Promise.all([statusQuery.refetch(), workspacesQuery.refetch(), detailQuery.refetch()]); + } + async function validateCurrent() { if (!currentDraft) return; setNotice(undefined); @@ -160,6 +282,8 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
+ {publishRequest && { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} onPull={pullLatest} onReload={reloadWorkspace} />} ); } diff --git a/frontend/src/shell/WorkspacePublishDialog.test.tsx b/frontend/src/shell/WorkspacePublishDialog.test.tsx new file mode 100644 index 00000000..03883cba --- /dev/null +++ b/frontend/src/shell/WorkspacePublishDialog.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { beforeEach, expect, test, vi } from "vitest"; +import type { CanonicalWorkspace, PublishWorkspaceRequest, WorkspaceConflict } from "../api/workspaces"; +import { server } from "../test/msw"; +import { WorkspacePublishDialog } from "./WorkspacePublishDialog"; + +const workspace: CanonicalWorkspace = { + workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" }, + dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", 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: { allowed: ["zai/glm-5.2"] }, +}; + +const request: PublishWorkspaceRequest = { + action: "update", workspace, baseCommit: "a".repeat(40), baseBlob: "b".repeat(40), +}; + +const conflict: WorkspaceConflict = { + code: "workspace_conflict", + fields: ["semantic_index.embedding.model"], + base: workspace, + local: { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "local-model" } } }, + remote: { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } }, +}; + +beforeEach(() => { + server.use(http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} }))); +}); + +test("validates a draft and requires a separate confirmation before publishing", async () => { + const user = userEvent.setup(); + const published = vi.fn(); + let publishCalls = 0; + server.use(http.post("http://localhost:8787/workspaces/publish", () => { + publishCalls += 1; + return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe", state: "operational" } }); + })); + render(); + + expect(screen.getByRole("button", { name: "Publish" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Validate draft" })); + expect(await screen.findByText("Workspace definition is valid." )).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Publish" })); + expect(screen.getByRole("heading", { name: "Confirm publication" })).toBeVisible(); + expect(publishCalls).toBe(0); + await user.click(screen.getByRole("button", { name: "Confirm publish" })); + + await waitFor(() => expect(published).toHaveBeenCalledTimes(1)); + expect(publishCalls).toBe(1); +}); + +test("shows a field-level conflict and never overwrites the remote workspace", async () => { + const user = userEvent.setup(); + let published = false; + server.use(http.post("http://localhost:8787/workspaces/publish", () => { + published = true; + return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 }); + })); + render(); + + await user.click(screen.getByRole("button", { name: "Validate draft" })); + await user.click(await screen.findByRole("button", { name: "Publish" })); + await user.click(screen.getByRole("button", { name: "Confirm publish" })); + + expect(await screen.findByText("semantic_index.embedding.model")).toBeVisible(); + expect(screen.getByText("local-model")).toBeVisible(); + expect(screen.getByText("remote-model")).toBeVisible(); + expect(screen.getByRole("button", { name: "Pull latest registry" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Reload workspace" })).toBeVisible(); + expect(screen.queryByRole("button", { name: /use local|use remote|confirm publish/i })).not.toBeInTheDocument(); + expect(published).toBe(true); +}); + +test("keeps a conflict open and redacts a failed registry pull", async () => { + const user = userEvent.setup(); + server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ + ...conflict, message: "Workspace changed in the registry.", + }, { status: 409 }))); + render(); + + await user.click(screen.getByRole("button", { name: "Validate draft" })); + await user.click(await screen.findByRole("button", { name: "Publish" })); + await user.click(screen.getByRole("button", { name: "Confirm publish" })); + await user.click(await screen.findByRole("button", { name: "Pull latest registry" })); + + expect(await screen.findByText("Registry pull could not be completed. Try again or reload the workspace.")).toBeVisible(); + expect(screen.queryByText(/token=secret/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reload workspace" })).toBeVisible(); +}); diff --git a/frontend/src/shell/WorkspacePublishDialog.tsx b/frontend/src/shell/WorkspacePublishDialog.tsx new file mode 100644 index 00000000..c0418764 --- /dev/null +++ b/frontend/src/shell/WorkspacePublishDialog.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { + asWorkspaceApiError, + asWorkspaceConflict, + publishWorkspace, + validateWorkspace, + type CanonicalWorkspace, + type PublishWorkspaceRequest, + type WorkspaceConflict, + type WorkspaceRevision, +} from "../api/workspaces"; +import { Button } from "../components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog"; + +export interface WorkspacePublishDialogProps { + open: boolean; + request: PublishWorkspaceRequest; + onOpenChange: (open: boolean) => void; + onPublished: (revision: WorkspaceRevision | undefined) => void; + onPull: () => Promise | void; + onReload: () => void; +} + +function valueAt(workspace: CanonicalWorkspace, path: string): string { + const value = path.split(".").reduce((current, key) => ( + current && typeof current === "object" ? (current as Record)[key] : undefined + ), workspace); + return value === undefined ? "—" : typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? String(value) + : JSON.stringify(value); +} + +function requestWithWorkspace(request: PublishWorkspaceRequest, workspace: CanonicalWorkspace): PublishWorkspaceRequest { + return request.action === "delete" ? request : { ...request, workspace }; +} + +function RevisionSummary({ request }: { request: PublishWorkspaceRequest }) { + const label = request.action === "create" ? "New workspace" : request.action === "delete" ? "Deletion" : "Workspace update"; + return

{label} · base {request.baseCommit.slice(0, 12)}

; +} + +function ConflictReview({ conflict, onPull, onReload }: { conflict: WorkspaceConflict; onPull: () => Promise | void; onReload: () => void }) { + const [pulling, setPulling] = useState(false); + const [pulled, setPulled] = useState(false); + const [pullError, setPullError] = useState(false); + + async function pull() { + setPulling(true); + setPullError(false); + try { + await onPull(); + setPulled(true); + } catch { + setPullError(true); + } finally { + setPulling(false); + } + } + + return
+
+

The registry changed before publication.

+

Your browser draft is unchanged. Pull or reload before creating a revised draft; this screen never merges or overwrites remote values.

+
+
+ {conflict.fields.map((field) =>
+

{field}

+
+
Base
{valueAt(conflict.base, field)}
+
Your draft
{valueAt(conflict.local, field)}
+
Registry
{valueAt(conflict.remote, field)}
+
+
)} +
+ {pulled &&

Latest registry state pulled. Reload the workspace before editing or publishing again.

} + {pullError &&

Registry pull could not be completed. Try again or reload the workspace.

} +
+ + +
+
; +} + +export function WorkspacePublishDialog({ open, request, onOpenChange, onPublished, onPull, onReload }: WorkspacePublishDialogProps) { + const [validatedRequest, setValidatedRequest] = useState(); + const [confirmationOpen, setConfirmationOpen] = useState(false); + const [conflict, setConflict] = useState(); + const [message, setMessage] = useState(); + const [publishing, setPublishing] = useState(false); + + useEffect(() => { + if (!open) return; + setValidatedRequest(undefined); + setConfirmationOpen(false); + setConflict(undefined); + setMessage(undefined); + setPublishing(false); + }, [open, request]); + + async function validate() { + setMessage(undefined); + setConflict(undefined); + if (request.action === "delete") { + setValidatedRequest(request); + setMessage("Deletion is pinned to the published revision."); + return; + } + try { + const result = await validateWorkspace(request.workspace); + setValidatedRequest(requestWithWorkspace(request, result.workspace)); + setMessage("Workspace definition is valid."); + } catch (error) { + const safe = asWorkspaceApiError(error); + setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"); + } + } + + async function publish() { + if (!validatedRequest) return; + setPublishing(true); + setMessage(undefined); + try { + const result = await publishWorkspace(validatedRequest); + onPublished(result?.revision); + onOpenChange(false); + } catch (error) { + const detectedConflict = asWorkspaceConflict(error); + if (detectedConflict) { + setConflict(detectedConflict); + setConfirmationOpen(false); + } else { + const safe = asWorkspaceApiError(error); + setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Publication could not be completed"); + } + } finally { + setPublishing(false); + } + } + + return + + + {conflict ? "Publication conflict" : "Publish workspace"} + {conflict ? "Compare the changed fields, then pull and reload before revising your draft." : "Validation and an explicit confirmation are required before this shared definition is published."} + +
+ {conflict ? : <> + + {message &&

{message}

} +
+ + +
+ } +
+ +
+ + + Confirm publicationThis publishes the validated workspace definition to the shared Git registry. + + + + +
; +} diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index a2094699..25a91367 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -250,7 +250,8 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined { return diagnostics; } -function copyWorkspace(value: unknown): CanonicalWorkspace | undefined { +/** Drops unknown fields before a server response can become a browser draft or conflict view. */ +export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined { const source = exactRecord(value, ["workspace", "dwh", "semantic_index", "llm_policy", "diagnostics"]); const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]); const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]); @@ -330,7 +331,7 @@ function copyWorkspace(value: unknown): CanonicalWorkspace | undefined { function normalize(value: unknown): WorkspaceDraft | undefined { const source = exactRecord(value, ["workspaceId", "baseCommit", "baseBlob", "workspace", "updatedAt"]); - const workspace = copyWorkspace(source?.workspace); + const workspace = sanitizeCanonicalWorkspace(source?.workspace); const id = workspaceId(source?.workspaceId); const baseCommit = typeof source?.baseCommit === "string" && /^[0-9a-f]{40}$/.test(source.baseCommit) ? source.baseCommit : undefined; const baseBlob = source?.baseBlob === undefined ? undefined : typeof source.baseBlob === "string" && /^[0-9a-f]{40}$/.test(source.baseBlob) ? source.baseBlob : undefined; diff --git a/task-10-report.md b/task-10-report.md new file mode 100644 index 00000000..4b5688b5 --- /dev/null +++ b/task-10-report.md @@ -0,0 +1,43 @@ +# Task 10 — Workspace Registry Manager Publish UX + +## Delivered + +- Added a typed `WorkspacePublishDialog` with an explicit two-stage flow: validate the + canonical draft, then confirm publication. The dialog displays the action and pinned base + revision before a request can be sent. +- Connected the workspace editor's Publish action and staged deletion action to that dialog; + local browser drafts remain local until the explicit confirmation. +- Added registry pull, workspace bundle import, and Blob-URL export controls. Imports are saved + as browser-only drafts and never publish automatically; export URLs are revoked after download. +- Added field-level 409 conflict presentation with base, local, and registry values. The only + recovery actions are Pull latest registry and Reload workspace; no automatic merge, overwrite, + or re-publication occurs. +- Kept diagnostics user-initiated and restricted UI/API draft data to canonical workspace fields. + Conflict payloads now pass through the canonical draft sanitizer and reject unknown/secret + fields before rendering. + +## TDD evidence + +- Wrote the publish-dialog and manager import/export tests before the implementation and observed + the expected RED failures (missing dialog/import control). +- Added a regression test for conflict payloads containing a secret field and observed it fail + before wiring the conflict parser through the canonical sanitizer. +- Added a regression test for a failed pull during conflict recovery and observed the original + unhandled rejection before adding the redacted in-dialog error state. + +## Verification + +Run in `frontend/` after the final changes: + +```text +npx vitest run src/shell/WorkspacePublishDialog.test.tsx src/api/workspaces.test.ts src/shell/WorkspaceManager.test.tsx +# 3 files passed, 15 tests passed + +npx tsc -b +# exit 0 +``` + +```text +npx vitest run +# 51 files passed, 370 tests passed +```