From 1790d2449fbbd18f84c8f87be6452f3b83d468c2 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 11 Aug 2026 15:36:09 +0200 Subject: [PATCH] refactor: make browser workspace writes bootstrap-only --- frontend/src/api/sessions.test.ts | 29 +- frontend/src/api/workspaces.test.ts | 394 +++++------------- frontend/src/api/workspaces.ts | Bin 14585 -> 12201 bytes frontend/src/shell/NewSessionDialog.test.tsx | 11 +- frontend/src/shell/SteerInput.test.tsx | 41 +- frontend/src/shell/WorkspaceEditor.test.tsx | 159 +++---- frontend/src/shell/WorkspaceEditor.tsx | 191 +++++---- frontend/src/shell/WorkspaceManager.test.tsx | 352 ++++++---------- frontend/src/shell/WorkspaceManager.tsx | 386 ++++++++--------- .../src/shell/WorkspacePublishDialog.test.tsx | 145 ++----- frontend/src/shell/WorkspacePublishDialog.tsx | 188 ++------- frontend/src/test/workspace-fixtures.ts | 24 +- frontend/src/workspaces/drafts.test.ts | 302 ++++---------- frontend/src/workspaces/drafts.ts | 131 +++--- 14 files changed, 844 insertions(+), 1509 deletions(-) diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 355ce770..d7d1df0a 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; -import { canonicalWorkspaceFixture } from "../test/workspace-fixtures"; +import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { createSession, getMe, listSessions, prewarmRuntime, resumeSession } from "./sessions"; import { renameSession, setSessionGroup, archiveSession, unarchiveSession, @@ -15,8 +15,10 @@ test("createSession migrates legacy selections and POSTs browser preferences", a workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + ...workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + revision: workspaceRevisionFixture("psd-clinical"), + }), }])), http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("psd-clinical"), @@ -43,11 +45,11 @@ test("createSession does not POST when a selected summary aliases another worksp let posted = false; server.use( http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "other-workspace", file: "psd-clinical.yaml", - displayName: "PSD Clinical", language: "en", - revision: { - id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", - }, + ...workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + revision: workspaceRevisionFixture("psd-clinical"), + }), + name: "other-workspace", }])), http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("psd-clinical"), @@ -82,8 +84,10 @@ test.each([ let posted = false; server.use( http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", - displayName: "PSD Clinical", language: "en", revision, + ...workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + revision: revision as any, + }), }])), http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("psd-clinical"), @@ -131,7 +135,10 @@ test("createSession rejects a workspace summary that omits the canonical revisio workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", + ...workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + configurationState: "configuration_required", + }), }])), http.post("/api/sessions", async () => { posted = true; diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index 39b70247..8cacb0b7 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -1,27 +1,32 @@ import { expect, test } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; +import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { - asWorkspaceConflict, getWorkspace, importWorkspace, listWorkspaces, publishWorkspace, validateWorkspace, + asWorkspaceApiError, + getWorkspace, + importWorkspace, + listWorkspaces, + publishWorkspace, + validateWorkspace, type CanonicalWorkspace, } from "./workspaces"; -const workspace: CanonicalWorkspace = { - workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" }, - dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", 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: { allowed: ["zai/glm-5.2"] }, -}; +const workspace = canonicalWorkspaceFixture("psd-clinical"); +const revision = workspaceRevisionFixture("psd-clinical"); + +const readySummary = workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + description: "Clinical workspace", + revision, +}); const evidenceWorkspace = { ...workspace, evidence: { source: { type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", patterns: ["**/*.md"], max_bytes: 10 * 1024 * 1024, }, @@ -29,79 +34,43 @@ const evidenceWorkspace = { }, } satisfies CanonicalWorkspace; -const revision = { - id: "psd-clinical", - commit: "a".repeat(40), - blob: "b".repeat(40), - snapshotPath: "workspaces/psd-clinical.yaml", -}; +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", + }), + ]))); -const summary = { - id: "psd-clinical", - name: "psd-clinical", - file: "psd-clinical.yaml", - displayName: "PSD Clinical", - description: "Clinical workspace", - language: "en" as const, - revision, -}; - -test("decodes and normalizes state-free workspace summaries without passing through backend-only fields", async () => { - server.use(http.get("/api/workspaces", () => HttpResponse.json([{ - ...summary, workspace, backendOnly: "ignored", - }]))); - - await expect(listWorkspaces()).resolves.toEqual([summary]); -}); - -test("accepts internally multiline display names and descriptions using backend trim semantics", async () => { - const multiline = { - ...summary, - displayName: "PSD\nClinical", - description: "First line\n\tSecond line", - }; - server.use(http.get("/api/workspaces", () => HttpResponse.json([multiline]))); - - await expect(listWorkspaces()).resolves.toEqual([multiline]); + await expect(listWorkspaces()).resolves.toEqual([ + readySummary, + workspaceSummaryFixture("bootstrap-slot", { + displayName: "Bootstrap slot", + description: "Needs configuration", + configurationState: "configuration_required", + }), + ]); }); test.each([ - ["an id/name alias", { ...summary, name: "other-workspace" }], - ["a non-canonical selector file", { ...summary, file: "workspaces/psd-clinical.yaml" }], -])("rejects workspace summaries with %s", async (_case, malformedSummary) => { + ["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, + }], +])("rejects %s", async (_case, malformedSummary) => { server.use(http.get("/api/workspaces", () => HttpResponse.json([malformedSummary]))); await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary"); }); -test.each([ - ["historical state", { ...revision, state: "operational" }], - ["unknown revision field", { ...revision, generation: 1 }], - ["malformed revision", { ...revision, commit: "not-a-commit" }], -])("rejects workspace summaries with %s", async (_case, malformedRevision) => { - server.use(http.get("/api/workspaces", () => HttpResponse.json([{ - ...summary, revision: malformedRevision, - }]))); - - await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary"); -}); - -test.each([ - ["a non-array response", { ...summary }], - ["a malformed selector field", [{ ...summary, language: "fr" }]], -])("rejects %s from the workspace summary API", async (_case, response) => { - server.use(http.get("/api/workspaces", () => HttpResponse.json(response))); - - await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary"); -}); - -test("preserves a present summary without revision so callers can distinguish it from an absent workspace", async () => { - const { revision: _revision, ...incomplete } = summary; - server.use(http.get("/api/workspaces", () => HttpResponse.json([incomplete]))); - - await expect(listWorkspaces()).resolves.toEqual([incomplete]); -}); - test("uploads a workspace bundle without JSON content type", async () => { let contentType: string | null = null; server.use(http.post("/api/workspaces/import", ({ request }) => { @@ -111,13 +80,10 @@ test("uploads a workspace bundle without JSON content type", async () => { await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip", { type: "application/zip" })); - // jsdom's FormData is not the same implementation as Node's fetch FormData, - // so it cannot expose a browser-generated boundary here. The client must leave - // that header untouched; a real browser adds multipart/form-data + boundary. expect(contentType ?? "").not.toMatch(/application\/json/i); }); -test("sanitizes imported Evidence before returning a browser draft", async () => { +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: [] } }, }))); @@ -128,13 +94,21 @@ test("sanitizes imported Evidence before returning a browser draft", async () => expect(result.draft.workspace).not.toBe(evidenceWorkspace); }); -test("rejects imported Evidence with a secret-shaped field", async () => { - const malformed = { - ...evidenceWorkspace, - evidence: { ...evidenceWorkspace.evidence, signed_urls_file: "/run/secrets/urls" }, - }; +test("rejects imported filesystem Evidence that still points at workspace-content", async () => { server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ - draft: { workspace: malformed, contract: {} }, + 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"))) @@ -142,140 +116,12 @@ test("rejects imported Evidence with a secret-shaped field", async () => { }); test("accepts the atomic schema-v3 workspace revision contract without historical state", async () => { - const stateFreeRevision = { - id: "psd-clinical", - commit: "a".repeat(40), - blob: "b".repeat(40), - snapshotPath: "workspaces/psd-clinical.yaml", - }; - server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ - workspace, revision: stateFreeRevision, - }))); + server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision }))); - await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision: stateFreeRevision }); + await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision }); }); -test("rejects the removed historical workspace revision state as an extra API key", async () => { - server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ - workspace, - revision: { - id: "psd-clinical", - commit: "a".repeat(40), - blob: "b".repeat(40), - snapshotPath: "workspaces/psd-clinical.yaml", - [["st", "ate"].join("")]: "operational", - }, - }))); - - await expect(getWorkspace("psd-clinical")).rejects.toThrow("invalid workspace revision"); -}); - -test("rejects read responses with a missing or inconsistent revision", async () => { - server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ - workspace: evidenceWorkspace, - revision: { ...revision, id: "other-workspace" }, - }))); - await expect(getWorkspace("psd-clinical")).rejects.toThrow("invalid workspace revision"); - - server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ - workspace: evidenceWorkspace, revision: null, - }))); - await expect(getWorkspace("psd-clinical")).rejects.toThrow("invalid workspace revision"); -}); - -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" }, - }))); - - await expect(publishWorkspace({ - action: "update", workspace: evidenceWorkspace, - baseCommit: revision.commit, baseBlob: revision.blob, - })).rejects.toThrow("invalid workspace revision"); -}); - -test("rejects a conflict payload that attempts to surface a secret field", async () => { - server.use(http.post("/api/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(); -}); - -const diagnosticConflictFields = [ - "diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth", - "diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema", -] as const; - -const optionalDiagnosticsConflictFields = [ - "diagnostics", - "diagnostics.dwh_rest", -] as const; - -const diagnosticsWorkspace: CanonicalWorkspace = { - ...workspace, - dwh: { ...workspace.dwh, supported_transports: ["postgres_direct", "rest_api"] }, - diagnostics: { - dwh_rest: { method: "POST", path: "/rpc/ping", auth: "bearer", response: { database: "database", schema: "schema" } }, - }, -}; - -test.each(optionalDiagnosticsConflictFields)("accepts optional diagnostics conflict branch %s", async (field) => { - server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - code: "workspace_conflict", message: "Workspace changed in the registry.", fields: [field], - expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, - actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, - base: workspace, local: diagnosticsWorkspace, remote: diagnosticsWorkspace, - }, { status: 409 }))); - - const error = await publishWorkspace({ action: "update", workspace: diagnosticsWorkspace, baseCommit: "a".repeat(40), baseBlob: "b".repeat(40) }).catch((cause: unknown) => cause); - - expect(asWorkspaceConflict(error)).toMatchObject({ fields: [field] }); -}); - -test.each(diagnosticConflictFields)("accepts canonical diagnostic conflict leaf %s with its remote revision", async (field) => { - const diagnosticsWorkspace: CanonicalWorkspace = { - ...workspace, - dwh: { ...workspace.dwh, supported_transports: ["postgres_direct", "rest_api"] }, - diagnostics: { - dwh_rest: { method: "POST", path: "/rpc/ping", auth: "bearer", response: { database: "database", schema: "schema" } }, - }, - }; - server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - code: "workspace_conflict", message: "Workspace changed in the registry.", - fields: [field], - expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, - actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, - base: diagnosticsWorkspace, local: diagnosticsWorkspace, remote: diagnosticsWorkspace, - }, { status: 409 }))); - - const error = await publishWorkspace({ action: "update", workspace: diagnosticsWorkspace, baseCommit: "a".repeat(40), baseBlob: "b".repeat(40) }).catch((cause: unknown) => cause); - - expect(asWorkspaceConflict(error)).toMatchObject({ actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, fields: [field] }); -}); - -test("rejects a conflict payload that attempts to surface removed vector transport and credential branches", async () => { - server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - code: "workspace_conflict", - message: "Workspace changed in the registry.", - fields: ["diagnostics.vector_rest.reversible_probe.auth"], - expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, - actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, - base: workspace, - 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(); -}); - - -test("sanitizes read and validate responses while preserving Evidence", async () => { +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: {} })), @@ -289,100 +135,54 @@ test("sanitizes read and validate responses while preserving Evidence", async () expect(validated.workspace.evidence).toEqual(evidenceWorkspace.evidence); }); -test("rejects malformed workspace API responses instead of exposing unknown Evidence fields", async () => { - const malformed = { - ...evidenceWorkspace, - evidence: { ...evidenceWorkspace.evidence, signed_urls_file: "/run/secrets/urls" }, - }; - server.use( - http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: malformed, revision })), - http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: malformed, contract: {} })), - ); - - await expect(getWorkspace("psd-clinical")).rejects.toThrow(); - await expect(validateWorkspace(evidenceWorkspace)).rejects.toThrow(); -}); - -test("publishes Evidence without mutating or dropping it from the request", async () => { +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 }); })); - await publishWorkspace({ - action: "update", workspace: evidenceWorkspace, - baseCommit: revision.commit, baseBlob: revision.blob, + await expect(publishWorkspace({ + action: "create", + workspace: evidenceWorkspace, + baseCommit: revision.commit, + })).resolves.toEqual({ revision }); + + expect(sent).toEqual({ + action: "create", + workspace: { ...evidenceWorkspace }, + baseCommit: revision.commit, }); - - expect(sent).toMatchObject({ workspace: { evidence: evidenceWorkspace.evidence } }); - expect(evidenceWorkspace.evidence.source.patterns).toEqual(["**/*.md"]); + expect(sent).not.toHaveProperty("baseBlob"); }); -const evidenceConflictFields = [ - "evidence", - "evidence.source", - "evidence.source.type", - "evidence.source.uri", - "evidence.source.patterns", - "evidence.source.max_bytes", - "evidence.source.uris", - "evidence.source.authentication", - "evidence.source.connect_timeout_ms", - "evidence.source.read_timeout_ms", - "evidence.source.max_redirects", - "evidence.source.allow_private_hosts", - "evidence.source.max_cache_bytes", - "evidence.source.endpoint_url", - "evidence.source.region", - "evidence.source.credentials", - "evidence.source.trusted_endpoint", - "evidence.source.allow_private_endpoint", - "evidence.source.allow_insecure_endpoint", - "evidence.source.max_objects", - "evidence.source.max_pages", - "evidence.source.page_size", - "evidence.policy", - "evidence.policy.max_chunk_chars", - "evidence.policy.retain_published_generations", -] as const; - -test.each(evidenceConflictFields)("accepts canonical Evidence conflict field %s", async (field) => { +test("rejects a publish response with a malformed revision", async () => { server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - code: "workspace_conflict", - message: "Workspace changed in the registry.", - fields: [field], - expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, - actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, - base: evidenceWorkspace, - local: evidenceWorkspace, - remote: evidenceWorkspace, + revision: { ...revision, commit: "not-a-commit" }, + }))); + + 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: "update", workspace: evidenceWorkspace, - baseCommit: "a".repeat(40), baseBlob: "b".repeat(40), + action: "create", + workspace, + baseCommit: revision.commit, }).catch((cause: unknown) => cause); - expect(asWorkspaceConflict(error)).toMatchObject({ fields: [field] }); -}); - -test("rejects unknown Evidence conflict paths", async () => { - server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - code: "workspace_conflict", - message: "Workspace changed in the registry.", - fields: ["evidence.source.signed_url"], - expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, - actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, - base: evidenceWorkspace, - local: evidenceWorkspace, - remote: evidenceWorkspace, - }, { status: 409 }))); - - const error = await publishWorkspace({ - action: "update", workspace: evidenceWorkspace, - baseCommit: "a".repeat(40), baseBlob: "b".repeat(40), - }).catch((cause: unknown) => cause); - - expect(asWorkspaceConflict(error)).toBeUndefined(); + expect(asWorkspaceApiError(error)).toEqual({ + status: 409, + code: "workspace_curator_owned", + message: "Existing descriptors are curator-owned.", + }); }); diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index bb8c7aca2d8eb3de518fe7814a5cdb697577881e..ff03b403d086a8ab3ada3464c1860de5cfaee52c 100644 GIT binary patch delta 1641 zcmZ`(&ubGw6n3j(8pWT`l-jhftF(!2Hf?OJwuBt?pa@#@;6ZEL?oR5|W;flPwbVA| z;!#C?Ab3*nARYu^FAARYDoFnV#UFP;M5+kRY%-gy+OTAI-}~M--<$94tSvk(KJQi% zi?(yct>^|R8lIyw+bP(}?XPM5(1gH+t%_S99K#in}4q5Q1 z!%Kh0kGJ8it?}%XH#1M&%je`0XE=hm43^^nx*<3@ox%ASQ77Yo&&JjGjP8=7cDYO$ z?u-xM>m74g?0B-COl(V}x;UFUOIgvftzt=cS+Qh0i@IZ~LwGEq;hSB%@u9Nazmw=} z!`i?WeA^kr)1%w`WY;HU(}qlM4fk}v5D`pv#iltM=cBwDR7)=taWB#N;BHd4GOsV4 zYJ^SV8V;?9Mg%Id9l>sis6(z&H$ZS^JlS;++mro0|6-vEd2nsdF~}r-N&bpwLu>Fu z&-~PXD*E#I(2o9oSPv12hc@9t@9kR9Y$^f>ghM_`-6y+92Nf$Tux?R=EL=?mF0kI6 zCb^&wcfz)qq{QF7nT=wPRtGw;0B?3hn1n39z3-6X|Je7r4Idr&)>-nbV2GeGc_+#h z+hN4Smj@2VOg@SRgFL=Zbz|q?_KtcW&B4rbEU3Ru;$1aeD-))hI@7rU_RJhFOVyPY zw9tYypHH#De0sT`Cb10iFIr~)13nuV+bogLg67&a;dx6baIEoy^z z?lV5<>8Z8E>kjiol(wLW-s($>FU(zX87ZrhrO|kxy+xf&XD^=DGuNiY-*DzgW_md{ zbSPK7kZr6{Bm1)&tIY3Iix}I*F|BbQ2H2HBG>!{mU(zRF%rfr6#f}9~%!<;i>Gw)h){CHPM8a@-f*{ zFiHTzp%4436T>@oxXl;5)^?m|0a&%YOBf!;h6%_(46?_TuLe6*uSZ o;r-lo|JcEtf*(ik;;T_LmIkhhUwC7z;(r@kYu5SS$2Tc|06ujiEC2ui delta 3619 zcmaJ^-EUMy6wiX7w1HYm=@*n?q3v$Fy@fz*Z7EeNjlLL#7#@%;(|c#TL+=-J?`>Jy z%?AGfKQe!SLSlmY0Leb6Pb$6kFeoSA!f?=D+NXz!el-}#+0XJ$@6 zFaC4m!I4aTF7s&LA)C5u5nW^RB+J|^*(O;Y)@Qrl*!5gk@tG`44i9z_5N{D`hTL<( z&J>J>EW03YcORQ3t2AKCp5yRvo&=%b?i#7Kdqlp~@Thh0SWvRpY;SdE42#(eqY`c< zM%t}4cqgcZt}b@d_e4mz8_J)$U#u;AZpr3mNVb5g=~g zR*;0eausEO#>|ibk;{owf_RZ_@Br$BW>B$++8vB~(pFGF`ThQ(J+^03Ti%{HBtPli zyW8G%q*>m35fGzPvF@PT|6~AG{ zU5njpwK6^11}{iR-sw5C)T*?Aii>qpRGtN6@gStGDL?J$uNll)WtIiU!0?5)!7U~* zlQ#FJkpsjh9JUDD># zfUPJB7Jz@7VMAj$EG&DfGpr7dhEKz?+NeZXGlQlxjZnKIHiY(}x~#Q55~MUx!!l_c z#Ds%pG1pX0m3$kuz>7qiOp6S>&yZ`wHkL<%Ym)5>NG@bTZ-2Yap*M@Gm5`~Lrj#{| zZUAK5qbZfrU`ZG@ElrM5y9JzO7kaXoJ_+e`jhHrE!f+&7jtfB5R7nyWWGR@%1pw|u z2z#aI3%&t2bg>ML(w>syjYuYi>m$j!6D8(aHQ$3fwipT3)9twlwiaI=?HYJffO*KN zy#vosL;+p`5JQFNw3E>H`BXAZ(@tdNw|fR^olr1UZ}s}lB|`%rp3IJgLPas)w_00G zpVziF^Szup#j3XD$Xzd*Whz=x^`y=+^^;rq;ufDob5R&uVn^y!{L%qk0HOz zjMjhX8QgVwW`?W?&kZ4WLgBv&mm#s3Z+jI&L(hT6v+c?}JWVF^L*lu1g+NCVeXyoC z&J^GghfbI-3C6(Ng-h2A4f)E{X#KC=-!gsgMW^83H2ykW!k@Dx`D@?T^3<_jd3kKV z9O_?bJO`u-Pmyj{d=7!zrO0ups1Uds14c@4{d${$A^;LAGMShN$Sp(95xKkp^MnJx z9PZ{$=NoilFuZ)GZNAQkfOf*0T0hpa0SH3MT?FPrO(pPJ3+?aO(^zK63MFM`t_Os! zdK=7;)&3{){loTH@|=<5i;H?J8XqUELLSQV10P5_I3+(C{Q3D~nImZ!VJutI;_C#Br@a48zFbt>~3vbWB&*K1-Hh z!%Glf$bKg*Yd9 z0ZxRk`uqa8;O+{#DgPTDYgQmXGP0hO0|w0|?X5=c0WC4eod%6@D*?9Xtb4+{^_hF+yM1%?{XC3%mJ%g0BLYl*$qryP>0v?avTO_=y6<|za{us++rmP-J%wQ|SC zJ08o)_BJ^lm#z6}Fqk?kxtvnu$>RA2u@yD68f6;9%Dh5I=&40a4B>{bs!FKqi85UZ z*x3_w=V2ZPkz}0L-$@9)(lPWm#?W6Xi2jVD+!-2{YmbmHqjwoXdYKALXkvg58K<-ZCH*-d2lv-r~P&6uBk1xRixj z&DU2Z&txv>K1{g2X~FhjFJ9ak3(juZf?H%>?kk*-+qu`}H&gHQHnHGTFLYHG N|E%!0W_{oI_kVIY>3aYG diff --git a/frontend/src/shell/NewSessionDialog.test.tsx b/frontend/src/shell/NewSessionDialog.test.tsx index 7d9a76ce..3d48f423 100644 --- a/frontend/src/shell/NewSessionDialog.test.tsx +++ b/frontend/src/shell/NewSessionDialog.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; -import { canonicalWorkspaceFixture, workspaceRevisionFixture } from "../test/workspace-fixtures"; +import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { NewSessionDialog } from "./NewSessionDialog"; function renderDialog() { @@ -36,8 +36,10 @@ test("submitting includes browser-local migrated preferences and calls onCreated workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "default", name: "default", file: "default.yaml", displayName: "Default", language: "en", - revision: workspaceRevisionFixture("default"), + ...workspaceSummaryFixture("default", { + displayName: "Default", + revision: workspaceRevisionFixture("default"), + }), }])), http.get("/api/workspaces/default", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("default", ["zai/glm-5.2"], "zai/glm-5.2"), @@ -76,8 +78,7 @@ test("first-run direct dialog creation waits for registry policy without a mount http.get("/api/workspaces", () => { summaryRequestStarted = true; return HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", - revision, + ...workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision as any }), }]); }), http.get("/api/workspaces/psd-clinical", async () => { diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index 6b55ea09..6366e058 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; -import { canonicalWorkspaceFixture } from "../test/workspace-fixtures"; +import { canonicalWorkspaceFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { useSessionStore } from "../store/sessionStore"; import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput"; @@ -24,8 +24,10 @@ test("new sessions send the browser-selected workspace, model, provider, and thi })); server.use( http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + ...workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + }), }])), http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("psd-clinical"), @@ -136,8 +138,10 @@ test("footer shows cumulative k-token counters after workspace and context gauge workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd", name: "psd", file: "psd.yaml", displayName: "PSD", language: "en", - revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + ...workspaceSummaryFixture("psd", { + displayName: "PSD", + revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + }), }])), http.get("/api/workspaces/psd", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("psd"), @@ -175,8 +179,10 @@ test("footer limits model choices to the selected workspace policy", async () => workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + ...workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + }), }])), http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("psd-clinical"), @@ -208,8 +214,8 @@ test("switching workspaces replaces an out-of-policy model before session creati workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "research", name: "research", file: "research.yaml", displayName: "Research", language: "en", revision: revision("research") }, - { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", revision: revision("psd-clinical") }, + workspaceSummaryFixture("research", { displayName: "Research", revision: revision("research") }), + workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision("psd-clinical") }), ])), http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), @@ -259,8 +265,8 @@ test("immediate submit waits for a switched workspace policy before creating a s server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "research", name: "research", file: "research.yaml", displayName: "Research", language: "en", revision: revision("research") }, - { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", revision: revision("psd-clinical") }, + workspaceSummaryFixture("research", { displayName: "Research", revision: revision("research") }), + workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision("psd-clinical") }), ])), http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"), @@ -315,7 +321,7 @@ test("initial restored workspace waits for its delayed policy before creating a server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", revision: revision("psd-clinical") }, + workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision("psd-clinical") }), ])), http.get("/api/workspaces/psd-clinical", async () => { policyRequestStarted = true; @@ -359,7 +365,10 @@ test("initial submit rejects a workspace summary that omits the canonical revisi server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "broken-workspace" })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "broken-workspace", name: "broken-workspace", file: "broken-workspace.yaml", displayName: "Broken workspace", language: "en", + ...workspaceSummaryFixture("broken-workspace", { + displayName: "Broken workspace", + configurationState: "configuration_required", + }), }])), http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, @@ -432,9 +441,9 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "research", name: "research", file: "research.yaml", displayName: "Research", language: "en", revision: revision("research") }, - { id: "workspace-b", name: "workspace-b", file: "workspace-b.yaml", displayName: "Workspace B", language: "en", revision: revision("workspace-b") }, - { id: "workspace-c", name: "workspace-c", file: "workspace-c.yaml", displayName: "Workspace C", language: "en", revision: revision("workspace-c") }, + workspaceSummaryFixture("research", { displayName: "Research", revision: revision("research") }), + workspaceSummaryFixture("workspace-b", { displayName: "Workspace B", revision: revision("workspace-b") }), + workspaceSummaryFixture("workspace-c", { displayName: "Workspace C", revision: revision("workspace-c") }), ])), http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"), diff --git a/frontend/src/shell/WorkspaceEditor.test.tsx b/frontend/src/shell/WorkspaceEditor.test.tsx index e4aee007..d72b4fd8 100644 --- a/frontend/src/shell/WorkspaceEditor.test.tsx +++ b/frontend/src/shell/WorkspaceEditor.test.tsx @@ -2,11 +2,12 @@ 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 { WorkspaceDraft } from "../workspaces/drafts"; +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: "psd-clinical", name: "PSD Clinical", language: "en" }, + 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"], @@ -22,10 +23,11 @@ const workspace: CanonicalWorkspace = { const evidenceWorkspace: CanonicalWorkspace = { ...workspace, + workspace: { ...workspace.workspace, id: "psd-clinical", name: "PSD Clinical", description: "Clinical workspace" }, evidence: { source: { type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", patterns: ["documents/**/*.pdf"], max_bytes: 12_000_000, }, @@ -33,129 +35,80 @@ const evidenceWorkspace: CanonicalWorkspace = { }, }; -const draft: WorkspaceDraft = { - workspaceId: "psd-clinical", +const draft: WorkspaceBootstrapDraft = { + workspaceId: "bootstrap-slot", baseCommit: "a".repeat(40), - baseBlob: "b".repeat(40), workspace, updatedAt: "2026-08-04T10:00:00.000Z", }; -test("uses closed choices for transport and rejects an invalid free-form port before save", async () => { - 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("saves only the editable collection while preserving the fixed schema-v3 semantic architecture", async () => { +test("bootstrap mode locks catalog metadata and saves only the local bootstrap draft", async () => { const user = userEvent.setup(); const onSaveDraft = vi.fn(); - render(); + 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: { - engine: "qdrant", - collection: "research_docs", - dimensions: 1024, - distance: "cosine", - }, - embedding: { - provider: "ollama_internal", - model: "qwen3-embedding:0.6b", - dimensions: 1024, - }, + vector_store: expect.objectContaining({ collection: "research_docs" }), }), }), })); + expect(onSaveDraft.mock.calls[0]?.[0]).not.toHaveProperty("baseBlob"); }); -test("shows fixed architecture values and no editable endpoint or credential controls", () => { - render(); - - expect(screen.getByRole("combobox", { name: "Workspace language" })).toHaveValue("en"); - expect(screen.getByRole("listbox", { name: "DWH transport" })).toHaveProperty("multiple", true); - expect(screen.getByLabelText("Vector store engine")).toHaveValue("qdrant"); - expect(screen.getByLabelText("Vector distance")).toHaveValue("cosine"); - expect(screen.getByLabelText("Semantic index dimensions")).toHaveValue(1024); - expect(screen.getByLabelText("Embedding provider")).toHaveValue("ollama_internal"); - expect(screen.getByLabelText("Embedding model")).toHaveValue("qwen3-embedding:0.6b"); - expect(screen.queryByLabelText("Vector database")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Vector schema")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Vector port")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Vector transport")).not.toBeInTheDocument(); - expect(screen.queryByLabelText(/api[- ]key|endpoint|base url/i)).not.toBeInTheDocument(); -}); - -test("rejects a non-positive DWH timeout without saving a draft", async () => { - const user = userEvent.setup(); - const onSaveDraft = vi.fn(); - render(); - - await user.clear(screen.getByLabelText("DWH timeout (ms)")); - await user.type(screen.getByLabelText("DWH timeout (ms)"), "0"); - await user.click(screen.getByRole("button", { name: "Save draft" })); - - expect(screen.getByRole("alert")).toHaveTextContent("DWH timeout must be a positive whole number"); - expect(screen.getByLabelText("DWH timeout (ms)")).toHaveAttribute("aria-invalid", "true"); - expect(onSaveDraft).not.toHaveBeenCalled(); -}); - - -test("shows a safe read-only Evidence summary without authoring or secret binding controls", () => { - render(); +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("workspace-content/psd-clinical/evidence"); + expect(summary).toHaveTextContent("psd-clinical/evidence"); expect(summary).toHaveTextContent("8,000"); expect(summary).toHaveTextContent("5"); - expect(summary).toHaveTextContent("Evidence is managed by the registry descriptor in P1."); - expect(summary).not.toHaveTextContent(/signed_urls_file|static_files|secret|binding/i); - expect(screen.queryByLabelText(/evidence.*(source|uri|pattern|credential)/i)).not.toBeInTheDocument(); -}); - -test("publishes an edited DWH and LLM field without dropping or mutating Evidence", async () => { - const user = userEvent.setup(); - const onPublish = vi.fn().mockResolvedValue(undefined); - render(); - - await user.clear(screen.getByLabelText("DWH database")); - await user.type(screen.getByLabelText("DWH database"), "research"); - await user.clear(screen.getByLabelText("Allowed models")); - await user.type(screen.getByLabelText("Allowed models"), "openai/gpt-5"); - await user.click(screen.getByRole("button", { name: "Publish draft" })); - - expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ - action: "update", - workspace: expect.objectContaining({ - dwh: expect.objectContaining({ database: "research" }), - llm_policy: { allowed: ["openai/gpt-5"] }, - evidence: evidenceWorkspace.evidence, - }), - })); - expect(evidenceWorkspace.evidence?.source).toEqual({ - type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", - patterns: ["documents/**/*.pdf"], - max_bytes: 12_000_000, - }); -}); - -test("does not show an Evidence summary for a workspace without Evidence", () => { - render(); - - expect(screen.queryByRole("region", { name: "Evidence" })).not.toBeInTheDocument(); + 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 index 93855c92..d2e56f0a 100644 --- a/frontend/src/shell/WorkspaceEditor.tsx +++ b/frontend/src/shell/WorkspaceEditor.tsx @@ -1,34 +1,30 @@ -import { useEffect, useId, useMemo, useState } from "react"; -import type { CanonicalWorkspace, PublishWorkspaceRequest } from "../api/workspaces"; -import type { WorkspaceDraft } from "../workspaces/drafts"; +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 interface WorkspaceEditorProps { - draft?: WorkspaceDraft; - onSaveDraft: (draft: WorkspaceDraft) => void; - /** Reserved for Task 10; saving a draft never publishes it. */ - onPublish: (request: PublishWorkspaceRequest) => Promise; - idLocked?: boolean; -} +export type WorkspaceEditorMode = + | { kind: "bootstrap"; catalog: WorkspaceSummary; draft: WorkspaceBootstrapDraft } + | { kind: "read_only"; catalog: WorkspaceSummary; record: WorkspaceRecord }; -const EMPTY_COMMIT = "0".repeat(40); +type BootstrapEditorProps = { + mode: Extract; + onSaveDraft?: (draft: WorkspaceBootstrapDraft) => void; + onRequestCreate?: (request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) => void; +}; -function emptyWorkspace(): CanonicalWorkspace { - return { - workspace: { schema_version: 3, id: "new-workspace", name: "New workspace", language: "en" }, - dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] }, - semantic_index: { - vector_store: { - engine: "qdrant", collection: "documents", - dimensions: 1024, distance: "cosine", - }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, - }; -} +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; @@ -52,14 +48,6 @@ function validate(workspace: CanonicalWorkspace): FieldErrors { const dwhTimeout = positiveInteger(workspace.dwh.timeout_ms, "DWH timeout"); if (dwhTimeout) errors["dwh.timeout"] = dwhTimeout; 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.engine !== "qdrant") errors["semantic.engine"] = "Vector store engine is fixed to qdrant"; - if (workspace.semantic_index.vector_store.dimensions !== 1024) errors["semantic.dimensions"] = "Semantic index dimensions are fixed to 1024"; - if (workspace.semantic_index.vector_store.distance !== "cosine") errors["semantic.distance"] = "Vector distance is fixed to cosine"; - if (workspace.semantic_index.embedding.provider !== "ollama_internal") errors["embedding.provider"] = "Embedding provider is fixed to ollama_internal"; - if (workspace.semantic_index.embedding.model !== "qwen3-embedding:0.6b") errors["embedding.model"] = "Embedding model is fixed to qwen3-embedding:0.6b"; - if (workspace.semantic_index.embedding.dimensions !== 1024 || workspace.semantic_index.embedding.dimensions !== workspace.semantic_index.vector_store.dimensions) { - errors["semantic.dimensions"] = "Vector and embedding dimensions are fixed to 1024"; - } 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"; } @@ -69,7 +57,7 @@ function validate(workspace: CanonicalWorkspace): FieldErrors { return errors; } -function selectedValues(event: React.ChangeEvent): string[] { +function selectedValues(event: ChangeEvent): string[] { return Array.from(event.currentTarget.selectedOptions, (option) => option.value); } @@ -77,13 +65,28 @@ 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, children, hint, + label, + error, + hint, + children, }: { label: string; error?: string; hint?: string; - children: (props: { id: string; describedBy?: string; invalid: boolean }) => React.ReactNode; + children: (props: { id: string; describedBy?: string; invalid: boolean }) => ReactNode; }) { const id = useId(); const errorId = `${id}-error`; @@ -99,7 +102,7 @@ function Field({ ); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ title, children }: { title: string; children: ReactNode }) { return (

