feat: add read-only workspace and secret management UI
This commit is contained in:
@@ -3,205 +3,98 @@ import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import {
|
||||
asWorkspaceApiError,
|
||||
forgetWorkspaceSecret,
|
||||
getWorkspace,
|
||||
importWorkspace,
|
||||
getWorkspaceRuntimeConfiguration,
|
||||
listWorkspaces,
|
||||
publishWorkspace,
|
||||
validateWorkspace,
|
||||
type CanonicalWorkspace,
|
||||
saveWorkspaceSecrets,
|
||||
} from "./workspaces";
|
||||
|
||||
const workspace = canonicalWorkspaceFixture("psd-clinical");
|
||||
const revision = workspaceRevisionFixture("psd-clinical");
|
||||
|
||||
const readySummary = workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical",
|
||||
description: "Clinical workspace",
|
||||
const runtimeConfiguration = {
|
||||
workspaceId: "psd-clinical",
|
||||
revision,
|
||||
});
|
||||
|
||||
const evidenceWorkspace = {
|
||||
...workspace,
|
||||
evidence: {
|
||||
source: {
|
||||
type: "filesystem",
|
||||
uri: "psd-clinical/evidence",
|
||||
patterns: ["**/*.md"],
|
||||
max_bytes: 10 * 1024 * 1024,
|
||||
},
|
||||
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
||||
},
|
||||
} satisfies CanonicalWorkspace;
|
||||
|
||||
test("decodes catalog-driven workspace summaries with exact root descriptor paths", async () => {
|
||||
server.use(http.get("/api/workspaces", () => HttpResponse.json([
|
||||
readySummary,
|
||||
workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
])));
|
||||
|
||||
await expect(listWorkspaces()).resolves.toEqual([
|
||||
readySummary,
|
||||
workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a summary with the removed language field", { ...readySummary, language: "en" }],
|
||||
["a non-canonical descriptor path", { ...readySummary, file: "psd-clinical.yaml" }],
|
||||
["a ready summary without a revision", { ...readySummary, revision: undefined }],
|
||||
["a configuration_required summary with a revision", {
|
||||
...workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
revision,
|
||||
configurationState: "configuration_required",
|
||||
requirements: [{
|
||||
id: "dwh.password",
|
||||
connector: "dwh",
|
||||
label: "Data warehouse password",
|
||||
description: "Password used by the selected data warehouse connection.",
|
||||
input: "password",
|
||||
required: true,
|
||||
configured: false,
|
||||
}],
|
||||
])("rejects %s", async (_case, malformedSummary) => {
|
||||
server.use(http.get("/api/workspaces", () => HttpResponse.json([malformedSummary])));
|
||||
} as const;
|
||||
|
||||
await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary");
|
||||
test("decodes read-only workspace summaries with a revision in every readiness state", async () => {
|
||||
const ready = workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical",
|
||||
revision,
|
||||
});
|
||||
const needsSecrets = {
|
||||
...ready,
|
||||
configurationState: "configuration_required" as const,
|
||||
};
|
||||
server.use(http.get("/api/workspaces", () => HttpResponse.json([ready, needsSecrets])));
|
||||
|
||||
await expect(listWorkspaces()).resolves.toEqual([ready, needsSecrets]);
|
||||
});
|
||||
|
||||
test("uploads a workspace bundle without JSON content type", async () => {
|
||||
let contentType: string | null = null;
|
||||
server.use(http.post("/api/workspaces/import", ({ request }) => {
|
||||
contentType = request.headers.get("content-type");
|
||||
return HttpResponse.json({ draft: { workspace } });
|
||||
}));
|
||||
|
||||
await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip", { type: "application/zip" }));
|
||||
|
||||
expect(contentType ?? "").not.toMatch(/application\/json/i);
|
||||
});
|
||||
|
||||
test("sanitizes imported filesystem Evidence only when it uses the workspace directory root", async () => {
|
||||
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
|
||||
draft: { workspace: evidenceWorkspace, contract: { variables: [] } },
|
||||
})));
|
||||
|
||||
const result = await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip"));
|
||||
|
||||
expect(result.draft.workspace.evidence).toEqual(evidenceWorkspace.evidence);
|
||||
expect(result.draft.workspace).not.toBe(evidenceWorkspace);
|
||||
});
|
||||
|
||||
test("rejects imported filesystem Evidence that still points at workspace-content", async () => {
|
||||
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
|
||||
draft: {
|
||||
workspace: {
|
||||
...evidenceWorkspace,
|
||||
evidence: {
|
||||
...evidenceWorkspace.evidence,
|
||||
source: {
|
||||
...evidenceWorkspace.evidence.source,
|
||||
uri: "workspace-content/psd-clinical/evidence",
|
||||
},
|
||||
},
|
||||
},
|
||||
contract: { variables: [] },
|
||||
},
|
||||
})));
|
||||
|
||||
await expect(importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip")))
|
||||
.rejects.toThrow("invalid imported workspace draft");
|
||||
});
|
||||
|
||||
test("accepts the atomic schema-v3 workspace revision contract without historical state", async () => {
|
||||
test("accepts the immutable workspace revision contract", async () => {
|
||||
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision })));
|
||||
|
||||
await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision });
|
||||
});
|
||||
|
||||
test("accepts a Qdrant collection using the canonical hyphenated workspace name", async () => {
|
||||
const hyphenatedCollection = {
|
||||
...workspace,
|
||||
semantic_index: {
|
||||
...workspace.semantic_index,
|
||||
vector_store: { ...workspace.semantic_index.vector_store, collection: "psd-clinical" },
|
||||
},
|
||||
} satisfies CanonicalWorkspace;
|
||||
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||
workspace: hyphenatedCollection,
|
||||
revision,
|
||||
})));
|
||||
test("decodes runtime requirements but rejects any secret value returned by the server", async () => {
|
||||
server.use(http.get(
|
||||
"/api/workspaces/psd-clinical/runtime-configuration",
|
||||
() => HttpResponse.json(runtimeConfiguration),
|
||||
));
|
||||
await expect(getWorkspaceRuntimeConfiguration("psd-clinical"))
|
||||
.resolves.toEqual(runtimeConfiguration);
|
||||
|
||||
await expect(getWorkspace("psd-clinical")).resolves.toEqual({
|
||||
workspace: hyphenatedCollection,
|
||||
revision,
|
||||
});
|
||||
server.use(http.get(
|
||||
"/api/workspaces/psd-clinical/runtime-configuration",
|
||||
() => HttpResponse.json({
|
||||
...runtimeConfiguration,
|
||||
requirements: [{ ...runtimeConfiguration.requirements[0], value: "leaked-secret" }],
|
||||
}),
|
||||
));
|
||||
await expect(getWorkspaceRuntimeConfiguration("psd-clinical"))
|
||||
.rejects.toThrow("invalid runtime configuration");
|
||||
});
|
||||
|
||||
test("sanitizes read and validate responses while preserving directory-based Evidence", async () => {
|
||||
server.use(
|
||||
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, revision })),
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: evidenceWorkspace, contract: {} })),
|
||||
);
|
||||
|
||||
const read = await getWorkspace("psd-clinical");
|
||||
const validated = await validateWorkspace(evidenceWorkspace);
|
||||
|
||||
expect(read.workspace.evidence).toEqual(evidenceWorkspace.evidence);
|
||||
expect(read.workspace).not.toBe(evidenceWorkspace);
|
||||
expect(validated.workspace.evidence).toEqual(evidenceWorkspace.evidence);
|
||||
});
|
||||
|
||||
test("publishes only bootstrap create requests", async () => {
|
||||
let sent: unknown;
|
||||
server.use(http.post("/api/workspaces/publish", async ({ request }) => {
|
||||
sent = await request.json();
|
||||
return HttpResponse.json({ revision });
|
||||
test("blind secret replacement sends values once and returns status only", async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(http.put("/api/workspaces/psd-clinical/secrets", async ({ request }) => {
|
||||
requestBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
...runtimeConfiguration,
|
||||
configurationState: "ready",
|
||||
requirements: [{ ...runtimeConfiguration.requirements[0], configured: true }],
|
||||
});
|
||||
}));
|
||||
|
||||
await expect(publishWorkspace({
|
||||
action: "create",
|
||||
workspace: evidenceWorkspace,
|
||||
baseCommit: revision.commit,
|
||||
})).resolves.toEqual({ revision });
|
||||
|
||||
expect(sent).toEqual({
|
||||
action: "create",
|
||||
workspace: { ...evidenceWorkspace },
|
||||
baseCommit: revision.commit,
|
||||
const response = await saveWorkspaceSecrets("psd-clinical", {
|
||||
"dwh.password": "one-time-value",
|
||||
});
|
||||
expect(sent).not.toHaveProperty("baseBlob");
|
||||
|
||||
expect(requestBody).toEqual({ values: { "dwh.password": "one-time-value" } });
|
||||
expect(response.configurationState).toBe("ready");
|
||||
expect(JSON.stringify(response)).not.toContain("one-time-value");
|
||||
});
|
||||
|
||||
test("rejects a publish response with a malformed revision", async () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
||||
revision: { ...revision, commit: "not-a-commit" },
|
||||
})));
|
||||
test("forget targets one declared requirement", async () => {
|
||||
let called = false;
|
||||
server.use(http.delete("/api/workspaces/psd-clinical/secrets/dwh.password", () => {
|
||||
called = true;
|
||||
return HttpResponse.json(runtimeConfiguration);
|
||||
}));
|
||||
|
||||
await expect(publishWorkspace({
|
||||
action: "create",
|
||||
workspace: evidenceWorkspace,
|
||||
baseCommit: revision.commit,
|
||||
})).rejects.toThrow("invalid workspace revision");
|
||||
});
|
||||
|
||||
test("decodes workspace_curator_owned safely without conflict fields", async () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
||||
code: "workspace_curator_owned",
|
||||
message: "Existing descriptors are curator-owned.",
|
||||
}, { status: 409 })));
|
||||
|
||||
const error = await publishWorkspace({
|
||||
action: "create",
|
||||
workspace,
|
||||
baseCommit: revision.commit,
|
||||
}).catch((cause: unknown) => cause);
|
||||
|
||||
expect(asWorkspaceApiError(error)).toEqual({
|
||||
status: 409,
|
||||
code: "workspace_curator_owned",
|
||||
message: "Existing descriptors are curator-owned.",
|
||||
});
|
||||
await expect(forgetWorkspaceSecret("psd-clinical", "dwh.password"))
|
||||
.resolves.toEqual(runtimeConfiguration);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ApiError, apiFetch, apiFetchBlob } from "./client";
|
||||
import { ApiError, apiFetch } from "./client";
|
||||
import { sanitizeCanonicalWorkspace } from "../workspaces/drafts";
|
||||
|
||||
export type WorkspaceErrorCode =
|
||||
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
|
||||
| "workspace_stale" | "workspace_conflict" | "workspace_curator_owned" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
|
||||
| "workspace_stale" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward"
|
||||
| "connector_unavailable" | "semantic_index_incompatible";
|
||||
|
||||
export interface RestDiagnosticRequest {
|
||||
@@ -124,6 +124,11 @@ export interface WorkspaceRegistryStatus {
|
||||
behind: number;
|
||||
degraded: boolean;
|
||||
lastError?: WorkspaceErrorCode;
|
||||
repository?: {
|
||||
host: string;
|
||||
repository: string;
|
||||
transport: "https" | "ssh" | "local";
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceDiagnostic {
|
||||
@@ -138,10 +143,21 @@ export interface WorkspaceDiagnostics {
|
||||
diagnostics: WorkspaceDiagnostic[];
|
||||
}
|
||||
|
||||
export interface PublishWorkspaceRequest {
|
||||
action: "create";
|
||||
workspace: CanonicalWorkspace;
|
||||
baseCommit: string;
|
||||
export interface WorkspaceSecretRequirement {
|
||||
id: string;
|
||||
connector: "dwh" | "evidence";
|
||||
label: string;
|
||||
description: string;
|
||||
input: "password" | "textarea";
|
||||
required: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeConfiguration {
|
||||
workspaceId: string;
|
||||
revision: WorkspaceRevision;
|
||||
configurationState: "ready" | "configuration_required";
|
||||
requirements: WorkspaceSecretRequirement[];
|
||||
}
|
||||
|
||||
export interface WorkspaceApiError {
|
||||
@@ -153,8 +169,7 @@ export interface WorkspaceApiError {
|
||||
|
||||
const workspaceErrorCodes = new Set<WorkspaceErrorCode>([
|
||||
"workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
|
||||
"workspace_conflict", "workspace_curator_owned", "git_unavailable", "git_auth_failed",
|
||||
"git_non_fast_forward", "git_push_rejected", "connector_unavailable",
|
||||
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
|
||||
"semantic_index_incompatible",
|
||||
]);
|
||||
|
||||
@@ -204,9 +219,7 @@ function workspaceSummary(value: unknown): WorkspaceSummary | undefined {
|
||||
? undefined
|
||||
: workspaceRevision(source.revision, id);
|
||||
if (
|
||||
(configurationState === "ready" && !revision)
|
||||
|| (configurationState === "configuration_required" && source.revision !== undefined)
|
||||
|| (source.revision !== undefined && !revision)
|
||||
!revision
|
||||
) return undefined;
|
||||
return {
|
||||
id,
|
||||
@@ -215,7 +228,7 @@ function workspaceSummary(value: unknown): WorkspaceSummary | undefined {
|
||||
displayName: displayName as string,
|
||||
...(description === undefined ? {} : { description: description as string }),
|
||||
configurationState,
|
||||
...(revision ? { revision } : {}),
|
||||
revision,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,38 +259,6 @@ function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
function requireImportedWorkspace(value: unknown): CanonicalWorkspace {
|
||||
const direct = sanitizeCanonicalWorkspace(value);
|
||||
if (direct) return direct;
|
||||
const source = object(value);
|
||||
const metadata = object(source?.workspace);
|
||||
const evidence = object(source?.evidence);
|
||||
const evidenceSource = object(evidence?.source);
|
||||
const workspaceId = typeof metadata?.id === "string" ? metadata.id : undefined;
|
||||
const uri = typeof evidenceSource?.uri === "string" ? evidenceSource.uri : undefined;
|
||||
if (
|
||||
workspaceId
|
||||
&& /^[a-z][a-z0-9-]{2,62}$/.test(workspaceId)
|
||||
&& evidenceSource?.type === "filesystem"
|
||||
&& typeof uri === "string"
|
||||
&& /^[a-z][a-z0-9-]{2,62}\/evidence$/.test(uri)
|
||||
) {
|
||||
const repaired = {
|
||||
...source,
|
||||
evidence: {
|
||||
...evidence,
|
||||
source: {
|
||||
...evidenceSource,
|
||||
uri: `${workspaceId}/evidence`,
|
||||
},
|
||||
},
|
||||
};
|
||||
const sanitized = sanitizeCanonicalWorkspace(repaired);
|
||||
if (sanitized) return sanitized;
|
||||
}
|
||||
throw new Error("Workspace API returned an invalid imported workspace draft");
|
||||
}
|
||||
|
||||
export const listWorkspaces = async (): Promise<WorkspaceSummary[]> => {
|
||||
const response = await apiFetch<unknown>("/workspaces");
|
||||
if (!Array.isArray(response)) throw new Error("Workspace API returned an invalid workspace summary");
|
||||
@@ -309,43 +290,61 @@ export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
|
||||
});
|
||||
const source = object(response);
|
||||
if (!source) throw new Error("Workspace API returned an invalid validation result");
|
||||
return { workspace: requireImportedWorkspace(source.workspace), contract: source.contract };
|
||||
return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract };
|
||||
};
|
||||
|
||||
export const testWorkspace = (id: string) =>
|
||||
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
|
||||
|
||||
export const publishWorkspace = async (request: PublishWorkspaceRequest) => {
|
||||
const safeRequest: PublishWorkspaceRequest = { ...request, workspace: requireCanonicalWorkspace(request.workspace) };
|
||||
const response = await apiFetch<unknown>("/workspaces/publish", {
|
||||
method: "POST", body: JSON.stringify(safeRequest),
|
||||
function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration {
|
||||
const source = exactObject(value, [
|
||||
"workspaceId", "revision", "configurationState", "requirements",
|
||||
]);
|
||||
if (
|
||||
!source
|
||||
|| source.workspaceId !== expectedId
|
||||
|| (source.configurationState !== "ready" && source.configurationState !== "configuration_required")
|
||||
|| !Array.isArray(source.requirements)
|
||||
) throw new Error("Workspace API returned an invalid runtime configuration");
|
||||
const requirements = source.requirements.map((value) => {
|
||||
const requirement = exactObject(value, [
|
||||
"id", "connector", "label", "description", "input", "required", "configured",
|
||||
]);
|
||||
if (
|
||||
!requirement
|
||||
|| typeof requirement.id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,127}$/.test(requirement.id)
|
||||
|| (requirement.connector !== "dwh" && requirement.connector !== "evidence")
|
||||
|| typeof requirement.label !== "string" || requirement.label.length === 0
|
||||
|| typeof requirement.description !== "string" || requirement.description.length === 0
|
||||
|| (requirement.input !== "password" && requirement.input !== "textarea")
|
||||
|| typeof requirement.required !== "boolean"
|
||||
|| typeof requirement.configured !== "boolean"
|
||||
) throw new Error("Workspace API returned an invalid runtime configuration");
|
||||
return requirement as unknown as WorkspaceSecretRequirement;
|
||||
});
|
||||
if (response === undefined) return undefined;
|
||||
const source = exactObject(response, ["revision"]);
|
||||
if (!source) throw new Error("Workspace API returned an invalid publish result");
|
||||
return { revision: requireWorkspaceRevision(source.revision, safeRequest.workspace.workspace.id) };
|
||||
};
|
||||
|
||||
export const exportWorkspace = (id: string) =>
|
||||
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
|
||||
|
||||
export const importWorkspace = async (bundle: File) => {
|
||||
const body = new FormData();
|
||||
body.set("bundle", bundle);
|
||||
const response = await apiFetch<unknown>("/workspaces/import", { method: "POST", body });
|
||||
const source = exactObject(response, ["draft"]);
|
||||
const draft = exactObject(source?.draft, ["workspace", "contract"]);
|
||||
if (!source || !draft) throw new Error("Workspace API returned an invalid imported workspace draft");
|
||||
let workspace: CanonicalWorkspace;
|
||||
try {
|
||||
workspace = requireImportedWorkspace(draft.workspace);
|
||||
} catch {
|
||||
throw new Error("Workspace API returned an invalid imported workspace draft");
|
||||
}
|
||||
return {
|
||||
draft: {
|
||||
workspace,
|
||||
...(draft.contract === undefined ? {} : { contract: draft.contract }),
|
||||
},
|
||||
workspaceId: expectedId,
|
||||
revision: requireWorkspaceRevision(source.revision, expectedId),
|
||||
configurationState: source.configurationState,
|
||||
requirements,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const getWorkspaceRuntimeConfiguration = async (id: string) => runtimeConfiguration(
|
||||
await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/runtime-configuration`),
|
||||
id,
|
||||
);
|
||||
|
||||
export const saveWorkspaceSecrets = async (id: string, values: Readonly<Record<string, string>>) => (
|
||||
runtimeConfiguration(await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/secrets`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ values }),
|
||||
}), id)
|
||||
);
|
||||
|
||||
export const forgetWorkspaceSecret = async (id: string, requirementId: string) => (
|
||||
runtimeConfiguration(await apiFetch<unknown>(
|
||||
`/workspaces/${encodeURIComponent(id)}/secrets/${encodeURIComponent(requirementId)}`,
|
||||
{ method: "DELETE" },
|
||||
), id)
|
||||
);
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { CanonicalWorkspace } from "../api/workspaces";
|
||||
import type { WorkspaceBootstrapDraft } from "../workspaces/drafts";
|
||||
import { workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { WorkspaceEditor } from "./WorkspaceEditor";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
workspace: { schema_version: 3, id: "bootstrap-slot", name: "Bootstrap slot", description: "Needs configuration", language: "en" },
|
||||
dwh: {
|
||||
engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432,
|
||||
supported_transports: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine",
|
||||
},
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
|
||||
const evidenceWorkspace: CanonicalWorkspace = {
|
||||
...workspace,
|
||||
workspace: { ...workspace.workspace, id: "psd-clinical", name: "PSD Clinical", description: "Clinical workspace" },
|
||||
evidence: {
|
||||
source: {
|
||||
type: "filesystem",
|
||||
uri: "psd-clinical/evidence",
|
||||
patterns: ["documents/**/*.pdf"],
|
||||
max_bytes: 12_000_000,
|
||||
},
|
||||
policy: { max_chunk_chars: 8_000, retain_published_generations: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
const draft: WorkspaceBootstrapDraft = {
|
||||
workspaceId: "bootstrap-slot",
|
||||
baseCommit: "a".repeat(40),
|
||||
workspace,
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
};
|
||||
|
||||
test("bootstrap mode locks catalog metadata and saves only the local bootstrap draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveDraft = vi.fn();
|
||||
render(
|
||||
<WorkspaceEditor
|
||||
mode={{
|
||||
kind: "bootstrap",
|
||||
catalog: workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
draft,
|
||||
}}
|
||||
onSaveDraft={onSaveDraft}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Workspace ID")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Workspace name")).toHaveValue("Bootstrap slot");
|
||||
expect(screen.getByLabelText("Workspace name")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Description")).toHaveValue("Needs configuration");
|
||||
expect(screen.getByLabelText("Description")).toBeDisabled();
|
||||
|
||||
await user.clear(screen.getByLabelText("Vector collection"));
|
||||
await user.type(screen.getByLabelText("Vector collection"), "research-docs");
|
||||
await user.click(screen.getByRole("button", { name: "Save draft" }));
|
||||
|
||||
expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({
|
||||
baseCommit: "a".repeat(40),
|
||||
workspaceId: "bootstrap-slot",
|
||||
workspace: expect.objectContaining({
|
||||
semantic_index: expect.objectContaining({
|
||||
vector_store: expect.objectContaining({ collection: "research-docs" }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
expect(onSaveDraft.mock.calls[0]?.[0]).not.toHaveProperty("baseBlob");
|
||||
});
|
||||
|
||||
test("read-only mode shows evidence and curator git guidance without mutation actions", () => {
|
||||
render(
|
||||
<WorkspaceEditor
|
||||
mode={{
|
||||
kind: "read_only",
|
||||
catalog: workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical",
|
||||
description: "Clinical workspace",
|
||||
revision: workspaceRevisionFixture("psd-clinical"),
|
||||
}),
|
||||
record: {
|
||||
workspace: evidenceWorkspace,
|
||||
revision: workspaceRevisionFixture("psd-clinical"),
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("DWH database")).toHaveValue("clinical");
|
||||
expect(screen.getByLabelText("DWH database")).toHaveAttribute("readonly");
|
||||
const summary = screen.getByRole("region", { name: "Evidence" });
|
||||
expect(summary).toHaveTextContent("filesystem");
|
||||
expect(summary).toHaveTextContent("psd-clinical/evidence");
|
||||
expect(summary).toHaveTextContent("8,000");
|
||||
expect(summary).toHaveTextContent("5");
|
||||
expect(screen.getByText("Curator workflow")).toBeVisible();
|
||||
expect(screen.getByText(/edit psd-clinical\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Save draft" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /create workspace|publish/i })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useState, type ChangeEvent, type ReactNode } from "react";
|
||||
import type { CanonicalWorkspace, PublishWorkspaceRequest, WorkspaceRecord, WorkspaceSummary } from "../api/workspaces";
|
||||
import type { WorkspaceBootstrapDraft } from "../workspaces/drafts";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
type FieldErrors = Record<string, string>;
|
||||
|
||||
export type WorkspaceEditorMode =
|
||||
| { kind: "bootstrap"; catalog: WorkspaceSummary; draft: WorkspaceBootstrapDraft }
|
||||
| { kind: "read_only"; catalog: WorkspaceSummary; record: WorkspaceRecord };
|
||||
|
||||
type BootstrapEditorProps = {
|
||||
mode: Extract<WorkspaceEditorMode, { kind: "bootstrap" }>;
|
||||
onSaveDraft?: (draft: WorkspaceBootstrapDraft) => void;
|
||||
onRequestCreate?: (request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) => void;
|
||||
};
|
||||
|
||||
type ReadOnlyEditorProps = {
|
||||
mode: Extract<WorkspaceEditorMode, { kind: "read_only" }>;
|
||||
/** Absent in read-only mode; declared so the union is uniformly addressable. */
|
||||
onSaveDraft?: never;
|
||||
onRequestCreate?: never;
|
||||
};
|
||||
|
||||
export type WorkspaceEditorProps = BootstrapEditorProps | ReadOnlyEditorProps;
|
||||
|
||||
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive";
|
||||
|
||||
function positiveInteger(value: number | undefined, label: string, max = Number.MAX_SAFE_INTEGER): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isInteger(value) || value < 1 || value > max) {
|
||||
return max === 65_535 ? "Port must be between 1 and 65535" : `${label} must be a positive whole number`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validate(workspace: CanonicalWorkspace): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.workspace.id)) {
|
||||
errors["workspace.id"] = "Use 3–63 lowercase letters, numbers, or hyphens; start with a letter";
|
||||
}
|
||||
if (!workspace.workspace.name.trim()) errors["workspace.name"] = "Workspace name is required";
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.database)) errors["dwh.database"] = "Use a database identifier";
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.schema)) errors["dwh.schema"] = "Use a schema identifier";
|
||||
if (!workspace.dwh.supported_transports.length) errors["dwh.transport"] = "Choose at least one DWH transport";
|
||||
const dwhPort = positiveInteger(workspace.dwh.port, "DWH port", 65_535);
|
||||
if (dwhPort) errors["dwh.port"] = dwhPort;
|
||||
const dwhTimeout = positiveInteger(workspace.dwh.timeout_ms, "DWH timeout");
|
||||
if (dwhTimeout) errors["dwh.timeout"] = dwhTimeout;
|
||||
if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a canonical collection name";
|
||||
if (!workspace.llm_policy.allowed.length || workspace.llm_policy.allowed.some((model) => !/^[^/\s]+\/[^/\s]+$/.test(model))) {
|
||||
errors["llm.allowed"] = "Use provider/model entries separated by commas";
|
||||
}
|
||||
if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) {
|
||||
errors["llm.default"] = "Default model must be in the allowlist";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function selectedValues(event: ChangeEvent<HTMLSelectElement>): string[] {
|
||||
return Array.from(event.currentTarget.selectedOptions, (option) => option.value);
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: string): number | undefined {
|
||||
return value.trim() === "" ? undefined : Number(value);
|
||||
}
|
||||
|
||||
function coerceCatalogMetadata(workspace: CanonicalWorkspace, catalog: WorkspaceSummary): CanonicalWorkspace {
|
||||
return {
|
||||
...workspace,
|
||||
workspace: {
|
||||
...workspace.workspace,
|
||||
id: catalog.id,
|
||||
name: catalog.displayName,
|
||||
description: catalog.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: (props: { id: string; describedBy?: string; invalid: boolean }) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const errorId = `${id}-error`;
|
||||
const hintId = `${id}-hint`;
|
||||
const describedBy = [hint ? hintId : undefined, error ? errorId : undefined].filter(Boolean).join(" ") || undefined;
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-semibold text-foreground" htmlFor={id}>{label}</label>
|
||||
{children({ id, describedBy, invalid: Boolean(error) })}
|
||||
{hint && <p id={hintId} className="text-xs text-muted-foreground">{hint}</p>}
|
||||
{error && <p id={errorId} role="alert" className="text-xs font-medium text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="border-t border-border/70 pt-5 first:border-t-0 first:pt-0" aria-label={title}>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">{title}</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceSummary({ evidence }: { evidence: NonNullable<CanonicalWorkspace["evidence"]> }) {
|
||||
const sourceIdentity = evidence.source.type === "http"
|
||||
? `${evidence.source.uris.length} canonical URI${evidence.source.uris.length === 1 ? "" : "s"}`
|
||||
: evidence.source.uri;
|
||||
return (
|
||||
<Section title="Evidence">
|
||||
<dl className="grid gap-2 text-sm sm:col-span-2 sm:grid-cols-2">
|
||||
<div><dt className="font-medium text-foreground">Source type</dt><dd className="text-muted-foreground">{evidence.source.type}</dd></div>
|
||||
<div><dt className="font-medium text-foreground">Source</dt><dd className="break-all text-muted-foreground">{sourceIdentity}</dd></div>
|
||||
<div><dt className="font-medium text-foreground">Chunk size</dt><dd className="text-muted-foreground">{evidence.policy.max_chunk_chars.toLocaleString("en-US")}</dd></div>
|
||||
<div><dt className="font-medium text-foreground">Retention</dt><dd className="text-muted-foreground">{evidence.policy.retain_published_generations.toLocaleString("en-US")}</dd></div>
|
||||
</dl>
|
||||
<p className="sm:col-span-2 text-sm text-muted-foreground">Evidence summary is read-only. Curate source files or URIs in Git.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
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<CanonicalWorkspace>(initialWorkspace);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const readOnly = !bootstrapMode;
|
||||
const allowedModels = useMemo(() => workspace.llm_policy.allowed.join(", "), [workspace.llm_policy.allowed]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspace(props.mode.kind === "bootstrap"
|
||||
? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog)
|
||||
: props.mode.record.workspace);
|
||||
setErrors({});
|
||||
}, [bootstrapMode, props.mode]);
|
||||
|
||||
function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) {
|
||||
if (readOnly) return;
|
||||
setWorkspace((previous) => {
|
||||
const next = coerceCatalogMetadata(change(previous), props.mode.catalog);
|
||||
setErrors(validate(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function currentDraft(): WorkspaceBootstrapDraft {
|
||||
if (props.mode.kind !== "bootstrap") throw new Error("Read-only workspaces cannot create drafts");
|
||||
return {
|
||||
workspaceId: props.mode.catalog.id,
|
||||
baseCommit: props.mode.draft.baseCommit,
|
||||
workspace: coerceCatalogMetadata(workspace, props.mode.catalog),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (props.mode.kind !== "bootstrap") return;
|
||||
const nextErrors = validate(workspace);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length > 0) return;
|
||||
props.onSaveDraft?.(currentDraft());
|
||||
}
|
||||
|
||||
function requestCreate() {
|
||||
if (props.mode.kind !== "bootstrap") return;
|
||||
const nextErrors = validate(workspace);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length > 0) return;
|
||||
const draft = currentDraft();
|
||||
props.onRequestCreate?.({ action: "create", workspace: draft.workspace, baseCommit: draft.baseCommit }, draft);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="grid gap-6" onSubmit={(event) => { event.preventDefault(); saveDraft(); }} noValidate>
|
||||
<Section title="General">
|
||||
<Field label="Workspace ID" error={errors["workspace.id"]} hint="Catalog-owned technical identifier.">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace ID" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} className={fieldClass} value={workspace.workspace.id} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, id: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="Workspace name" error={errors["workspace.name"]} hint={bootstrapMode ? "Catalog-owned display metadata." : undefined}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace name" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} className={fieldClass} value={workspace.workspace.name} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, name: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Description" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} className={fieldClass} value={workspace.workspace.description ?? ""} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />}
|
||||
</Field>
|
||||
<Field label="Workspace language">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="Workspace language" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.language} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, language: event.target.value as "en" | "it" } }))} disabled={readOnly}><option value="en">English</option><option value="it">Italian</option></select>}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="DWH">
|
||||
<Field label="DWH engine">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="DWH engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="postgres" disabled><option value="postgres">PostgreSQL</option></select>}
|
||||
</Field>
|
||||
<Field label="DWH transport" error={errors["dwh.transport"]} hint="Choose supported installation transports.">
|
||||
{({ id, describedBy, invalid }) => <select id={id} multiple aria-label="DWH transport" aria-describedby={describedBy} aria-invalid={invalid} className={`${fieldClass} h-24 py-1`} value={workspace.dwh.supported_transports} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, supported_transports: selectedValues(event) as CanonicalWorkspace["dwh"]["supported_transports"] } }))} disabled={readOnly}><option value="postgres_direct">postgres_direct</option><option value="rest_api">rest_api</option><option value="ssh_tunnel">ssh_tunnel</option></select>}
|
||||
</Field>
|
||||
<Field label="DWH database" error={errors["dwh.database"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH database" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.database} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, database: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="DWH schema" error={errors["dwh.schema"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH schema" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.schema} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, schema: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="DWH port" error={errors["dwh.port"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="65535" aria-label="DWH port" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.port ?? ""} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />}
|
||||
</Field>
|
||||
<Field label="DWH timeout (ms)" error={errors["dwh.timeout"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" aria-label="DWH timeout (ms)" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.timeout_ms ?? ""} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="Semantic index">
|
||||
<Field label="Vector store engine">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector store engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qdrant" readOnly />}
|
||||
</Field>
|
||||
<Field label="Vector collection" error={errors["vector.collection"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector collection" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.collection} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />}
|
||||
</Field>
|
||||
<Field label="Vector distance">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector distance" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="cosine" readOnly />}
|
||||
</Field>
|
||||
<Field label="Semantic index dimensions" hint="One value is shared by the vector collection and embedding model.">
|
||||
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="32768" aria-label="Semantic index dimensions" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.dimensions} readOnly />}
|
||||
</Field>
|
||||
<Field label="Embedding provider">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding provider" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="ollama_internal" readOnly />}
|
||||
</Field>
|
||||
<Field label="Embedding model">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qwen3-embedding:0.6b" readOnly />}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="LLM policy">
|
||||
<Field label="Allowed models" error={errors["llm.allowed"]} hint="Comma-separated provider/model identifiers.">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Allowed models" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={allowedModels} readOnly={readOnly} onChange={(event) => 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 } : {}) } };
|
||||
})} />}
|
||||
</Field>
|
||||
<Field label="Default model" error={errors["llm.default"]} hint="Only models in the allowlist are selectable.">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="Default model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.llm_policy.default ?? ""} onChange={(event) => update((value) => ({ ...value, llm_policy: { ...value.llm_policy, ...(event.target.value ? { default: event.target.value as `${string}/${string}` } : {}) } }))} disabled={readOnly}><option value="">No default</option>{workspace.llm_policy.allowed.map((model) => <option key={model} value={model}>{model}</option>)}</select>}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{workspace.evidence && <EvidenceSummary evidence={workspace.evidence} />}
|
||||
|
||||
<Section title="Installation requirements">
|
||||
<p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in browser drafts.</p>
|
||||
</Section>
|
||||
|
||||
{readOnly ? (
|
||||
<Section title="Curator workflow">
|
||||
<p className="sm:col-span-2 text-sm text-muted-foreground">To change this workspace, edit {workspace.workspace.id}/workspace.yaml, commit/push, then Pull.</p>
|
||||
</Section>
|
||||
) : (
|
||||
<div className="flex justify-end gap-2 border-t border-border/70 pt-4">
|
||||
<Button type="submit">Save draft</Button>
|
||||
{"onRequestCreate" in props && props.onRequestCreate && <Button type="button" variant="outline" onClick={requestCreate}>Create workspace</Button>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +1,165 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { server } from "../test/msw";
|
||||
import { workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { WorkspaceManager } from "./WorkspaceManager";
|
||||
|
||||
const readyWorkspace = {
|
||||
workspace: {
|
||||
schema_version: 3,
|
||||
id: "psd-clinical",
|
||||
name: "PSD Clinical",
|
||||
description: "Clinical data",
|
||||
language: "en" as const,
|
||||
},
|
||||
dwh: {
|
||||
engine: "postgres" as const,
|
||||
database: "clinical",
|
||||
schema: "datawarehouse",
|
||||
port: 5432,
|
||||
supported_transports: ["postgres_direct"] as const,
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant" as const, collection: "clinical", dimensions: 1024 as const, distance: "cosine" as const },
|
||||
embedding: { provider: "ollama_internal" as const, model: "qwen3-embedding:0.6b" as const, dimensions: 1024 as const },
|
||||
},
|
||||
llm_policy: { default: "zai/glm-5.2" as const, allowed: ["zai/glm-5.2"] as const },
|
||||
evidence: {
|
||||
source: {
|
||||
type: "filesystem" as const,
|
||||
uri: "psd-clinical/evidence",
|
||||
patterns: ["documents/**/*.pdf"],
|
||||
max_bytes: 12_000_000,
|
||||
},
|
||||
policy: { max_chunk_chars: 8_000, retain_published_generations: 5 },
|
||||
},
|
||||
const workspace = canonicalWorkspaceFixture("psd-clinical");
|
||||
const revision = workspaceRevisionFixture("psd-clinical");
|
||||
const requirement = {
|
||||
id: "dwh.password",
|
||||
connector: "dwh",
|
||||
label: "Data warehouse password",
|
||||
description: "Password used by the selected data warehouse connection.",
|
||||
input: "password",
|
||||
required: true,
|
||||
configured: false,
|
||||
};
|
||||
|
||||
const bootstrapWorkspace = {
|
||||
...readyWorkspace,
|
||||
workspace: {
|
||||
...readyWorkspace.workspace,
|
||||
id: "bootstrap-slot",
|
||||
name: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
},
|
||||
semantic_index: {
|
||||
...readyWorkspace.semantic_index,
|
||||
vector_store: { ...readyWorkspace.semantic_index.vector_store, collection: "bootstrap-slot" },
|
||||
},
|
||||
};
|
||||
function runtimeConfiguration(configured = false) {
|
||||
return {
|
||||
workspaceId: "psd-clinical",
|
||||
revision,
|
||||
configurationState: configured ? "ready" : "configuration_required",
|
||||
requirements: [{ ...requirement, configured }],
|
||||
};
|
||||
}
|
||||
|
||||
function renderManager() {
|
||||
function renderManager(onClose = vi.fn()) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><WorkspaceManager open onClose={() => undefined} /></QueryClientProvider>);
|
||||
return {
|
||||
onClose,
|
||||
...render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WorkspaceManager open onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
server.use(
|
||||
http.get("/api/workspace-registry/status", () =>
|
||||
HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })),
|
||||
http.get("/api/workspace-registry/status", () => HttpResponse.json({
|
||||
branch: "main",
|
||||
head: "a".repeat(40),
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
degraded: false,
|
||||
repository: {
|
||||
host: "git.example.test",
|
||||
repository: "analytics/thoth-workspaces",
|
||||
transport: "ssh",
|
||||
},
|
||||
})),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical",
|
||||
description: "Clinical data",
|
||||
revision: workspaceRevisionFixture("psd-clinical"),
|
||||
configurationState: "configuration_required",
|
||||
revision,
|
||||
}),
|
||||
])),
|
||||
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||
workspace: readyWorkspace,
|
||||
revision: workspaceRevisionFixture("psd-clinical"),
|
||||
})),
|
||||
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision })),
|
||||
http.get("/api/workspaces/psd-clinical/runtime-configuration", () => (
|
||||
HttpResponse.json(runtimeConfiguration())
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
test("uses at least sixty percent of the viewport on desktop", () => {
|
||||
renderManager();
|
||||
|
||||
test("renders catalog slots in order and opens a bootstrap form for configuration_required entries", async () => {
|
||||
expect(screen.getByRole("dialog")).toHaveClass(
|
||||
"h-[70vh]",
|
||||
"w-[94vw]",
|
||||
"sm:w-[70vw]",
|
||||
"max-w-[94vw]",
|
||||
);
|
||||
});
|
||||
|
||||
test("level one explains the read-only Git sequence and the repository update button", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post("/api/workspace-registry/pull", () => HttpResponse.json({
|
||||
branch: "main", head: "b".repeat(40), ahead: 0, behind: 0, degraded: false,
|
||||
})));
|
||||
renderManager();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Bootstrap slot" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "PSD Clinical" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Bootstrap slot" }));
|
||||
const overview = screen.getByTestId("workspace-overview");
|
||||
expect(within(overview).getByText(/Git server such as GitHub, GitLab, or Gitea/i)).toBeVisible();
|
||||
expect(within(overview).getByText(/configured during ThothII installation/i)).toBeVisible();
|
||||
expect(within(overview).getAllByText(/managed read-only checkout/i)).toHaveLength(2);
|
||||
expect(within(overview).getByText(/current active revision remains unchanged/i)).toBeVisible();
|
||||
expect(within(overview).getByText(/No workspace selection is required/i)).toBeVisible();
|
||||
expect(await within(overview).findByText("git.example.test/analytics/thoth-workspaces")).toBeVisible();
|
||||
|
||||
expect(await screen.findByLabelText("Workspace ID")).toHaveValue("bootstrap-slot");
|
||||
expect(screen.getByLabelText("Workspace ID")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Workspace name")).toHaveValue("Bootstrap slot");
|
||||
expect(screen.getByLabelText("Workspace name")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Description")).toHaveValue("Needs configuration");
|
||||
expect(screen.getByRole("button", { name: "Save draft" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Create workspace" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Update workspace repository" }));
|
||||
expect(await screen.findByText("Workspace repository updated and validated.")).toBeVisible();
|
||||
expect(screen.queryByText(/import|export|bundle|create a local workspace/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("saves a bootstrap draft locally for a configuration_required slot", async () => {
|
||||
test("workspace-specific commands remain isolated until a workspace is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManager();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Bootstrap slot" }));
|
||||
await user.clear(screen.getByLabelText("Vector collection"));
|
||||
await user.type(screen.getByLabelText("Vector collection"), "bootstrap-docs");
|
||||
await user.click(screen.getByRole("button", { name: "Save draft" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Draft saved in this browser.")).toBeVisible());
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).toContain('"baseCommit"');
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).not.toContain("baseBlob");
|
||||
});
|
||||
|
||||
test("successful create discards the bootstrap draft and reloads the workspace as read-only", async () => {
|
||||
const user = userEvent.setup();
|
||||
let workspacesCalls = 0;
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => {
|
||||
workspacesCalls += 1;
|
||||
return HttpResponse.json(workspacesCalls === 1 ? [
|
||||
workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
] : [
|
||||
workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
revision: workspaceRevisionFixture("bootstrap-slot"),
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
http.get("/api/workspaces/bootstrap-slot", () => HttpResponse.json({
|
||||
workspace: bootstrapWorkspace,
|
||||
revision: workspaceRevisionFixture("bootstrap-slot"),
|
||||
})),
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: bootstrapWorkspace, contract: {} })),
|
||||
http.post("/api/workspaces/publish", () => HttpResponse.json({ revision: workspaceRevisionFixture("bootstrap-slot") })),
|
||||
);
|
||||
localStorage.setItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot", JSON.stringify({
|
||||
workspaceId: "bootstrap-slot",
|
||||
baseCommit: "a".repeat(40),
|
||||
workspace: bootstrapWorkspace,
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
}));
|
||||
renderManager();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Bootstrap slot" }));
|
||||
await user.click(screen.getByRole("button", { name: "Create workspace" }));
|
||||
await user.click(screen.getByRole("button", { name: "Validate draft" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Create workspace" }));
|
||||
await user.click(screen.getByRole("button", { name: "Confirm create" }));
|
||||
|
||||
await waitFor(() => expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).toBeNull());
|
||||
expect(await screen.findByText(/edit bootstrap-slot\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Save draft" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Create workspace" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("ready workspaces stay read-only while keeping pull, export, validate, and installation test actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createObjectURL = vi.fn(() => "blob:workspace-bundle");
|
||||
const revokeObjectURL = vi.fn();
|
||||
class DownloadUrl extends URL {
|
||||
static createObjectURL = createObjectURL;
|
||||
static revokeObjectURL = revokeObjectURL;
|
||||
}
|
||||
vi.stubGlobal("URL", DownloadUrl);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||
server.use(
|
||||
http.post("/api/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })),
|
||||
http.get("/api/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))),
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: readyWorkspace, contract: {} })),
|
||||
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||
activatable: false,
|
||||
diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }],
|
||||
})),
|
||||
);
|
||||
localStorage.setItem("thothii.workspace-registry.v1.draft.psd-clinical", JSON.stringify({ foo: "bar" }));
|
||||
renderManager();
|
||||
|
||||
expect(screen.queryByRole("heading", { name: "Workspace-specific actions" })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
expect(await screen.findByText(/edit psd-clinical\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: /duplicate workspace|delete workspace|publish draft/i })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Pull latest registry" }));
|
||||
expect(await screen.findByText("Registry updated. Reload a workspace to review its latest revision.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Export workspace bundle" }));
|
||||
await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1));
|
||||
await user.click(screen.getByRole("button", { name: "Validate workspace" }));
|
||||
expect(await screen.findByText("Workspace definition is valid.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Test on this installation" }));
|
||||
expect(await screen.findByText("binding_missing: DWH binding is not configured")).toBeVisible();
|
||||
expect(within(screen.getByTestId("workspace-diagnostics")).queryByText(/password|token|secret/i)).not.toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Workspace-specific actions" })).toBeVisible();
|
||||
expect(screen.getByText(/reads this revision without modifying or publishing it/i)).toBeVisible();
|
||||
expect(screen.getByText(/checks workspace.yaml and the required workspace directories/i)).toBeVisible();
|
||||
expect(screen.getByText(/temporary decrypted credentials/i)).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Validate workspace source" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Test workspace connections" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("import populates only a matching configuration_required slot and refuses existing workspaces", async () => {
|
||||
test("secret fields are write-only, clear after blind save, and may be forgotten", async () => {
|
||||
const user = userEvent.setup();
|
||||
let savedBody: unknown;
|
||||
server.use(
|
||||
http.put("/api/workspaces/psd-clinical/secrets", async ({ request }) => {
|
||||
savedBody = await request.json();
|
||||
return HttpResponse.json(runtimeConfiguration(true));
|
||||
}),
|
||||
http.delete("/api/workspaces/psd-clinical/secrets/dwh.password", () => (
|
||||
HttpResponse.json(runtimeConfiguration(false))
|
||||
)),
|
||||
);
|
||||
renderManager();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
|
||||
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace: readyWorkspace, contract: {} } })));
|
||||
await user.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" }));
|
||||
expect(await screen.findByText(/workspace_invalid: Imported bundle can only bootstrap a matching catalog slot/i)).toBeVisible();
|
||||
const input = await screen.findByLabelText("Data warehouse password");
|
||||
expect(input).toHaveValue("");
|
||||
expect(input).toHaveAttribute("type", "password");
|
||||
expect(screen.getByText("Not configured")).toBeVisible();
|
||||
await user.type(input, "one-time-password");
|
||||
await user.click(screen.getByRole("button", { name: "Save entered secrets" }));
|
||||
|
||||
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace: bootstrapWorkspace, contract: {} } })));
|
||||
await user.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" }));
|
||||
expect(await screen.findByText("Imported bootstrap draft saved in this browser. Validate it before creating the descriptor.")).toBeVisible();
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).not.toBeNull();
|
||||
await waitFor(() => expect(savedBody).toEqual({
|
||||
values: { "dwh.password": "one-time-password" },
|
||||
}));
|
||||
expect(input).toHaveValue("");
|
||||
expect(await screen.findByText("Configured")).toBeVisible();
|
||||
expect(screen.queryByDisplayValue("one-time-password")).not.toBeInTheDocument();
|
||||
expect(localStorage.length).toBe(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Forget stored Data warehouse password" }));
|
||||
expect(await screen.findByText("Not configured")).toBeVisible();
|
||||
});
|
||||
|
||||
test("closing clears unsaved secret fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderManager(onClose);
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.type(await screen.findByLabelText("Data warehouse password"), "unsaved-value");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close workspace management" }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByDisplayValue("unsaved-value")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Upload, X } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
FlaskConical,
|
||||
GitPullRequest,
|
||||
KeyRound,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
asWorkspaceApiError,
|
||||
exportWorkspace,
|
||||
forgetWorkspaceSecret,
|
||||
getWorkspace,
|
||||
getWorkspaceRegistryStatus,
|
||||
importWorkspace,
|
||||
getWorkspaceRuntimeConfiguration,
|
||||
listWorkspaces,
|
||||
pullWorkspaceRegistry,
|
||||
saveWorkspaceSecrets,
|
||||
testWorkspace,
|
||||
validateWorkspace,
|
||||
type CanonicalWorkspace,
|
||||
type PublishWorkspaceRequest,
|
||||
type WorkspaceRecord,
|
||||
type WorkspaceSummary,
|
||||
type WorkspaceRuntimeConfiguration,
|
||||
} from "../api/workspaces";
|
||||
import { workspaceBootstrapDrafts, type WorkspaceBootstrapDraft } from "../workspaces/drafts";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { WorkspaceEditor } from "./WorkspaceEditor";
|
||||
import { WorkspacePublishDialog } from "./WorkspacePublishDialog";
|
||||
|
||||
const EMPTY_COMMIT = "0".repeat(40);
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
|
||||
function QueryError({ name, message, retryLabel, onRetry }: {
|
||||
name: string;
|
||||
@@ -30,278 +39,375 @@ function QueryError({ name, message, retryLabel, onRetry }: {
|
||||
retryLabel: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return <div role="alert" aria-label={name} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm"><p>{message}</p><div><Button size="sm" variant="outline" onClick={onRetry}>{retryLabel}</Button></div></div>;
|
||||
return (
|
||||
<div role="alert" aria-label={name} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p>{message}</p>
|
||||
<div><Button size="sm" variant="outline" onClick={onRetry}>{retryLabel}</Button></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultBootstrapWorkspace(summary: WorkspaceSummary): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: {
|
||||
schema_version: 3,
|
||||
id: summary.id,
|
||||
name: summary.displayName,
|
||||
...(summary.description ? { description: summary.description } : {}),
|
||||
language: "en",
|
||||
},
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: "database",
|
||||
schema: "public",
|
||||
supported_transports: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant",
|
||||
collection: summary.id.replaceAll("-", "_"),
|
||||
dimensions: 1024,
|
||||
distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_internal",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"], default: "zai/glm-5.2" },
|
||||
};
|
||||
function publicError(error: unknown, fallback: string): string {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
return safe ? `${safe.code}: ${safe.message}` : fallback;
|
||||
}
|
||||
|
||||
function bootstrapDraftFor(summary: WorkspaceSummary, baseCommit: string): WorkspaceBootstrapDraft {
|
||||
return {
|
||||
workspaceId: summary.id,
|
||||
baseCommit,
|
||||
workspace: defaultBootstrapWorkspace(summary),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
function stateLabel(state: "ready" | "configuration_required"): string {
|
||||
return state === "ready" ? "Ready" : "Runtime configuration required";
|
||||
}
|
||||
|
||||
export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [localDraft, setLocalDraft] = useState<WorkspaceBootstrapDraft>();
|
||||
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [diagnostics, setDiagnostics] = useState<string[]>([]);
|
||||
const [publishRequest, setPublishRequest] = useState<PublishWorkspaceRequest>();
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const [busyAction, setBusyAction] = useState<string>();
|
||||
|
||||
const statusQuery = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open });
|
||||
const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open });
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["workspace-repository-status"],
|
||||
queryFn: getWorkspaceRegistryStatus,
|
||||
enabled: open,
|
||||
});
|
||||
const workspacesQuery = useQuery({
|
||||
queryKey: ["workspaces"],
|
||||
queryFn: listWorkspaces,
|
||||
enabled: open,
|
||||
});
|
||||
const workspaces = workspacesQuery.data ?? [];
|
||||
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
|
||||
const selectedSummary = useMemo(
|
||||
() => workspaces.find(({ id }) => id === selectedId),
|
||||
[selectedId, workspaces],
|
||||
);
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workspace", selectedId],
|
||||
queryFn: () => getWorkspace(selectedId!),
|
||||
enabled: Boolean(open && selectedId && selectedSummary?.configurationState === "ready"),
|
||||
enabled: Boolean(open && selectedId),
|
||||
});
|
||||
const runtimeQuery = useQuery({
|
||||
queryKey: ["workspace-runtime-configuration", selectedId],
|
||||
queryFn: () => getWorkspaceRuntimeConfiguration(selectedId!),
|
||||
enabled: Boolean(open && selectedId),
|
||||
});
|
||||
const record = detailQuery.data;
|
||||
|
||||
const activeBootstrapDraft = selectedSummary?.configurationState === "configuration_required"
|
||||
? (localDraft?.workspaceId === selectedSummary.id ? localDraft : workspaceBootstrapDrafts.load(selectedSummary.id) ?? bootstrapDraftFor(selectedSummary, statusQuery.data?.head ?? EMPTY_COMMIT))
|
||||
: undefined;
|
||||
|
||||
function resetTransientState() {
|
||||
const clearMessages = () => {
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
setPublishRequest(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
function selectWorkspace(id: string) {
|
||||
const close = () => {
|
||||
setSecretValues({});
|
||||
clearMessages();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const selectWorkspace = (id: string) => {
|
||||
setSelectedId(id);
|
||||
setLocalDraft(undefined);
|
||||
resetTransientState();
|
||||
}
|
||||
setSecretValues({});
|
||||
clearMessages();
|
||||
};
|
||||
|
||||
function saveDraft(draft: WorkspaceBootstrapDraft) {
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setLocalDraft(draft);
|
||||
setSelectedId(draft.workspaceId);
|
||||
setNotice("Draft saved in this browser.");
|
||||
setDiagnostics([]);
|
||||
}
|
||||
|
||||
function requestCreate(request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) {
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setLocalDraft(draft);
|
||||
setSelectedId(draft.workspaceId);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
setPublishRequest(request);
|
||||
}
|
||||
|
||||
async function pullLatest() {
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
async function updateRepository() {
|
||||
setBusyAction("repository");
|
||||
clearMessages();
|
||||
try {
|
||||
await pullWorkspaceRegistry();
|
||||
await Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]);
|
||||
setNotice("Registry updated. Reload a workspace to review its latest revision.");
|
||||
await Promise.all([
|
||||
statusQuery.refetch(),
|
||||
workspacesQuery.refetch(),
|
||||
selectedId ? detailQuery.refetch() : Promise.resolve(),
|
||||
selectedId ? runtimeQuery.refetch() : Promise.resolve(),
|
||||
]);
|
||||
setNotice("Workspace repository updated and validated.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "git_unavailable: Registry pull could not be completed"]);
|
||||
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
|
||||
} finally {
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSelectedWorkspace() {
|
||||
if (!record) return;
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
async function validateSource() {
|
||||
if (!detailQuery.data) return;
|
||||
setBusyAction("validate");
|
||||
clearMessages();
|
||||
try {
|
||||
await validateWorkspace(record.workspace);
|
||||
setNotice("Workspace definition is valid.");
|
||||
await validateWorkspace(detailQuery.data.workspace);
|
||||
setNotice("Workspace source is valid.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]);
|
||||
setDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
|
||||
} finally {
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSelectedWorkspace() {
|
||||
if (!record) return;
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
async function testConnections() {
|
||||
if (!selectedId) return;
|
||||
setBusyAction("test");
|
||||
clearMessages();
|
||||
try {
|
||||
const result = await testWorkspace(record.workspace.workspace.id);
|
||||
setDiagnostics(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`));
|
||||
const result = await testWorkspace(selectedId);
|
||||
setDiagnostics(result.diagnostics.map(({ code, message }) => `${code}: ${message}`));
|
||||
if (result.diagnostics.length === 0) {
|
||||
setNotice(result.activatable ? "Installation test passed." : "Installation test completed.");
|
||||
setNotice(result.activatable
|
||||
? "Workspace connections are valid."
|
||||
: "Workspace connection test completed.");
|
||||
}
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "connector_unavailable: Installation test could not be completed"]);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBundle() {
|
||||
if (!record) return;
|
||||
setTransferring(true);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
try {
|
||||
const bundle = await exportWorkspace(record.workspace.workspace.id);
|
||||
const url = URL.createObjectURL(bundle);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${record.workspace.workspace.id}.zip`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setNotice("Workspace bundle downloaded.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be exported"]);
|
||||
setDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function importBundle(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setTransferring(true);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
async function saveSecrets() {
|
||||
if (!selectedId) return;
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(secretValues).filter(([, value]) => value.length > 0),
|
||||
);
|
||||
if (Object.keys(values).length === 0) return;
|
||||
setBusyAction("save-secrets");
|
||||
clearMessages();
|
||||
try {
|
||||
const result = await importWorkspace(file);
|
||||
const importedId = result.draft.workspace.workspace.id;
|
||||
const catalog = workspaces.length > 0 ? workspaces : ((await workspacesQuery.refetch()).data ?? []);
|
||||
const matchingSummary = catalog.find((workspace) => workspace.id === importedId && workspace.configurationState === "configuration_required");
|
||||
if (!matchingSummary) {
|
||||
setDiagnostics(["workspace_invalid: Imported bundle can only bootstrap a matching catalog slot"]);
|
||||
return;
|
||||
}
|
||||
const draft: WorkspaceBootstrapDraft = {
|
||||
workspaceId: importedId,
|
||||
baseCommit: statusQuery.data?.head ?? EMPTY_COMMIT,
|
||||
workspace: result.draft.workspace,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setNotice("Imported bootstrap draft saved in this browser. Validate it before creating the descriptor.");
|
||||
const configuration = await saveWorkspaceSecrets(selectedId, values);
|
||||
queryClient.setQueryData(
|
||||
["workspace-runtime-configuration", selectedId],
|
||||
configuration,
|
||||
);
|
||||
setSecretValues({});
|
||||
await workspacesQuery.refetch();
|
||||
setNotice("Runtime secrets saved. Stored values remain hidden.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]);
|
||||
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function published() {
|
||||
if (publishRequest) {
|
||||
workspaceBootstrapDrafts.discard(publishRequest.workspace.workspace.id);
|
||||
setSelectedId(publishRequest.workspace.workspace.id);
|
||||
setNotice(`To change this workspace, edit ${publishRequest.workspace.workspace.id}/workspace.yaml, commit/push, then Pull.`);
|
||||
} else {
|
||||
setNotice("Workspace created.");
|
||||
async function forgetSecret(requirementId: string) {
|
||||
if (!selectedId) return;
|
||||
setBusyAction(`forget:${requirementId}`);
|
||||
clearMessages();
|
||||
try {
|
||||
const configuration = await forgetWorkspaceSecret(selectedId, requirementId);
|
||||
queryClient.setQueryData(
|
||||
["workspace-runtime-configuration", selectedId],
|
||||
configuration,
|
||||
);
|
||||
setSecretValues((current) => ({ ...current, [requirementId]: "" }));
|
||||
await workspacesQuery.refetch();
|
||||
setNotice("Stored secret forgotten.");
|
||||
} catch (error) {
|
||||
setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]);
|
||||
} finally {
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
setLocalDraft(undefined);
|
||||
setPublishRequest(undefined);
|
||||
setDiagnostics([]);
|
||||
void Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]).then(() => detailQuery.refetch());
|
||||
}
|
||||
|
||||
const titleReady = workspacesQuery.isSuccess || workspacesQuery.isError;
|
||||
const repository = statusQuery.data?.repository;
|
||||
const repositoryLabel = repository
|
||||
? `${repository.host}/${repository.repository}`
|
||||
: "the repository configured for this ThothII installation";
|
||||
const runtime = runtimeQuery.data;
|
||||
const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
|
||||
<DialogContent showCloseButton={false} className="max-h-[min(90vh,54rem)] max-w-[min(94vw,74rem)] overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
|
||||
<DialogTitle>{titleReady ? "Workspace management" : ""}</DialogTitle>
|
||||
<DialogDescription>{titleReady ? "Draft bootstrap-only workspace definitions locally. Existing published descriptors stay read-only." : ""}</DialogDescription>
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) close(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="h-[70vh] w-[94vw] max-h-[90vh] max-w-[94vw] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:w-[70vw] sm:max-w-[94vw]"
|
||||
>
|
||||
<DialogHeader className="border-b border-border/70 px-6 pb-4 pt-5 pr-12">
|
||||
<DialogTitle>Workspace management</DialogTitle>
|
||||
<DialogDescription>
|
||||
Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<button type="button" aria-label="Close workspace management" onClick={onClose} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"><X className="size-4" /></button>
|
||||
<div className="grid max-h-[calc(min(90vh,54rem)-6.75rem)] min-h-[32rem] grid-cols-[14rem_minmax(0,1fr)] overflow-hidden">
|
||||
<button type="button" aria-label="Close workspace management" onClick={close} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className="grid min-h-0 grid-cols-[15rem_minmax(0,1fr)] overflow-hidden">
|
||||
<nav aria-label="Workspaces" className="flex min-h-0 flex-col border-r border-border/70 bg-muted/30 p-3">
|
||||
<label className="mb-2 inline-flex h-7 cursor-pointer items-center justify-center gap-1 rounded-md border border-input px-3 text-[0.8rem] font-semibold text-foreground shadow-xs hover:bg-muted has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50"><Upload className="size-3.5" />Import bundle<input aria-label="Import workspace bundle" type="file" accept="application/zip,.zip" className="sr-only" disabled={transferring} onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; void importBundle(file); }} /></label>
|
||||
<Button size="sm" variant="outline" className="mb-3 w-full" disabled={transferring} onClick={() => { void pullLatest(); }}><GitPullRequest />Pull latest registry</Button>
|
||||
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Available workspaces</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{workspacesQuery.isLoading ? <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p> : workspacesQuery.isError ? <QueryError name="Workspace list failed" message="Could not load workspaces." retryLabel="Retry workspace list" onRetry={() => { void workspacesQuery.refetch(); }} /> : workspaces.map((workspace) => (
|
||||
<button key={workspace.id} type="button" aria-label={workspace.displayName} aria-current={selectedId === workspace.id ? "page" : undefined} onClick={() => selectWorkspace(workspace.id)} className="mb-1 w-full rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted aria-[current=page]:bg-primary/10 aria-[current=page]:font-semibold">
|
||||
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p>}
|
||||
{workspacesQuery.isError && (
|
||||
<QueryError name="Workspace list failed" message="Could not load workspaces." retryLabel="Retry workspace list" onRetry={() => { void workspacesQuery.refetch(); }} />
|
||||
)}
|
||||
{workspaces.map((workspace) => (
|
||||
<button
|
||||
key={workspace.id}
|
||||
type="button"
|
||||
aria-label={workspace.displayName}
|
||||
aria-current={selectedId === workspace.id ? "page" : undefined}
|
||||
onClick={() => selectWorkspace(workspace.id)}
|
||||
className="mb-1 w-full rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted aria-[current=page]:bg-primary/10 aria-[current=page]:font-semibold"
|
||||
>
|
||||
<span className="block truncate">{workspace.displayName}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
|
||||
<span className="mt-1 block text-[0.7rem] text-muted-foreground">{stateLabel(workspace.configurationState)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 overflow-y-auto px-5 py-5">
|
||||
{notice && <p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm"><CheckCircle2 className="size-4 text-primary" />{notice}</p>}
|
||||
{diagnostics.length > 0 && <div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">{diagnostics.map((diagnostic) => <p key={diagnostic} className="flex items-start gap-2"><AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}</p>)}</div>}
|
||||
<main className="min-w-0 overflow-y-auto px-6 py-5">
|
||||
{notice && (
|
||||
<p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
<CheckCircle2 className="size-4 text-primary" />{notice}
|
||||
</p>
|
||||
)}
|
||||
{diagnostics.length > 0 && (
|
||||
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<p key={diagnostic} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedSummary && !workspacesQuery.isLoading && !workspacesQuery.isError && <div className="grid min-h-64 place-items-center text-center"><div><h3 className="font-heading font-semibold">Select a workspace</h3><p className="mt-1 text-sm text-muted-foreground">Review an existing definition or bootstrap a configuration-required slot.</p></div></div>}
|
||||
|
||||
{selectedSummary?.configurationState === "configuration_required" && activeBootstrapDraft && (
|
||||
<>
|
||||
<div className="mb-5 border-b border-border/70 pb-4">
|
||||
<p className="thot-label">Bootstrap workspace</p>
|
||||
<h2 className="font-heading text-lg font-semibold">{selectedSummary.displayName}</h2>
|
||||
{!selectedSummary ? (
|
||||
<section data-testid="workspace-overview" className="mx-auto max-w-3xl space-y-5">
|
||||
<div>
|
||||
<p className="thot-label">Level 1 · Repository</p>
|
||||
<h2 className="font-heading text-xl font-semibold">How workspaces reach ThothII</h2>
|
||||
</div>
|
||||
<ol className="grid list-decimal gap-3 pl-5 text-sm leading-6 text-muted-foreground">
|
||||
<li>Prepare the workspace source in its own directory. It must contain <code>workspace.yaml</code> and every required subdirectory, including any versioned Evidence files.</li>
|
||||
<li>Publish that source by committing and pushing it to a repository hosted by a Git server such as GitHub, GitLab, or Gitea.</li>
|
||||
<li>The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads <span className="font-medium text-foreground">{repositoryLabel}</span> on branch <span className="font-mono text-foreground">{statusQuery.data?.branch ?? "main"}</span>.</li>
|
||||
<li>ThothII fetches the configured branch into its managed read-only checkout, validates the complete candidate revision, and activates it only when validation succeeds. It never edits, commits, pushes, or publishes workspace source.</li>
|
||||
</ol>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="max-w-2xl">
|
||||
<h3 className="font-heading font-semibold">Update workspace repository</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">Fetches the configured branch directly into the managed read-only checkout and validates it. No workspace selection is required. If candidate validation fails, the current active revision remains unchanged.</p>
|
||||
</div>
|
||||
<Button disabled={busyAction === "repository"} onClick={() => { void updateRepository(); }}>
|
||||
<GitPullRequest />Update workspace repository
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Select a workspace from the left only for workspace-specific validation, runtime credentials, and connection tests.</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="mx-auto max-w-4xl space-y-5">
|
||||
<div className="border-b border-border/70 pb-4">
|
||||
<p className="thot-label">Level 2 · Selected workspace</p>
|
||||
<h2 className="font-heading text-xl font-semibold">{selectedSummary.displayName}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
|
||||
</div>
|
||||
<WorkspaceEditor
|
||||
mode={{ kind: "bootstrap", catalog: selectedSummary, draft: activeBootstrapDraft }}
|
||||
onSaveDraft={saveDraft}
|
||||
onRequestCreate={publishRequest ? undefined : requestCreate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedSummary?.configurationState === "ready" && (
|
||||
detailQuery.isError ? <QueryError name="Workspace details failed" message="Could not load workspace details." retryLabel="Retry workspace details" onRetry={() => { void detailQuery.refetch(); }} /> : detailQuery.isLoading || !record ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : (
|
||||
<>
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
|
||||
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
|
||||
{(detailQuery.isError || runtimeQuery.isError) && (
|
||||
<QueryError name="Workspace configuration failed" message="Could not load this workspace configuration." retryLabel="Retry workspace configuration" onRetry={() => { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} />
|
||||
)}
|
||||
|
||||
{detailQuery.data && runtime && (
|
||||
<>
|
||||
<div>
|
||||
<p className="thot-label">Workspace definition</p>
|
||||
<h2 className="font-heading text-lg font-semibold">{record.workspace.workspace.name}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{record.workspace.workspace.id}</p>
|
||||
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">The actions below apply only to <span className="font-medium text-foreground">{selectedSummary.displayName}</span>. ThothII reads this revision without modifying or publishing it.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={transferring} onClick={() => { void downloadBundle(); }}><Download />Export workspace bundle</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { void validateSelectedWorkspace(); }}><ClipboardCheck />Validate workspace</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { void testSelectedWorkspace(); }}><FlaskConical />Test on this installation</Button>
|
||||
|
||||
<dl className="grid gap-3 rounded-lg border border-border bg-muted/20 p-4 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-xs text-muted-foreground">Source file</dt><dd className="font-mono">{selectedSummary.file}</dd></div>
|
||||
<div><dt className="text-xs text-muted-foreground">Active revision</dt><dd className="truncate font-mono">{detailQuery.data.revision.commit}</dd></div>
|
||||
<div><dt className="text-xs text-muted-foreground">Data warehouse</dt><dd>{detailQuery.data.workspace.dwh.engine} · {detailQuery.data.workspace.dwh.database}/{detailQuery.data.workspace.dwh.schema}</dd></div>
|
||||
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h4 className="font-heading font-semibold">Validate workspace source</h4>
|
||||
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Checks workspace.yaml and the required workspace directories against the supported workspace schema. No source file is changed.</p>
|
||||
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
|
||||
<ClipboardCheck />Validate workspace source
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h4 className="font-heading font-semibold">Test workspace connections</h4>
|
||||
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Uses temporary decrypted credentials to verify the configured data warehouse and Evidence source. Temporary files are deleted after the test.</p>
|
||||
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
|
||||
<FlaskConical />Test workspace connections
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceEditor mode={{ kind: "read_only", catalog: selectedSummary, record: record as WorkspaceRecord }} />
|
||||
</>
|
||||
)
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<KeyRound className="mt-0.5 size-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-heading font-semibold">Runtime secrets</h3>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">Enter only new or replacement values. Stored values are never displayed. Saving replaces the selected secret and clears the form field.</p>
|
||||
</div>
|
||||
</div>
|
||||
{runtime.requirements.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">This workspace does not require user-provided runtime secrets for its selected connectors.</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{runtime.requirements.map((requirement) => (
|
||||
<div key={requirement.id} className="grid gap-2 rounded-md bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label htmlFor={`workspace-secret-${requirement.id}`} className="font-medium">{requirement.label}</label>
|
||||
<span className={requirement.configured ? "text-xs font-medium text-emerald-700" : "text-xs font-medium text-amber-700"}>
|
||||
{requirement.configured ? "Configured" : "Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{requirement.description}{requirement.required ? " Required for this workspace." : " Optional."}</p>
|
||||
{requirement.input === "textarea" ? (
|
||||
<textarea
|
||||
id={`workspace-secret-${requirement.id}`}
|
||||
value={secretValues[requirement.id] ?? ""}
|
||||
placeholder="Enter a new or replacement value"
|
||||
autoComplete="off"
|
||||
rows={4}
|
||||
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`workspace-secret-${requirement.id}`}
|
||||
type="password"
|
||||
value={secretValues[requirement.id] ?? ""}
|
||||
placeholder="Enter a new or replacement value"
|
||||
autoComplete="new-password"
|
||||
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
{requirement.configured && (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busyAction === `forget:${requirement.id}`}
|
||||
aria-label={`Forget stored ${requirement.label}`}
|
||||
onClick={() => { void forgetSecret(requirement.id); }}
|
||||
>
|
||||
<Trash2 />Forget stored value
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button disabled={!hasEnteredSecrets || busyAction === "save-secrets"} onClick={() => { void saveSecrets(); }}>
|
||||
<KeyRound />Save entered secrets
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</DialogContent>
|
||||
{publishRequest && <WorkspacePublishDialog open request={publishRequest} onOpenChange={(nextOpen) => { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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 { canonicalWorkspaceFixture, workspaceRevisionFixture } from "../test/workspace-fixtures";
|
||||
import { server } from "../test/msw";
|
||||
import { WorkspacePublishDialog } from "./WorkspacePublishDialog";
|
||||
|
||||
const workspace = {
|
||||
...canonicalWorkspaceFixture("bootstrap-slot"),
|
||||
workspace: {
|
||||
...canonicalWorkspaceFixture("bootstrap-slot").workspace,
|
||||
name: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
},
|
||||
};
|
||||
|
||||
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 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: workspaceRevisionFixture("bootstrap-slot") });
|
||||
}));
|
||||
|
||||
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={published} />);
|
||||
|
||||
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: "Create workspace" }));
|
||||
expect(screen.getByRole("heading", { name: "Confirm workspace creation" })).toBeVisible();
|
||||
expect(publishCalls).toBe(0);
|
||||
await user.click(screen.getByRole("button", { name: "Confirm create" }));
|
||||
|
||||
await waitFor(() => expect(published).toHaveBeenCalledWith(workspaceRevisionFixture("bootstrap-slot")));
|
||||
expect(publishCalls).toBe(1);
|
||||
});
|
||||
|
||||
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({
|
||||
code: "workspace_curator_owned",
|
||||
message: "Existing descriptors are curator-owned.",
|
||||
}, { status: 409 })));
|
||||
|
||||
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} />);
|
||||
|
||||
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("workspace_curator_owned: Existing descriptors are curator-owned.")).toBeVisible();
|
||||
expect(screen.queryByText(/save revised draft|use your draft|use registry value/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
asWorkspaceApiError,
|
||||
publishWorkspace,
|
||||
validateWorkspace,
|
||||
type PublishWorkspaceRequest,
|
||||
type WorkspaceRevision,
|
||||
} from "../api/workspaces";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
|
||||
export interface WorkspacePublishDialogProps {
|
||||
open: boolean;
|
||||
request: PublishWorkspaceRequest;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPublished: (revision: WorkspaceRevision | undefined) => void;
|
||||
}
|
||||
|
||||
function RevisionSummary({ request }: { request: PublishWorkspaceRequest }) {
|
||||
return <p className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 font-mono text-xs text-muted-foreground">New workspace · base {request.baseCommit.slice(0, 12)}</p>;
|
||||
}
|
||||
|
||||
export function WorkspacePublishDialog({ open, request, onOpenChange, onPublished }: WorkspacePublishDialogProps) {
|
||||
const [validatedRequest, setValidatedRequest] = useState<PublishWorkspaceRequest>();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValidatedRequest(undefined);
|
||||
setConfirming(false);
|
||||
setMessage(undefined);
|
||||
setPublishing(false);
|
||||
}, [open, request]);
|
||||
|
||||
async function validateDraft() {
|
||||
setMessage(undefined);
|
||||
try {
|
||||
const result = await validateWorkspace(request.workspace);
|
||||
setValidatedRequest({ ...request, workspace: result.workspace });
|
||||
setMessage("Workspace definition is valid.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed");
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkspace() {
|
||||
if (!validatedRequest) return;
|
||||
setPublishing(true);
|
||||
setMessage(undefined);
|
||||
try {
|
||||
const result = await publishWorkspace(validatedRequest);
|
||||
onPublished(result?.revision);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Publication could not be completed");
|
||||
setConfirming(false);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showCloseButton={false} className="max-w-[min(94vw,46rem)] p-0">
|
||||
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
|
||||
<DialogTitle>{confirming ? "Confirm workspace creation" : "Create workspace"}</DialogTitle>
|
||||
<DialogDescription>{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."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 px-5 py-5">
|
||||
<RevisionSummary request={validatedRequest ?? request} />
|
||||
{message && <p role="status" className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-sm">{message}</p>}
|
||||
{confirming ? (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirming(false)}>Cancel</Button>
|
||||
<Button onClick={() => { void createWorkspace(); }} disabled={publishing}>{publishing ? "Creating…" : "Confirm create"}</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => { void validateDraft(); }}>Validate draft</Button>
|
||||
<Button disabled={!validatedRequest} onClick={() => setConfirming(true)}>Create workspace</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" aria-label="Close publish workspace" onClick={() => onOpenChange(false)} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">×</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user