diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index acae0e53..a84ea83a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,23 +12,36 @@ export class ApiError extends Error { } } -export async function apiFetch(path: string, init?: RequestInit): Promise { - // Only declare a JSON content-type when we actually send a body. Body-less - // POSTs (resume, close) would otherwise make Fastify reject the empty body - // with FST_ERR_CTP_EMPTY_JSON_BODY (400). - const headers: Record = { - ...(init?.headers as Record | undefined), - }; - if (init?.body != null && !("content-type" in headers) && !("Content-Type" in headers)) { - headers["content-type"] = "application/json"; +function requestHeaders(init: RequestInit | undefined): Headers { + const headers = new Headers(init?.headers); + // Fetch supplies the multipart boundary for FormData. Declaring JSON here + // would prevent Fastify from parsing an imported workspace bundle. + if ( + init?.body != null + && !(typeof FormData !== "undefined" && init.body instanceof FormData) + && !headers.has("content-type") + ) { + headers.set("content-type", "application/json"); } - const res = await fetch(joinBackendPath(BASE, path), { ...init, headers }); + return headers; +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(joinBackendPath(BASE, path), { ...init, headers: requestHeaders(init) }); if (!res.ok) { const bodyText = await res.text().catch(() => ""); let payload: unknown; try { payload = bodyText ? JSON.parse(bodyText) : undefined; } catch { payload = undefined; } throw new ApiError(res.status, bodyText, payload); } + return res; +} + +export async function apiFetch(path: string, init?: RequestInit): Promise { + // Only declare a JSON content-type when we actually send a body. Body-less + // POSTs (resume, close) would otherwise make Fastify reject the empty body + // with FST_ERR_CTP_EMPTY_JSON_BODY (400). + const res = await request(path, init); if (res.status === 204) return undefined as T; // Accepted fire-and-forget endpoints may legitimately return 202 with no // representation. Keep apiFetch useful for both 202 and 204 contracts. @@ -36,4 +49,9 @@ export async function apiFetch(path: string, init?: RequestInit): Promise return body ? (JSON.parse(body) as T) : (undefined as T); } +/** Download registry bundles without attempting to parse their ZIP body as JSON. */ +export async function apiFetchBlob(path: string, init?: RequestInit): Promise { + return (await request(path, init)).blob(); +} + export { BASE }; diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 3c6b4181..3f436f04 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,6 +1,6 @@ import { apiFetch } from "./client"; import { getSettings } from "./settings"; -import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences"; +import { workspacePreferences, type WorkspacePreference } from "../workspaces/drafts"; import type { Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse, } from "./types"; diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts new file mode 100644 index 00000000..2b547930 --- /dev/null +++ b/frontend/src/api/workspaces.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "../test/msw"; +import { importWorkspace } from "./workspaces"; + +test("uploads a workspace bundle without JSON content type", async () => { + let contentType: string | null = null; + server.use(http.post("http://localhost:8787/workspaces/import", ({ request }) => { + contentType = request.headers.get("content-type"); + return HttpResponse.json({ draft: { workspace: {} } }); + })); + + await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip", { type: "application/zip" })); + + // 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); +}); diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 43c612bc..db4067a1 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -1,4 +1,194 @@ -import { apiFetch } from "./client"; +import { ApiError, apiFetch, apiFetchBlob } from "./client"; -export const listWorkspaces = () => - apiFetch<{ name: string; file: string }[]>("/workspaces"); +export type WorkspaceErrorCode = + | "workspace_invalid" | "binding_missing" | "workspace_not_activatable" + | "workspace_stale" | "workspace_conflict" | "git_unavailable" + | "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected" + | "connector_unavailable" | "semantic_index_incompatible"; + +export interface RestDiagnosticRequest { + method: "GET" | "POST"; + path: string; + auth: "none" | "bearer" | "x-api-key"; +} + +export interface CanonicalDiagnostics { + dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } }; + vector_rest?: { + metadata: RestDiagnosticRequest & { response: { collection: string; dimensions: string; distance: string } }; + reversible_probe?: RestDiagnosticRequest & { method: "POST"; auth: "bearer" | "x-api-key"; response: { operation: string } }; + }; + embedding?: RestDiagnosticRequest & { response: { model: string; dimensions: string } }; +} + +export interface CanonicalWorkspace { + workspace: { + schema_version: 2; + id: string; + name: string; + description?: string; + language: "en" | "it"; + }; + dwh: { + engine: "postgres"; + database: string; + schema: string; + port?: number; + timeout_ms?: number; + supported_transports: ("postgres_direct" | "rest_api" | "ssh_tunnel")[]; + }; + semantic_index: { + vector_store: { + engine: "pgvector"; + database: string; + schema: string; + collection: string; + dimensions: number; + distance: "cosine" | "l2" | "inner_product"; + port?: number; + timeout_ms?: number; + supported_transports: ("pgvector_direct" | "rest_api" | "ssh_tunnel")[]; + }; + vector_writer?: Record; + embedding: { + provider: "ollama_compatible" | "openai_compatible"; + model: string; + dimensions: number; + timeout_ms?: number; + }; + }; + llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] }; + diagnostics?: CanonicalDiagnostics; +} + +export interface WorkspaceRevision { + id: string; + commit: string; + blob: string; + snapshotPath: string; + state: "operational" | "migration_required"; +} + +export interface WorkspaceSummary { + id: string; + /** Kept for compatibility with the existing workspace selector. */ + name: string; + file: string; + displayName: string; + description?: string; + language: "en" | "it"; + revision: WorkspaceRevision; +} + +export interface WorkspaceRecord { + workspace: CanonicalWorkspace; + revision: WorkspaceRevision; +} + +export interface WorkspaceRegistryStatus { + branch: string; + head?: string; + ahead: number; + behind: number; + degraded: boolean; + lastError?: WorkspaceErrorCode; +} + +export interface WorkspaceDiagnostic { + level: "error" | "warning" | "info"; + code: WorkspaceErrorCode | "binding_ok"; + field?: string; + message: string; +} + +export interface WorkspaceDiagnostics { + activatable: boolean; + diagnostics: WorkspaceDiagnostic[]; +} + +export type PublishWorkspaceRequest = + | { action: "create"; workspace: CanonicalWorkspace; baseCommit: string } + | { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string } + | { action: "delete"; id: string; baseCommit: string; baseBlob: string }; + +export interface WorkspaceConflict { + code: "workspace_conflict"; + fields: string[]; + base: CanonicalWorkspace; + local: CanonicalWorkspace; + remote: CanonicalWorkspace; +} + +export interface WorkspaceApiError { + status: number; + code: WorkspaceErrorCode; + message: string; + fields?: string[]; +} + +const workspaceErrorCodes = new Set([ + "workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale", + "workspace_conflict", "git_unavailable", "git_auth_failed", "git_non_fast_forward", + "git_push_rejected", "connector_unavailable", "semantic_index_incompatible", +]); + +function object(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +/** Sanitized registry error data; it intentionally excludes the raw response body. */ +export function asWorkspaceApiError(error: unknown): WorkspaceApiError | undefined { + if (!(error instanceof ApiError)) return undefined; + const payload = object(error.payload); + const code = payload?.code; + const message = payload?.message; + if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode) || typeof message !== "string") { + return undefined; + } + const fields = Array.isArray(payload?.fields) && payload.fields.every((field) => typeof field === "string") + ? payload.fields + : undefined; + return { status: error.status, code: code as WorkspaceErrorCode, message, ...(fields ? { fields } : {}) }; +} + +/** Narrows a sanitized 409 payload without exposing ApiError's raw body. */ +export function asWorkspaceConflict(error: unknown): WorkspaceConflict | undefined { + const safe = asWorkspaceApiError(error); + if (safe?.code !== "workspace_conflict") return undefined; + const payload = object((error as ApiError).payload); + const fields = safe.fields; + if (!payload || !fields || !object(payload.base) || !object(payload.local) || !object(payload.remote)) return undefined; + return { + code: "workspace_conflict", + fields, + base: payload.base as CanonicalWorkspace, + local: payload.local as CanonicalWorkspace, + remote: payload.remote as CanonicalWorkspace, + }; +} + +export const listWorkspaces = () => apiFetch("/workspaces"); +export const getWorkspace = (id: string) => apiFetch(`/workspaces/${encodeURIComponent(id)}`); +export const getWorkspaceRegistryStatus = () => apiFetch("/workspace-registry/status"); +export const pullWorkspaceRegistry = () => apiFetch("/workspace-registry/pull", { method: "POST" }); +export const validateWorkspace = (workspace: CanonicalWorkspace) => + apiFetch<{ workspace: CanonicalWorkspace; contract: unknown }>("/workspaces/validate", { + method: "POST", body: JSON.stringify({ workspace }), + }); +export const testWorkspace = (id: string) => + apiFetch(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }); +export const publishWorkspace = (request: PublishWorkspaceRequest) => + apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", { + method: "POST", body: JSON.stringify(request), + }); +export const exportWorkspace = (id: string) => + apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`); +export const importWorkspace = (bundle: File) => { + const body = new FormData(); + body.set("bundle", bundle); + return apiFetch<{ draft: { workspace: CanonicalWorkspace; contract?: unknown } }>("/workspaces/import", { + method: "POST", body, + }); +}; diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index aa1c89b7..86faebbd 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -125,6 +125,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("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "psd" }])), + http.get("http://localhost:8787/workspaces/psd", () => HttpResponse.json({ + workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, + revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, + })), http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }], })), @@ -151,6 +155,32 @@ test("footer shows cumulative k-token counters after workspace and context gauge expect(thinking.compareDocumentPosition(gauge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); +test("footer limits model choices to the selected workspace policy", async () => { + server.use( + http.get("http://localhost:8787/settings", () => HttpResponse.json({ + workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", + })), + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, + }])), + http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ + workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, + })), + http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, + { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, + ] })), + ); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + const selector = await screen.findByRole("combobox", { name: "Model" }); + await waitFor(() => expect(selector).toHaveTextContent("GLM-5.2")); + await waitFor(() => expect(selector).not.toHaveTextContent("DeepSeek V4 Pro")); +}); + test("pulses the stop dot only while the harness is working", () => { const { rerender } = render(); const dot = () => diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 7426050a..a503b7b8 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -5,10 +5,10 @@ import { useQuery } from "@tanstack/react-query"; import { postSteer, createSession } from "../api/sessions"; import { ApiError } from "../api/client"; import { getSettings } from "../api/settings"; -import { listWorkspaces } from "../api/workspaces"; +import { getWorkspace, listWorkspaces } from "../api/workspaces"; import { listModels } from "../api/models"; import { useSessionStore } from "../store/sessionStore"; -import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences"; +import { workspacePreferences, type WorkspacePreference } from "../workspaces/drafts"; const THINKING_LEVELS = ["low", "medium", "high"] as const; @@ -84,7 +84,7 @@ export function SteerInput({ setLastUserEntry({ kind: "input", text: trimmed }); onSessionCreating?.(trimmed); try { - const { id } = await createSession({ question: trimmed }); + const { id } = await createSession({ question: trimmed, ...workspacePreferences.load() }); onSessionCreated?.(id); setText(""); } catch (error) { @@ -173,20 +173,33 @@ export function ComposerFooter() { }, [settings]); const workspace = preferences.workspaceId ?? settings?.workspace ?? ""; + const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace); + const { data: workspaceRecord } = useQuery({ + queryKey: ["workspace", workspace], + queryFn: () => getWorkspace(workspace), + // Legacy metadata responses do not carry a registry revision, so retain the + // existing selector behavior without issuing an incompatible detail request. + enabled: Boolean(selectedWorkspace?.revision), + }); const model = preferences.model ?? settings?.model ?? ""; const thinking = preferences.thinking ?? settings?.thinking ?? "medium"; + const allowedModels = workspaceRecord?.workspace.llm_policy.allowed; + const policyModels = allowedModels + ? models.filter((candidate) => allowedModels.includes(`${candidate.provider}/${candidate.id}`)) + : models; + function update(patch: WorkspacePreference) { const next = workspacePreferences.save({ ...preferences, ...patch }); setPreferences(next); } function onModelChange(id: string) { - const m = models.find((x) => x.id === id); + const m = policyModels.find((x) => x.id === id); update({ model: id, provider: m?.provider }); } - const knownModel = models.some((m) => m.id === model); + const knownModel = policyModels.some((m) => m.id === model); const contextPct = tokenUsage && tokenUsage.contextWindow > 0 ? tokenUsage.totalTokens / tokenUsage.contextWindow : 0; @@ -215,12 +228,12 @@ export function ComposerFooter() {
- {models.length === 0 ? ( + {policyModels.length === 0 ? ( ) : ( <> {!knownModel && } - {models.map((m) => ( + {policyModels.map((m) => ( diff --git a/frontend/src/workspaces/drafts.test.ts b/frontend/src/workspaces/drafts.test.ts new file mode 100644 index 00000000..a8fe0c8d --- /dev/null +++ b/frontend/src/workspaces/drafts.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "vitest"; +import type { CanonicalWorkspace } from "../api/workspaces"; +import { workspaceDrafts, workspacePreferences } from "./drafts"; + +const workspace: CanonicalWorkspace = { + workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" }, + dwh: { + engine: "postgres", database: "clinical", schema: "datawarehouse", + supported_transports: ["postgres_direct"], + }, + semantic_index: { + vector_store: { + engine: "pgvector", database: "vectors", schema: "public", collection: "clinical", + dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"], + }, + embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 }, + }, + llm_policy: { allowed: ["zai/glm-5.2"] }, +}; + +test("keeps an anonymous user's model selection in browser storage", () => { + workspacePreferences.save({ + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", + }); + + expect(workspacePreferences.load()).toMatchObject({ model: "glm-5.2" }); +}); + +test("reloads a canonical workspace draft and discards it by workspace ID", () => { + workspaceDrafts.save({ + 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("keeps canonical diagnostic configuration but never stores unknown draft fields", () => { + const configured = { + ...workspace, + diagnostics: { + dwh_rest: { + method: "POST", + path: "/rpc/ping", + auth: "bearer", + response: { database: "database", schema: "schema" }, + }, + }, + secret: "must-not-be-persisted", + } as CanonicalWorkspace & { secret: string }; + 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); + expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).not.toContain("must-not-be-persisted"); +}); diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts new file mode 100644 index 00000000..dd10d768 --- /dev/null +++ b/frontend/src/workspaces/drafts.ts @@ -0,0 +1,179 @@ +import type { CanonicalDiagnostics, CanonicalWorkspace, RestDiagnosticRequest } from "../api/workspaces"; +export { workspacePreferences, type WorkspacePreference } from "./preferences"; + +export interface WorkspaceDraft { + workspaceId: string; + baseCommit: string; + baseBlob?: string; + workspace: CanonicalWorkspace; + updatedAt: string; +} + +const PREFIX = "thothii.workspace-registry.v1"; +const DRAFT_PREFIX = `${PREFIX}.draft.`; + +function storage(): Storage | undefined { + try { return window.localStorage; } catch { return undefined; } +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function copyRequest(value: unknown): RestDiagnosticRequest | undefined { + const source = record(value); + if ( + !source + || (source.method !== "GET" && source.method !== "POST") + || typeof source.path !== "string" + || (source.auth !== "none" && source.auth !== "bearer" && source.auth !== "x-api-key") + ) return undefined; + return { method: source.method, path: source.path, auth: source.auth }; +} + +function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined { + const source = record(value); + if (!source) return undefined; + const diagnostics: CanonicalDiagnostics = {}; + if (source.dwh_rest !== undefined) { + const request = copyRequest(source.dwh_rest); + const response = record(record(source.dwh_rest)?.response); + if (!request || !response || typeof response.database !== "string" || typeof response.schema !== "string") return undefined; + diagnostics.dwh_rest = { ...request, response: { database: response.database, schema: response.schema } }; + } + if (source.vector_rest !== undefined) { + const vector = record(source.vector_rest); + const request = copyRequest(vector?.metadata); + const response = record(record(vector?.metadata)?.response); + if (!vector || !request || !response || typeof response.collection !== "string" || typeof response.dimensions !== "string" || typeof response.distance !== "string") return undefined; + const metadata = { ...request, response: { collection: response.collection, dimensions: response.dimensions, distance: response.distance } }; + let reversibleProbe: NonNullable["reversible_probe"] | undefined; + if (vector.reversible_probe !== undefined) { + const probe = copyRequest(vector.reversible_probe); + const probeResponse = record(record(vector.reversible_probe)?.response); + if (!probe || probe.method !== "POST" || probe.auth === "none" || !probeResponse || typeof probeResponse.operation !== "string") return undefined; + reversibleProbe = { + method: "POST", + path: probe.path, + auth: probe.auth as "bearer" | "x-api-key", + response: { operation: probeResponse.operation }, + }; + } + diagnostics.vector_rest = { metadata, ...(reversibleProbe ? { reversible_probe: reversibleProbe } : {}) }; + } + if (source.embedding !== undefined) { + const request = copyRequest(source.embedding); + const response = record(record(source.embedding)?.response); + if (!request || !response || typeof response.model !== "string" || typeof response.dimensions !== "string") return undefined; + diagnostics.embedding = { ...request, response: { model: response.model, dimensions: response.dimensions } }; + } + return diagnostics; +} + +function copyWorkspace(value: unknown): CanonicalWorkspace | undefined { + const source = record(value); + const metadata = record(source?.workspace); + const dwh = record(source?.dwh); + const semanticIndex = record(source?.semantic_index); + const vectorStore = record(semanticIndex?.vector_store); + const embedding = record(semanticIndex?.embedding); + const policy = record(source?.llm_policy); + const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics); + if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined; + if ( + typeof metadata.schema_version !== "number" || typeof metadata.id !== "string" || typeof metadata.name !== "string" + || typeof metadata.language !== "string" || typeof dwh.database !== "string" || typeof dwh.schema !== "string" + || typeof vectorStore.database !== "string" || typeof vectorStore.schema !== "string" + || typeof vectorStore.collection !== "string" || typeof vectorStore.dimensions !== "number" + || typeof embedding.model !== "string" || typeof embedding.dimensions !== "number" + || !Array.isArray(dwh.supported_transports) || !Array.isArray(vectorStore.supported_transports) || !Array.isArray(policy.allowed) + ) return undefined; + if (source?.diagnostics !== undefined && !diagnostics) return undefined; + return { + workspace: { + schema_version: metadata.schema_version as 2, + id: metadata.id, + name: metadata.name, + ...(typeof metadata.description === "string" ? { description: metadata.description } : {}), + language: metadata.language as "en" | "it", + }, + dwh: { + engine: dwh.engine as "postgres", + database: dwh.database, + schema: dwh.schema, + ...(typeof dwh.port === "number" ? { port: dwh.port } : {}), + ...(typeof dwh.timeout_ms === "number" ? { timeout_ms: dwh.timeout_ms } : {}), + supported_transports: dwh.supported_transports.filter((transport): transport is "postgres_direct" | "rest_api" | "ssh_tunnel" => + transport === "postgres_direct" || transport === "rest_api" || transport === "ssh_tunnel"), + }, + semantic_index: { + vector_store: { + engine: vectorStore.engine as "pgvector", + database: vectorStore.database, + schema: vectorStore.schema, + collection: vectorStore.collection, + dimensions: vectorStore.dimensions, + distance: vectorStore.distance as "cosine" | "l2" | "inner_product", + ...(typeof vectorStore.port === "number" ? { port: vectorStore.port } : {}), + ...(typeof vectorStore.timeout_ms === "number" ? { timeout_ms: vectorStore.timeout_ms } : {}), + supported_transports: vectorStore.supported_transports.filter((transport): transport is "pgvector_direct" | "rest_api" | "ssh_tunnel" => + transport === "pgvector_direct" || transport === "rest_api" || transport === "ssh_tunnel"), + }, + ...(record(semanticIndex.vector_writer) ? { vector_writer: {} } : {}), + embedding: { + provider: embedding.provider as "ollama_compatible" | "openai_compatible", + model: embedding.model, + dimensions: embedding.dimensions, + ...(typeof embedding.timeout_ms === "number" ? { timeout_ms: embedding.timeout_ms } : {}), + }, + }, + llm_policy: { + ...(typeof policy.default === "string" ? { default: policy.default as `${string}/${string}` } : {}), + allowed: policy.allowed.filter((model): model is `${string}/${string}` => typeof model === "string"), + }, + ...(diagnostics ? { diagnostics } : {}), + }; +} + +function normalize(value: unknown): WorkspaceDraft | undefined { + const source = record(value); + const workspace = copyWorkspace(source?.workspace); + if (!source || !workspace || typeof source.workspaceId !== "string" || typeof source.baseCommit !== "string" || typeof source.updatedAt !== "string") { + return undefined; + } + return { + workspaceId: source.workspaceId, + baseCommit: source.baseCommit, + ...(typeof source.baseBlob === "string" ? { baseBlob: source.baseBlob } : {}), + workspace, + updatedAt: source.updatedAt, + }; +} + +function key(id: string): string { + return `${DRAFT_PREFIX}${encodeURIComponent(id)}`; +} + +/** Browser-only workspace drafts. Saving or editing one never calls the server. */ +export const workspaceDrafts = { + load(id: string): WorkspaceDraft | undefined { + try { + const raw = storage()?.getItem(key(id)); + return raw ? normalize(JSON.parse(raw)) : undefined; + } catch { + return undefined; + } + }, + + save(draft: WorkspaceDraft): void { + const safe = normalize(draft); + if (!safe) return; + try { storage()?.setItem(key(safe.workspaceId), JSON.stringify(safe)); } catch { /* storage is optional */ } + }, + + discard(id: string): void { + try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ } + }, +};