{title}

@@ -117,72 +120,81 @@ function EvidenceSummary({ evidence }: { evidence: NonNullable
Source type
{evidence.source.type}
Source
{sourceIdentity}
-
Chunk size
{evidence.policy.max_chunk_chars.toLocaleString("en-US")} characters
-
Retention
{evidence.policy.retain_published_generations.toLocaleString("en-US")} published generations
+
Chunk size
{evidence.policy.max_chunk_chars.toLocaleString("en-US")}
+
Retention
{evidence.policy.retain_published_generations.toLocaleString("en-US")}
-

Evidence is managed by the registry descriptor in P1.

+

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

); } -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, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) { - const [workspace, setWorkspace] = useState(draft?.workspace ?? emptyWorkspace()); +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(draft?.workspace ?? emptyWorkspace()); + setWorkspace(props.mode.kind === "bootstrap" + ? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog) + : props.mode.record.workspace); setErrors({}); - }, [draft]); + }, [bootstrapMode, props.mode]); function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) { + if (readOnly) return; setWorkspace((previous) => { - const next = change(previous); + const next = coerceCatalogMetadata(change(previous), props.mode.catalog); 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, + 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 publishDraft() { + function saveDraft() { + if (props.mode.kind !== "bootstrap") return; 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); + 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, 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, name: event.target.value } }))} />} - {({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />} + {({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />} - {({ id, describedBy, invalid }) => } + {({ id, describedBy, invalid }) => }
@@ -191,19 +203,19 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool {({ 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, 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, 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, port: numberOrUndefined(event.target.value) } }))} />} - {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />} + {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />} @@ -212,41 +224,50 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool {({ 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 }) => 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 }) => 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 }) => } + {({ id, describedBy, invalid }) => }
{workspace.evidence && }
-

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

+

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 7bc2927e..aa67a3d0 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -4,17 +4,53 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { server } from "../test/msw"; +import { workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { WorkspaceManager } from "./WorkspaceManager"; -const workspace = { - workspace: { schema_version: 3, 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: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, +const readyWorkspace = { + workspace: { + schema_version: 3, + id: "psd-clinical", + name: "PSD Clinical", + description: "Clinical data", + language: "en" as const, }, - llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, -} 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 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 renderManager() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -26,58 +62,108 @@ beforeEach(() => { 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/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", displayName: "PSD Clinical", description: "Clinical data", - language: "en", file: "psd-clinical.yaml", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd" }, - }])), + 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"), + }), + ])), http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ - workspace, - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd" }, + workspace: readyWorkspace, + revision: workspaceRevisionFixture("psd-clinical"), })), ); }); afterEach(() => vi.unstubAllGlobals()); -test("lists registry workspaces and saves a new workspace only as a browser draft", async () => { +test("renders catalog slots in order and opens a bootstrap form for configuration_required entries", async () => { const user = userEvent.setup(); renderManager(); expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible(); - expect(await screen.findByRole("button", { name: "PSD Clinical" })).toBeVisible(); - expect(screen.getByText(`Commit ${"a".repeat(12)}`)).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"); + 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" })); + + 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(); +}); + +test("saves a bootstrap draft locally for a configuration_required slot", 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.v1.draft.trial-registry")).not.toBeNull(); + 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("imports a bundle as a local draft and never publishes it automatically", async () => { +test("successful create discards the bootstrap draft and reloads the workspace as read-only", async () => { const user = userEvent.setup(); - const publishSpy = vi.fn(); + let workspacesCalls = 0; server.use( - http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })), - http.post("/api/workspaces/publish", () => { - publishSpy(); - return HttpResponse.json({}); + 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.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" })); + 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" })); - 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(); + 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("pulls and exports only when the manager explicitly requests each action", async () => { +test("ready workspaces stay read-only while keeping pull, export, validate, and installation test actions", async () => { const user = userEvent.setup(); - const publishSpy = vi.fn(); const createObjectURL = vi.fn(() => "blob:workspace-bundle"); const revokeObjectURL = vi.fn(); class DownloadUrl extends URL { @@ -89,123 +175,22 @@ test("pulls and exports only when the manager explicitly requests each action", 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/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; - server.use(http.post("/api/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("saves resolved conflict choices as a rebased browser draft without publishing again", async () => { - const user = userEvent.setup(); - let publishCalls = 0; - const local = { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "local_collection" } } }; - const remote = { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "remote_collection" } } }; - server.use( - http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })), - http.post("/api/workspaces/publish", () => { - publishCalls += 1; - return HttpResponse.json({ - code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["semantic_index.vector_store.collection"], - expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, - base: workspace, local, remote, - }, { status: 409 }); - }), - ); - renderManager(); - - await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); - await user.click(screen.getByRole("button", { name: "Publish draft" })); - 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("radio", { name: "Use your draft for semantic_index.vector_store.collection" })); - await user.click(screen.getByRole("button", { name: "Save revised draft" })); - - expect(await screen.findByText("Revised draft saved with registry revision cccccccccccc. Validate it before publishing.")).toBeVisible(); - expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toContain('"baseCommit":"cccccccccccccccccccccccccccccccccccccccc"'); - expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toContain('"baseBlob":"dddddddddddddddddddddddddddddddddddddddd"'); - expect(publishCalls).toBe(1); -}); - -test("proposes a different valid ID when duplicating a 63-character workspace ID", async () => { - const user = userEvent.setup(); - const maxId = `w${"a".repeat(62)}`; - const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } }; - server.use( - http.get("/api/workspaces", () => HttpResponse.json([{ - id: maxId, name: maxId, displayName: "Maximum", language: "en", file: `${maxId}.yaml`, - revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum" }, - }])), - http.get(`/api/workspaces/${maxId}`, () => HttpResponse.json({ - workspace: maxWorkspace, - revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum" }, - })), - ); - renderManager(); - - await user.click(await screen.findByRole("button", { name: "Maximum" })); - await user.click(screen.getByRole("button", { name: "Duplicate workspace" })); - - const proposed = screen.getByLabelText("Workspace ID") as HTMLInputElement; - expect(proposed.value).toMatch(/^[a-z][a-z0-9-]{2,62}$/); - expect(proposed).not.toHaveValue(maxId); -}); - -test("does not show a saved-draft toast when manager validation rejects a DWH timeout", async () => { - const user = userEvent.setup(); - renderManager(); - - await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); - await user.clear(screen.getByLabelText("DWH timeout (ms)")); - await user.type(screen.getByLabelText("DWH timeout (ms)"), "0"); - await user.click(screen.getByRole("button", { name: "Save draft" })); - - expect(screen.getByRole("alert")).toHaveTextContent("DWH timeout must be a positive whole number"); - expect(screen.queryByText("Draft saved in this browser.")).not.toBeInTheDocument(); -}); - -test("runs validation and installation test with only sanitized messages", async () => { - const user = userEvent.setup(); - server.use( - http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })), + 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(); 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" })); @@ -213,81 +198,16 @@ test("runs validation and installation test with only sanitized messages", async expect(within(screen.getByTestId("workspace-diagnostics")).queryByText(/password|token|secret/i)).not.toBeInTheDocument(); }); -test("loads a state-free registry revision and displays only its commit", async () => { +test("import populates only a matching configuration_required slot and refuses existing workspaces", async () => { const user = userEvent.setup(); 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(); - expect(await screen.findByLabelText("Vector collection")).toBeVisible(); - expect(screen.getByText(`Revision ${"a".repeat(12)}`)).toBeVisible(); - expect(screen.queryByText(/migration required/i)).not.toBeInTheDocument(); -}); - -test("shows an actionable unavailable message when a workspace summary omits its canonical revision", async () => { - const user = userEvent.setup(); - server.use( - http.get("/api/workspaces", () => HttpResponse.json([ - { - id: "broken-workspace", name: "broken-workspace", displayName: "Broken workspace", description: "Broken data", - language: "en", file: "broken-workspace.yaml", - }, - ])), - ); - renderManager(); - - await user.click(await screen.findByRole("button", { name: "Broken workspace" })); - - expect(await screen.findByText("This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.")).toBeVisible(); - expect(screen.queryByLabelText("Vector collection")).not.toBeInTheDocument(); -}); - -test("shows an accessible retry instead of a loading status when the registry status query fails", async () => { - const user = userEvent.setup(); - let calls = 0; - server.use(http.get("/api/workspace-registry/status", () => { - calls += 1; - return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false }); - })); - renderManager(); - - expect(await screen.findByRole("alert", { name: "Workspace registry status failed" })).toHaveTextContent("Could not load registry status."); - await user.click(screen.getByRole("button", { name: "Retry registry status" })); - expect(await screen.findByText("main")).toBeVisible(); - expect(calls).toBe(2); -}); - -test("shows an accessible retry instead of an empty list when the workspace list query fails", async () => { - const user = userEvent.setup(); - let calls = 0; - server.use(http.get("/api/workspaces", () => { - calls += 1; - return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json([]); - })); - renderManager(); - - expect(await screen.findByRole("alert", { name: "Workspace list failed" })).toHaveTextContent("Could not load workspaces."); - expect(screen.queryByText("No published workspaces.")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Retry workspace list" })); - expect(await screen.findByText("No published workspaces.")).toBeVisible(); - expect(calls).toBe(2); -}); - -test("shows an accessible retry when the selected workspace detail query fails", async () => { - const user = userEvent.setup(); - let calls = 0; - server.use(http.get("/api/workspaces/psd-clinical", () => { - calls += 1; - return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ - workspace, - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd" }, - }); - })); - renderManager(); - - await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); - expect(await screen.findByRole("alert", { name: "Workspace details failed" })).toHaveTextContent("Could not load workspace details."); - await user.click(screen.getByRole("button", { name: "Retry workspace details" })); - expect(await screen.findByRole("heading", { name: "PSD Clinical" })).toBeVisible(); - expect(calls).toBe(2); + 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(); }); diff --git a/frontend/src/shell/WorkspaceManager.tsx b/frontend/src/shell/WorkspaceManager.tsx index 403b915e..caa493d7 100644 --- a/frontend/src/shell/WorkspaceManager.tsx +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -1,12 +1,22 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Plus, Trash2, Copy, Upload, X } from "lucide-react"; +import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Upload, X } from "lucide-react"; import { - asWorkspaceApiError, exportWorkspace, getWorkspace, getWorkspaceRegistryStatus, importWorkspace, - listWorkspaces, pullWorkspaceRegistry, testWorkspace, validateWorkspace, type CanonicalWorkspace, - type PublishWorkspaceRequest, type WorkspaceRecord, type WorkspaceRevision, + asWorkspaceApiError, + exportWorkspace, + getWorkspace, + getWorkspaceRegistryStatus, + importWorkspace, + listWorkspaces, + pullWorkspaceRegistry, + testWorkspace, + validateWorkspace, + type CanonicalWorkspace, + type PublishWorkspaceRequest, + type WorkspaceRecord, + type WorkspaceSummary, } from "../api/workspaces"; -import { workspaceDeletionDrafts, workspaceDrafts, type WorkspaceDeletionDraft, type WorkspaceDraft } from "../workspaces/drafts"; +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"; @@ -14,34 +24,6 @@ import { WorkspacePublishDialog } from "./WorkspacePublishDialog"; const EMPTY_COMMIT = "0".repeat(40); -function newWorkspace(): CanonicalWorkspace { - return { - workspace: { schema_version: 3, id: "new-workspace", name: "New workspace", language: "en" }, - dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: "documents", dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - 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 { - const copy = `${id.slice(0, 58)}-copy`; - // A max-length source ending in "-copy" would otherwise reproduce itself. - return copy === id ? `${id.slice(0, 61)}-2` : copy; -} - function QueryError({ name, message, retryLabel, onRetry }: { name: string; message: string; @@ -51,98 +33,99 @@ function QueryError({ name, message, retryLabel, onRetry }: { 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 bootstrapDraftFor(summary: WorkspaceSummary, baseCommit: string): WorkspaceBootstrapDraft { + return { + workspaceId: summary.id, + baseCommit, + workspace: defaultBootstrapWorkspace(summary), + updatedAt: new Date().toISOString(), + }; +} + export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) { const [selectedId, setSelectedId] = useState(); - const [localDraft, setLocalDraft] = useState(); + const [localDraft, setLocalDraft] = useState(); 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 workspaces = workspacesQuery.data ?? []; const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]); - const selectedSummaryIncomplete = Boolean(selectedSummary && !selectedSummary.revision); const detailQuery = useQuery({ queryKey: ["workspace", selectedId], queryFn: () => getWorkspace(selectedId!), - enabled: Boolean(open && selectedId && !localDraft && !selectedSummaryIncomplete), + enabled: Boolean(open && selectedId && selectedSummary?.configurationState === "ready"), }); - const status = statusQuery.data; const record = detailQuery.data; - 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 activeBootstrapDraft = selectedSummary?.configurationState === "configuration_required" + ? (localDraft?.workspaceId === selectedSummary.id ? localDraft : workspaceBootstrapDrafts.load(selectedSummary.id) ?? bootstrapDraftFor(selectedSummary, statusQuery.data?.head ?? EMPTY_COMMIT)) + : undefined; + + function resetTransientState() { + setNotice(undefined); + setDiagnostics([]); + setPublishRequest(undefined); + } function selectWorkspace(id: string) { setSelectedId(id); setLocalDraft(undefined); - setDeletionDraft(undefined); - setNotice(undefined); - setDiagnostics([]); - setPublishRequest(undefined); + resetTransientState(); } - function createWorkspace() { - const draft: WorkspaceDraft = { workspaceId: "new-workspace", baseCommit: EMPTY_COMMIT, workspace: newWorkspace(), updatedAt: new Date().toISOString() }; - setSelectedId(draft.workspaceId); + function saveDraft(draft: WorkspaceBootstrapDraft) { + workspaceBootstrapDrafts.save(draft); setLocalDraft(draft); - setDeletionDraft(undefined); - setNotice("New draft. Choose its immutable workspace ID before saving."); - setDiagnostics([]); - setPublishRequest(undefined); - } - - 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."); - setPublishRequest(undefined); - } - - function saveDraft(draft: WorkspaceDraft) { - workspaceDrafts.save(draft); setSelectedId(draft.workspaceId); - setLocalDraft(draft); setNotice("Draft saved in this browser."); + setDiagnostics([]); } - 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); - } + function requestCreate(request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) { + workspaceBootstrapDrafts.save(draft); + setLocalDraft(draft); + setSelectedId(draft.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([]); @@ -153,47 +136,35 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () } 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); + async function validateSelectedWorkspace() { + if (!record) return; 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."); + await validateWorkspace(record.workspace); + setNotice("Workspace definition is valid."); } catch (error) { const safe = asWorkspaceApiError(error); - setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]); - } finally { - setTransferring(false); + setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]); + } + } + + async function testSelectedWorkspace() { + 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"]); } } @@ -219,80 +190,62 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () } } - 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); + async function importBundle(file: File | undefined) { + if (!file) return; + setTransferring(true); + setNotice(undefined); + setDiagnostics([]); + 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."); + } catch (error) { + const safe = asWorkspaceApiError(error); + setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]); + } finally { + setTransferring(false); + } + } + + 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."); } 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()]); - } - - function saveResolvedDraft(draft: WorkspaceDraft) { - workspaceDrafts.save(draft); - setSelectedId(draft.workspaceId); - setLocalDraft(draft); - setDeletionDraft(undefined); setPublishRequest(undefined); setDiagnostics([]); - setNotice(`Revised draft saved with registry revision ${draft.baseCommit.slice(0, 12)}. Validate it before publishing.`); + void Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]).then(() => detailQuery.refetch()); } - 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([]); - } + const titleReady = workspacesQuery.isSuccess || workspacesQuery.isError; return ( { if (!nextOpen) onClose(); }}> - Workspace management - Draft shared workspace definitions locally. Installation bindings and secrets stay outside this page. + {titleReady ? "Workspace management" : ""} + {titleReady ? "Draft bootstrap-only workspace definitions locally. Existing published descriptors stay read-only." : ""}
- {selectedSummaryIncomplete ? ( -
-

Workspace summary unavailable

-

This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.

-
- ) : detailQuery.isError && selectedId && !localDraft ? { void detailQuery.refetch(); }} /> : !currentDraft && !detailQuery.isLoading &&

Select a workspace

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

} - {!selectedSummaryIncomplete && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && ( + {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 && ( <> - {detailQuery.isLoading && !currentDraft ?

Loading workspace definition…

: currentDraft && <> +
+

Bootstrap workspace

+

{selectedSummary.displayName}

+

{selectedSummary.id}

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

Loading workspace definition…

: ( + <>

Workspace definition

-

{currentDraft.workspace.workspace.name}

-

{currentDraft.workspaceId}

+

{record.workspace.workspace.name}

+

{record.workspace.workspace.id}

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

{notice}

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

{diagnostic}

)}
} - {activeDeletionDraft &&

Deletion draft

The published workspace is unchanged. Validation and a separate confirmation are required before this deletion is published.

} - { requestPublish(request); }} /> -
-

Git status & history

-

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

- {record &&

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

} -
- } - + + + ) )}
- {publishRequest && { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} onResolved={saveResolvedDraft} onPull={pullLatest} onReload={reloadWorkspace} />} + {publishRequest && { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} />}
); } diff --git a/frontend/src/shell/WorkspacePublishDialog.test.tsx b/frontend/src/shell/WorkspacePublishDialog.test.tsx index 3de944cc..b07cb377 100644 --- a/frontend/src/shell/WorkspacePublishDialog.test.tsx +++ b/frontend/src/shell/WorkspacePublishDialog.test.tsx @@ -2,150 +2,65 @@ 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 { canonicalWorkspaceFixture, workspaceRevisionFixture } from "../test/workspace-fixtures"; import { server } from "../test/msw"; import { WorkspacePublishDialog } from "./WorkspacePublishDialog"; -const workspace: CanonicalWorkspace = { - workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" }, - dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", 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 }, +const workspace = { + ...canonicalWorkspaceFixture("bootstrap-slot"), + workspace: { + ...canonicalWorkspaceFixture("bootstrap-slot").workspace, + name: "Bootstrap slot", + description: "Needs configuration", }, - 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.vector_store.collection"], - expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, - actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, - base: workspace, - local: { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "local_collection" } } }, - remote: { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "remote_collection" } } }, -}; - -const diagnosticsBranchConflict: WorkspaceConflict = { - ...conflict, - fields: ["diagnostics.dwh_rest"], - base: workspace, - remote: workspace, - local: { - ...workspace, - dwh: { ...workspace.dwh, supported_transports: ["postgres_direct", "rest_api"] }, - diagnostics: { dwh_rest: { method: "GET", path: "/health", auth: "none", response: { database: "database", schema: "schema" } } }, - }, +const request = { + action: "create" as const, + workspace, + baseCommit: "a".repeat(40), }; beforeEach(() => { server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} }))); }); -test("validates a draft and requires a separate confirmation before publishing", async () => { +test("validates a bootstrap draft and requires a separate confirmation before creating it", async () => { const user = userEvent.setup(); const published = vi.fn(); let publishCalls = 0; server.use(http.post("/api/workspaces/publish", () => { publishCalls += 1; - return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe" } }); + return HttpResponse.json({ revision: workspaceRevisionFixture("bootstrap-slot") }); })); - render(); - expect(screen.getByRole("button", { name: "Publish" })).toBeDisabled(); + render(); + + expect(screen.getByRole("button", { name: "Create workspace" })).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(await screen.findByText("Workspace definition is valid.")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Create workspace" })); + expect(screen.getByRole("heading", { name: "Confirm workspace creation" })).toBeVisible(); expect(publishCalls).toBe(0); - await user.click(screen.getByRole("button", { name: "Confirm publish" })); + await user.click(screen.getByRole("button", { name: "Confirm create" })); - await waitFor(() => expect(published).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(published).toHaveBeenCalledWith(workspaceRevisionFixture("bootstrap-slot"))); 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("/api/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.vector_store.collection")).toBeVisible(); - expect(screen.getByText("local_collection")).toBeVisible(); - expect(screen.getByText("remote_collection")).toBeVisible(); - expect(screen.getByRole("radio", { name: "Use your draft for semantic_index.vector_store.collection" })).toBeVisible(); - expect(screen.getByRole("radio", { name: "Use registry value for semantic_index.vector_store.collection" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Save revised draft" })).toBeDisabled(); - expect(published).toBe(true); -}); - -test("saves explicit local choices as a rebased draft and does not republish it", async () => { - const user = userEvent.setup(); - const saved = vi.fn(); - let publishCalls = 0; - server.use(http.post("/api/workspaces/publish", () => { - publishCalls += 1; - 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" })); - await user.click(await screen.findByRole("radio", { name: "Use your draft for semantic_index.vector_store.collection" })); - await user.click(screen.getByRole("button", { name: "Save revised draft" })); - - expect(saved).toHaveBeenCalledWith(expect.objectContaining({ - baseCommit: "c".repeat(40), baseBlob: "d".repeat(40), - workspace: expect.objectContaining({ semantic_index: expect.objectContaining({ vector_store: expect.objectContaining({ collection: "local_collection" }) }) }), - })); - expect(publishCalls).toBe(1); -}); - -test("rebases a selected optional diagnostics branch into the revised draft", async () => { - const user = userEvent.setup(); - const saved = vi.fn(); - server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - ...diagnosticsBranchConflict, 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("radio", { name: "Use your draft for diagnostics.dwh_rest" })); - await user.click(screen.getByRole("button", { name: "Save revised draft" })); - - expect(saved).toHaveBeenCalledWith(expect.objectContaining({ - baseCommit: "c".repeat(40), baseBlob: "d".repeat(40), - workspace: expect.objectContaining({ diagnostics: diagnosticsBranchConflict.local.diagnostics }), - })); -}); - -test("keeps a conflict open and redacts a failed registry pull", async () => { +test("surfaces a safe curator-owned refusal without showing conflict resolution UI", async () => { const user = userEvent.setup(); server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ - ...conflict, message: "Workspace changed in the registry.", + code: "workspace_curator_owned", + message: "Existing descriptors are curator-owned.", }, { status: 409 }))); - render(); + + 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" })); + await user.click(await screen.findByRole("button", { name: "Create workspace" })); + await user.click(screen.getByRole("button", { name: "Confirm create" })); - 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(); + expect(await screen.findByText("workspace_curator_owned: Existing descriptors are curator-owned.")).toBeVisible(); + expect(screen.queryByText(/save revised draft|use your draft|use registry value/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/shell/WorkspacePublishDialog.tsx b/frontend/src/shell/WorkspacePublishDialog.tsx index ac4e628c..9d5443cb 100644 --- a/frontend/src/shell/WorkspacePublishDialog.tsx +++ b/frontend/src/shell/WorkspacePublishDialog.tsx @@ -1,15 +1,11 @@ import { useEffect, useState } from "react"; import { asWorkspaceApiError, - asWorkspaceConflict, publishWorkspace, validateWorkspace, - type CanonicalWorkspace, type PublishWorkspaceRequest, - type WorkspaceConflict, type WorkspaceRevision, } from "../api/workspaces"; -import type { WorkspaceDraft } from "../workspaces/drafts"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog"; @@ -18,146 +14,31 @@ export interface WorkspacePublishDialogProps { request: PublishWorkspaceRequest; onOpenChange: (open: boolean) => void; onPublished: (revision: WorkspaceRevision | undefined) => void; - onResolved: (draft: WorkspaceDraft) => 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 valueAtPath(workspace: CanonicalWorkspace, path: string): unknown { - return path.split(".").reduce((current, key) => ( - current && typeof current === "object" ? (current as Record)[key] : undefined - ), workspace); -} - -function replaceAtPath(value: Record, path: string[], replacement: unknown): Record { - const [key, ...remaining] = path; - const copy = { ...value }; - if (remaining.length === 0) { - if (replacement === undefined) delete copy[key]; - else copy[key] = replacement; - return copy; - } - const child = copy[key]; - copy[key] = replaceAtPath(child && typeof child === "object" && !Array.isArray(child) ? child as Record : {}, remaining, replacement); - return copy; -} - -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)}

; + return

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

; } -function ConflictReview({ conflict, onPull, onReload, onResolved }: { - conflict: WorkspaceConflict; - onPull: () => Promise | void; - onReload: () => void; - onResolved: (draft: WorkspaceDraft) => void; -}) { - const [pulling, setPulling] = useState(false); - const [pulled, setPulled] = useState(false); - const [pullError, setPullError] = useState(false); - const [choices, setChoices] = useState>({}); - const canSave = Boolean(conflict.actual.blob) && conflict.fields.every((field) => choices[field]); - - async function pull() { - setPulling(true); - setPullError(false); - try { - await onPull(); - setPulled(true); - } catch { - setPullError(true); - } finally { - setPulling(false); - } - } - - function saveRevisedDraft() { - if (!conflict.actual.blob || !canSave) return; - const workspace = conflict.fields.reduce((current, field) => ( - choices[field] === "local" - ? replaceAtPath(current as unknown as Record, field.split("."), valueAtPath(conflict.local, field)) as unknown as CanonicalWorkspace - : current - ), conflict.remote); - onResolved({ - workspaceId: workspace.workspace.id, - baseCommit: conflict.actual.commit, - baseBlob: conflict.actual.blob, - workspace, - updatedAt: new Date().toISOString(), - }); - } - - return
-
-

The registry changed before publication.

-

Choose a value for every changed field to save a revised browser draft against registry revision {conflict.actual.commit.slice(0, 12)}. Saving never publishes it.

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

{field}

-
-
Base
{valueAt(conflict.base, field)}
-
Your draft
{valueAt(conflict.local, field)}
-
Registry
{valueAt(conflict.remote, field)}
-
-
- Resolve {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, onResolved, onPull, onReload }: WorkspacePublishDialogProps) { +export function WorkspacePublishDialog({ open, request, onOpenChange, onPublished }: WorkspacePublishDialogProps) { const [validatedRequest, setValidatedRequest] = useState(); - const [confirmationOpen, setConfirmationOpen] = useState(false); - const [conflict, setConflict] = useState(); + const [confirming, setConfirming] = useState(false); const [message, setMessage] = useState(); const [publishing, setPublishing] = useState(false); useEffect(() => { if (!open) return; setValidatedRequest(undefined); - setConfirmationOpen(false); - setConflict(undefined); + setConfirming(false); setMessage(undefined); setPublishing(false); }, [open, request]); - async function validate() { + async function validateDraft() { 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)); + setValidatedRequest({ ...request, workspace: result.workspace }); setMessage("Workspace definition is valid."); } catch (error) { const safe = asWorkspaceApiError(error); @@ -165,7 +46,7 @@ export function WorkspacePublishDialog({ open, request, onOpenChange, onPublishe } } - async function publish() { + async function createWorkspace() { if (!validatedRequest) return; setPublishing(true); setMessage(undefined); @@ -174,43 +55,38 @@ export function WorkspacePublishDialog({ open, request, onOpenChange, onPublishe 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"); - } + const safe = asWorkspaceApiError(error); + setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Publication could not be completed"); + setConfirming(false); } finally { setPublishing(false); } } - return - - - {conflict ? "Publication conflict" : "Publish workspace"} - {conflict ? "Choose each local or registry value, then save a revised draft for normal validation and confirmation." : "Validation and an explicit confirmation are required before this shared definition is published."} - -
- {conflict ? : <> + return ( + + + + {confirming ? "Confirm workspace creation" : "Create workspace"} + {confirming ? "This creates the validated workspace definition in the shared Git registry." : "Validation and an explicit confirmation are required before this shared definition is created."} + +
{message &&

{message}

} -
- - -
- } -
- -
- - - Confirm publicationThis publishes the validated workspace definition to the shared Git registry. - - + {confirming ? ( + + + + + ) : ( +
+ + +
+ )} +
+
- ; + ); } diff --git a/frontend/src/test/workspace-fixtures.ts b/frontend/src/test/workspace-fixtures.ts index e3817440..b368b8bb 100644 --- a/frontend/src/test/workspace-fixtures.ts +++ b/frontend/src/test/workspace-fixtures.ts @@ -1,4 +1,4 @@ -import type { CanonicalWorkspace, WorkspaceRevision } from "../api/workspaces"; +import type { CanonicalWorkspace, WorkspaceRevision, WorkspaceSummary } from "../api/workspaces"; export function canonicalWorkspaceFixture( id: string, @@ -29,3 +29,25 @@ export function workspaceRevisionFixture(id: string): WorkspaceRevision { snapshotPath: `/snapshots/${"a".repeat(40)}/${id}.yaml`, }; } + +export function workspaceSummaryFixture( + id: string, + options: { + displayName?: string; + description?: string; + configurationState?: WorkspaceSummary["configurationState"]; + revision?: WorkspaceRevision; + } = {}, +): WorkspaceSummary { + const configurationState = options.configurationState ?? "ready"; + const revision = options.revision ?? (configurationState === "ready" ? workspaceRevisionFixture(id) : undefined); + return { + id, + name: id, + file: `${id}/workspace.yaml`, + displayName: options.displayName ?? id, + ...(options.description === undefined ? {} : { description: options.description }), + configurationState, + ...(revision ? { revision } : {}), + }; +} diff --git a/frontend/src/workspaces/drafts.test.ts b/frontend/src/workspaces/drafts.test.ts index f2a48eed..96a177c4 100644 --- a/frontend/src/workspaces/drafts.test.ts +++ b/frontend/src/workspaces/drafts.test.ts @@ -1,7 +1,10 @@ import { beforeEach, expect, test } from "vitest"; import type { CanonicalWorkspace } from "../api/workspaces"; import { - sanitizeCanonicalWorkspace, workspaceDeletionDrafts, workspaceDrafts, workspacePreferences, + sanitizeCanonicalWorkspace, + workspaceBootstrapDrafts, + workspacePreferences, + type WorkspaceBootstrapDraft, } from "./drafts"; const workspace: CanonicalWorkspace = { @@ -19,68 +22,29 @@ const workspace: CanonicalWorkspace = { llm_policy: { allowed: ["zai/glm-5.2"] }, }; +const bootstrapDraft: WorkspaceBootstrapDraft = { + workspaceId: "psd-clinical", + baseCommit: "a".repeat(40), + workspace, + updatedAt: "2026-08-04T10:00:00.000Z", +}; + const policy = { max_chunk_chars: 8_000, retain_published_generations: 5 }; -const evidenceWorkspaces = [ - { - name: "filesystem", - workspace: { - ...workspace, - evidence: { - source: { - type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", - patterns: ["documents/**/*.pdf", "notes/*.md"], - max_bytes: 12_000_000, - }, - policy, - }, - } satisfies CanonicalWorkspace, +const evidenceWorkspace: CanonicalWorkspace = { + ...workspace, + evidence: { + source: { + type: "filesystem", + uri: "psd-clinical/evidence", + patterns: ["documents/**/*.pdf", "notes/*.md"], + max_bytes: 12_000_000, + }, + policy, }, - { - name: "http", - workspace: { - ...workspace, - evidence: { - source: { - type: "http", - uris: ["https://evidence.example/manifest.json", "http://evidence.example/files/list.txt"], - authentication: "signed_urls_file", - connect_timeout_ms: 2_000, - read_timeout_ms: 20_000, - max_bytes: 12_000_000, - max_redirects: 2, - allow_private_hosts: false, - max_cache_bytes: 24_000_000, - }, - policy, - }, - } satisfies CanonicalWorkspace, - }, - { - name: "s3", - workspace: { - ...workspace, - evidence: { - source: { - type: "s3", - uri: "s3://clinical-evidence/published/", - endpoint_url: "https://objects.example", - region: "eu-west-1", - credentials: "static_files", - trusted_endpoint: true, - allow_private_endpoint: false, - allow_insecure_endpoint: false, - max_bytes: 12_000_000, - max_objects: 2_000, - max_pages: 20, - page_size: 100, - }, - policy, - }, - } satisfies CanonicalWorkspace, - }, -] as const; +}; + +beforeEach(() => localStorage.clear()); test("keeps an anonymous user's model selection in browser storage", () => { workspacePreferences.save({ @@ -90,191 +54,73 @@ test("keeps an anonymous user's model selection in browser storage", () => { expect(workspacePreferences.load()).toMatchObject({ model: "glm-5.2" }); }); -test("reloads a canonical workspace draft and discards it by workspace ID", () => { - workspaceDrafts.save({ +test("reloads a bootstrap draft from the v2 browser-storage key and discards it by workspace ID", () => { + workspaceBootstrapDrafts.save(bootstrapDraft); + + expect(workspaceBootstrapDrafts.load("psd-clinical")).toEqual(bootstrapDraft); + expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.psd-clinical")).toContain('"baseCommit"'); + expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.psd-clinical")).not.toContain("baseBlob"); + + workspaceBootstrapDrafts.discard("psd-clinical"); + expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); +}); + +test("purges known v1 update and deletion keys without touching unrelated localStorage", () => { + localStorage.setItem("thothii.workspace-registry.v1.draft.psd-clinical", JSON.stringify({ workspaceId: "psd-clinical", baseCommit: "a".repeat(40), baseBlob: "b".repeat(40), workspace, updatedAt: "2026-08-04T10:00:00.000Z", - }); - - expect(workspaceDrafts.load("psd-clinical")).toMatchObject({ - baseCommit: "a".repeat(40), workspace, - }); - workspaceDrafts.discard("psd-clinical"); - expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); -}); - -test("persists a deletion draft without retaining a workspace definition", () => { - workspaceDeletionDrafts.save({ + })); + localStorage.setItem("thothii.workspace-registry.v1.delete.psd-clinical", JSON.stringify({ id: "psd-clinical", baseCommit: "a".repeat(40), baseBlob: "b".repeat(40), updatedAt: "2026-08-04T10:00:00.000Z", - }); + })); + localStorage.setItem("unrelated", "keep-me"); - expect(workspaceDeletionDrafts.load("psd-clinical")).toEqual({ - id: "psd-clinical", - baseCommit: "a".repeat(40), + expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); + expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull(); + expect(localStorage.getItem("thothii.workspace-registry.v1.delete.psd-clinical")).toBeNull(); + expect(localStorage.getItem("unrelated")).toBe("keep-me"); +}); + +test("rejects draft payloads that still carry baseBlob from the removed update flow", () => { + localStorage.setItem("thothii.workspace-registry.v2.bootstrap.psd-clinical", JSON.stringify({ + ...bootstrapDraft, 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"); + })); + + expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); }); -test("keeps valid canonical diagnostic configuration", () => { - const configured = { - ...workspace, - dwh: { ...workspace.dwh, supported_transports: ["postgres_direct", "rest_api"] }, - diagnostics: { - dwh_rest: { - method: "POST", - path: "/rpc/ping", - auth: "bearer", - response: { database: "database", schema: "schema" }, +test("accepts only the workspace directory evidence root for filesystem sources", () => { + const sanitized = sanitizeCanonicalWorkspace(evidenceWorkspace); + + expect(sanitized).toEqual(evidenceWorkspace); + expect(sanitized).not.toBe(evidenceWorkspace); + expect(sanitized?.evidence).not.toBe(evidenceWorkspace.evidence); +}); + +test("rejects filesystem Evidence that still points at workspace-content", () => { + const invalid: CanonicalWorkspace = { + ...evidenceWorkspace, + evidence: { + source: { + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + patterns: ["documents/**/*.pdf", "notes/*.md"], + max_bytes: 12_000_000, }, + policy, }, - } satisfies CanonicalWorkspace; - workspaceDrafts.save({ - workspaceId: "psd-clinical", - baseCommit: "a".repeat(40), - workspace: configured, - updatedAt: "2026-08-04T10:00:00.000Z", - }); + }; - expect(workspaceDrafts.load("psd-clinical")?.workspace.diagnostics).toEqual(configured.diagnostics); -}); - -beforeEach(() => localStorage.clear()); - -test.each([ - ["an unsupported schema version", { ...workspace, workspace: { ...workspace.workspace, schema_version: 1 } }], - ["an invalid DWH engine", { ...workspace, dwh: { ...workspace.dwh, engine: "mysql" } }], - ["a diagnostic path with a query", { - ...workspace, - diagnostics: { - dwh_rest: { - method: "POST", path: "/rpc/ping?token=secret", auth: "bearer", - response: { database: "database", schema: "schema" }, - }, - }, - }], - ["a diagnostic path with a fragment", { - ...workspace, - diagnostics: { - dwh_rest: { - method: "POST", path: "/rpc/ping#token", auth: "bearer", - response: { database: "database", schema: "schema" }, - }, - }, - }], - ["a diagnostic path outside the declared origin", { - ...workspace, - diagnostics: { - dwh_rest: { - method: "POST", path: "https://outside.example/rpc/ping", auth: "bearer", - response: { database: "database", schema: "schema" }, - }, - }, - }], - ["an unknown secret field", { ...workspace, secret: "must-not-be-persisted" }], -])("rejects a draft with %s", (_reason, invalidWorkspace) => { - workspaceDrafts.save({ - workspaceId: "psd-clinical", - baseCommit: "a".repeat(40), - workspace: invalidWorkspace as CanonicalWorkspace, - updatedAt: "2026-08-04T10:00:00.000Z", - }); - - expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); - expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull(); -}); - -test("rejects a draft that tries to persist removed external semantic configuration fields", () => { - workspaceDrafts.save({ - workspaceId: "psd-clinical", - baseCommit: "a".repeat(40), - workspace: { - ...workspace, - semantic_index: { - vector_store: { - ...workspace.semantic_index.vector_store, - database: "vectors", - }, - embedding: workspace.semantic_index.embedding, - }, - } as CanonicalWorkspace, - updatedAt: "2026-08-04T10:00:00.000Z", - }); - - expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); - expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull(); -}); - - -test.each(evidenceWorkspaces)("deep-sanitizes canonical $name Evidence", ({ workspace: configured }) => { - const sanitized = sanitizeCanonicalWorkspace(configured); - - expect(sanitized).toEqual(configured); - expect(sanitized).not.toBe(configured); - expect(sanitized?.evidence).not.toBe(configured.evidence); - expect(sanitized?.evidence?.source).not.toBe(configured.evidence.source); - expect(sanitized?.evidence?.policy).not.toBe(configured.evidence.policy); -}); - -test.each(evidenceWorkspaces)("saves and reloads canonical $name Evidence", ({ workspace: configured }) => { - workspaceDrafts.save({ - workspaceId: "psd-clinical", - baseCommit: "a".repeat(40), - workspace: configured, - updatedAt: "2026-08-04T10:00:00.000Z", - }); - - expect(workspaceDrafts.load("psd-clinical")?.workspace.evidence).toEqual(configured.evidence); -}); - -test.each([ - ["an unknown Evidence key", { ...evidenceWorkspaces[0].workspace.evidence, extra: "unexpected" }], - ["a secret-shaped source key", { - ...evidenceWorkspaces[1].workspace.evidence, - source: { ...evidenceWorkspaces[1].workspace.evidence.source, signed_urls_file: "/run/secrets/urls" }, - }], - ["an HTTP URI with credentials", { - ...evidenceWorkspaces[1].workspace.evidence, - source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://user:secret@evidence.example/file"] }, - }], - ["an HTTP URI with a signed query", { - ...evidenceWorkspaces[1].workspace.evidence, - source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://evidence.example/file?token=secret"] }, - }], - ["an unsafe S3 URI", { - ...evidenceWorkspaces[2].workspace.evidence, - source: { ...evidenceWorkspaces[2].workspace.evidence.source, uri: "s3://user:secret@clinical-evidence/published/" }, - }], - ["an invalid zero policy value", { - ...evidenceWorkspaces[0].workspace.evidence, - policy: { ...policy, max_chunk_chars: 0 }, - }], - ["an unsafe integer policy value", { - ...evidenceWorkspaces[0].workspace.evidence, - policy: { ...policy, retain_published_generations: Number.MAX_SAFE_INTEGER + 1 }, - }], - ["a malformed source union", { - ...evidenceWorkspaces[0].workspace.evidence, - source: { ...evidenceWorkspaces[0].workspace.evidence.source, uris: ["https://evidence.example/file"] }, - }], -])("rejects %s instead of putting it in browser state", (_reason, evidence) => { - const invalid = { ...workspace, evidence }; expect(sanitizeCanonicalWorkspace(invalid)).toBeUndefined(); - - workspaceDrafts.save({ - workspaceId: "psd-clinical", - baseCommit: "a".repeat(40), - workspace: invalid as CanonicalWorkspace, - updatedAt: "2026-08-04T10:00:00.000Z", - }); - expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); + workspaceBootstrapDrafts.save({ ...bootstrapDraft, workspace: invalid }); + expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); }); test("continues to sanitize workspaces without Evidence", () => { diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index 03e762b1..f6cc262c 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -4,21 +4,15 @@ import type { } from "../api/workspaces"; export { workspacePreferences, type WorkspacePreference } from "./preferences"; -export interface WorkspaceDraft { +export interface WorkspaceBootstrapDraft { workspaceId: string; baseCommit: string; - baseBlob?: string; workspace: CanonicalWorkspace; updatedAt: string; } -/** A publishable deletion intent; it deliberately carries no workspace body. */ -export interface WorkspaceDeletionDraft { - id: string; - baseCommit: string; - baseBlob: string; - updatedAt: string; -} +/** @deprecated Use WorkspaceBootstrapDraft. */ +export type WorkspaceDraft = WorkspaceBootstrapDraft; 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."; @@ -123,9 +117,9 @@ export const workspacePolicyGate = { }, }; -const PREFIX = "thothii.workspace-registry.v1"; -const DRAFT_PREFIX = `${PREFIX}.draft.`; -const DELETE_DRAFT_PREFIX = `${PREFIX}.delete.`; +const LEGACY_PREFIX = "thothii.workspace-registry.v1"; +const PREFIX = "thothii.workspace-registry.v2"; +const DRAFT_PREFIX = `${PREFIX}.bootstrap.`; function storage(): Storage | undefined { try { return window.localStorage; } catch { return undefined; } @@ -234,7 +228,7 @@ function copyFilesystemEvidence(value: unknown, id: string): EvidenceSource | un const maxBytes = positiveInteger(source?.max_bytes); if ( source?.type !== "filesystem" - || uri !== `workspace-content/${id}/evidence` + || uri !== `${id}/evidence` || !Array.isArray(patterns) || patterns.length === 0 || !patterns.every((pattern) => typeof pattern === "string" && isSafeEvidencePattern(pattern)) @@ -480,20 +474,47 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | }; } -function normalize(value: unknown): WorkspaceDraft | undefined { - const source = exactRecord(value, ["workspaceId", "baseCommit", "baseBlob", "workspace", "updatedAt"]); - const workspace = sanitizeCanonicalWorkspace(source?.workspace); +function repairBootstrapWorkspace(value: unknown, id: string): CanonicalWorkspace | undefined { + const direct = sanitizeCanonicalWorkspace(value); + if (direct) return direct; + const source = exactRecord(value, [ + "workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence", + ]); + const evidence = exactRecord(source?.evidence, ["source", "policy"]); + const evidenceSource = exactRecord(evidence?.source, ["type", "uri", "patterns", "max_bytes"]); + if ( + source + && evidence + && evidenceSource?.type === "filesystem" + && typeof evidenceSource.uri === "string" + && /^[a-z][a-z0-9-]{2,62}\/evidence$/.test(evidenceSource.uri) + ) { + return sanitizeCanonicalWorkspace({ + ...source, + evidence: { + ...evidence, + source: { + ...evidenceSource, + uri: `${id}/evidence`, + }, + }, + }); + } + return undefined; +} + +function normalize(value: unknown): WorkspaceBootstrapDraft | undefined { + const source = exactRecord(value, ["workspaceId", "baseCommit", "workspace", "updatedAt"]); const id = workspaceId(source?.workspaceId); + const workspace = id ? repairBootstrapWorkspace(source?.workspace, id) : undefined; 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; const updatedAt = typeof source?.updatedAt === "string" && Number.isFinite(Date.parse(source.updatedAt)) ? source.updatedAt : undefined; - if (!source || !workspace || !id || id !== workspace.workspace.id || !baseCommit || (source.baseBlob !== undefined && !baseBlob) || !updatedAt) { + if (!source || !workspace || !id || id !== workspace.workspace.id || !baseCommit || !updatedAt) { return undefined; } return { workspaceId: id, baseCommit, - ...(baseBlob ? { baseBlob } : {}), workspace, updatedAt, }; @@ -503,28 +524,21 @@ function key(id: string): string { return `${DRAFT_PREFIX}${encodeURIComponent(id)}`; } -function deletionKey(id: string): string { - return `${DELETE_DRAFT_PREFIX}${encodeURIComponent(id)}`; +function purgeLegacyRegistryDrafts(): void { + const store = storage(); + if (!store) return; + const keys = Array.from({ length: store.length }, (_, index) => store.key(index)).filter((value): value is string => value !== null); + for (const entry of keys) { + if (entry.startsWith(`${LEGACY_PREFIX}.draft.`) || entry.startsWith(`${LEGACY_PREFIX}.delete.`)) { + store.removeItem(entry); + } + } } -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 { +/** Browser-only bootstrap drafts. Saving or editing one never calls the server. */ +export const workspaceBootstrapDrafts = { + load(id: string): WorkspaceBootstrapDraft | undefined { + purgeLegacyRegistryDrafts(); try { const raw = storage()?.getItem(key(id)); return raw ? normalize(JSON.parse(raw)) : undefined; @@ -533,35 +547,34 @@ export const workspaceDrafts = { } }, - save(draft: WorkspaceDraft): void { + save(draft: WorkspaceBootstrapDraft): void { + purgeLegacyRegistryDrafts(); const safe = normalize(draft); if (!safe) return; try { storage()?.setItem(key(safe.workspaceId), JSON.stringify(safe)); } catch { /* storage is optional */ } }, discard(id: string): void { + purgeLegacyRegistryDrafts(); try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ } }, }; -/** Browser-only deletion drafts. Task 10 alone may publish one. */ +/** @deprecated Use workspaceBootstrapDrafts. */ +export const workspaceDrafts = workspaceBootstrapDrafts; + + +/** @deprecated Removed in P1.1; existing workspaces are curator-owned and deletions are not drafted in-browser. */ +export interface WorkspaceDeletionDraft { + id: string; + baseCommit: string; + baseBlob: string; + updatedAt: string; +} + +/** @deprecated Removed in P1.1; kept temporarily so legacy imports compile during the UI transition. */ 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 */ } - }, + load(_id: string): WorkspaceDeletionDraft | undefined { return undefined; }, + save(_draft: WorkspaceDeletionDraft): void {}, + discard(_id: string): void {}, };