feat: finish Pi and workspace management updates

This commit is contained in:
2026-08-16 14:19:32 +02:00
parent 7651b63cea
commit 351361f72f
20 changed files with 2276 additions and 149 deletions
+44 -33
View File
@@ -238,25 +238,30 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
"Open the project root",
"Edit the provider catalog",
"Enable the model",
"Set the provider credential",
"Check the provider credential",
"Reload Pi configuration",
"Update the Pi version",
"Recover a failed update",
]);
expect(linux).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(linux).toHaveTextContent("The deploy directory is beside compose.yaml");
expect(linux).toHaveTextContent("All commands below start in this project root");
expect(linux).toHaveTextContent("mkdir -p bin");
expect(linux).toHaveTextContent("go -C tools/thothctl build -o ../../bin/thothctl ./cmd/thothctl");
expect(linux).toHaveTextContent("deploy/pi/models.json");
expect(linux).toHaveTextContent("deploy/pi/settings.json");
expect(linux).toHaveTextContent("baseUrl is the provider API endpoint");
expect(linux).toHaveTextContent("enabledModels uses provider/model identifiers");
expect(linux).toHaveTextContent("PI_AUTH_FILE is a setting in the installation environment file");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
expect(linux).toHaveTextContent("<VERSION> is a placeholder. Replace it with the Pi release/version you want to install.");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance status");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi logs");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes");
expect(linux).toHaveTextContent("Pi reads the provider API key from a protected file on the host");
expect(linux).toHaveTextContent("Do not put the key in models.json or settings.json");
expect(linux).toHaveTextContent("./bin/thothctl pi restart --yes --drain");
expect(linux).toHaveTextContent("./bin/thothctl pi update");
expect(linux).toHaveTextContent("./bin/thothctl pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
expect(linux).toHaveTextContent("The command installs the Pi version pinned in docker/core.Dockerfile");
expect(linux).toHaveTextContent("./bin/thothctl pi maintenance status");
expect(linux).toHaveTextContent("./bin/thothctl pi logs");
expect(linux).toHaveTextContent("./bin/thothctl pi rollback --yes");
expect(linux).toHaveTextContent("./bin/thothctl pi maintenance recover --yes");
expect(linux).not.toHaveTextContent("~/bin/");
expect(linux).not.toHaveTextContent("~/.pi/agent/");
await user.click(macosTab);
@@ -266,22 +271,24 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
"Open the project root",
"Edit the provider catalog",
"Enable the model",
"Set the provider credential",
"Check the provider credential",
"Reload Pi configuration",
"Update the Pi version",
"Recover a failed update",
]);
expect(macos).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(macos).toHaveTextContent("The deploy directory is beside compose.yaml");
expect(macos).toHaveTextContent("All commands below start in this project root");
expect(macos).toHaveTextContent("go -C tools/thothctl build -o ../../bin/thothctl ./cmd/thothctl");
expect(macos).toHaveTextContent("deploy/pi/models.json");
expect(macos).toHaveTextContent("deploy/pi/settings.json");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
expect(macos).toHaveTextContent("<VERSION> is a placeholder. Replace it with the Pi release/version you want to install.");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance status");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi logs");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes");
expect(macos).toHaveTextContent("./bin/thothctl pi restart --yes --drain");
expect(macos).toHaveTextContent("./bin/thothctl pi update");
expect(macos).toHaveTextContent("./bin/thothctl pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
expect(macos).toHaveTextContent("The command installs the Pi version pinned in docker/core.Dockerfile");
expect(macos).toHaveTextContent("./bin/thothctl pi maintenance status");
expect(macos).toHaveTextContent("./bin/thothctl pi logs");
expect(macos).toHaveTextContent("./bin/thothctl pi rollback --yes");
expect(macos).toHaveTextContent("./bin/thothctl pi maintenance recover --yes");
await user.click(windowsTab);
const windows = screen.getByRole("tabpanel", { name: "Windows" });
@@ -290,22 +297,26 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
"Open the project root",
"Edit the provider catalog",
"Enable the model",
"Set the provider credential",
"Check the provider credential",
"Reload Pi configuration",
"Update the Pi version",
"Recover a failed update",
]);
expect(windows).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(windows).toHaveTextContent("The deploy directory is beside compose.yaml");
expect(windows).toHaveTextContent("All commands below start in this project root");
expect(windows).toHaveTextContent("deploy\\pi\\models.json");
expect(windows).toHaveTextContent("deploy\\pi\\settings.json");
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi restart --yes --drain');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source build --yes --drain');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain');
expect(windows).toHaveTextContent("<VERSION> is a placeholder. Replace it with the Pi release/version you want to install.");
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi maintenance status');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi logs');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi rollback --yes');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi maintenance recover --yes');
expect(windows).toHaveTextContent("New-Item -ItemType Directory -Force bin");
expect(windows).toHaveTextContent("go -C tools/thothctl build -o ../../bin/thothctl.exe ./cmd/thothctl");
expect(windows).toHaveTextContent('.\\bin\\thothctl.exe pi restart --yes --drain');
expect(windows).toHaveTextContent('.\\bin\\thothctl.exe pi update');
expect(windows).toHaveTextContent('.\\bin\\thothctl.exe pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain');
expect(windows).toHaveTextContent("The command installs the Pi version pinned in docker/core.Dockerfile");
expect(windows).toHaveTextContent('.\\bin\\thothctl.exe pi maintenance status');
expect(windows).toHaveTextContent('.\\bin\\thothctl.exe pi logs');
expect(windows).toHaveTextContent('.\\bin\\thothctl.exe pi rollback --yes');
expect(windows).toHaveTextContent('.\\bin\\thothctl.exe pi maintenance recover --yes');
expect(windows).not.toHaveTextContent("~\\bin\\");
});
test("keeps the required PI_AUTH_FILE guidance in one static text node", async () => {
@@ -314,13 +325,13 @@ test("keeps the required PI_AUTH_FILE guidance in one static text node", async (
const tablist = await screen.findByRole("tablist", { name: "Pi host platform" });
await user.click(within(tablist).getByRole("tab", { name: "Linux" }));
const credentialStep = screen.getByRole("heading", { name: "Set the provider credential" }).closest("li");
const credentialStep = screen.getByRole("heading", { name: "Check the provider credential" }).closest("li");
const guidance = credentialStep?.querySelector("p");
expect(
Array.from(guidance?.childNodes ?? []).some(
(node) => node.nodeType === Node.TEXT_NODE
&& node.textContent?.includes("PI_AUTH_FILE is a setting in the installation environment file"),
&& node.textContent?.includes("Pi reads the provider API key from a protected file on the host"),
),
).toBe(true);
});
+22 -17
View File
@@ -108,6 +108,7 @@ type PiPlatformDetails = {
settingsPath: string;
terminal: string;
credentialProtection: string;
buildCommand: string;
restartCommand: string;
updateCommand: string;
pullCommand: string;
@@ -123,10 +124,11 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet
settingsPath: "deploy/pi/settings.json",
terminal: "a terminal",
credentialProtection: "a protected host file with mode 0600",
restartCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain",
updateCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain",
pullCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
recoveryCommands: "~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance status\n~/bin/thothctl --installation ~/thothii-installation.yaml pi logs\n~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes\n~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes",
buildCommand: "mkdir -p bin\ngo -C tools/thothctl build -o ../../bin/thothctl ./cmd/thothctl",
restartCommand: "./bin/thothctl pi restart --yes --drain",
updateCommand: "./bin/thothctl pi update",
pullCommand: "./bin/thothctl pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
recoveryCommands: "./bin/thothctl pi maintenance status\n./bin/thothctl pi logs\n./bin/thothctl pi rollback --yes\n./bin/thothctl pi maintenance recover --yes",
},
},
{
@@ -137,10 +139,11 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet
settingsPath: "deploy/pi/settings.json",
terminal: "Terminal",
credentialProtection: "a protected host file with mode 0600",
restartCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain",
updateCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain",
pullCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
recoveryCommands: "~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance status\n~/bin/thothctl --installation ~/thothii-installation.yaml pi logs\n~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes\n~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes",
buildCommand: "mkdir -p bin\ngo -C tools/thothctl build -o ../../bin/thothctl ./cmd/thothctl",
restartCommand: "./bin/thothctl pi restart --yes --drain",
updateCommand: "./bin/thothctl pi update",
pullCommand: "./bin/thothctl pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
recoveryCommands: "./bin/thothctl pi maintenance status\n./bin/thothctl pi logs\n./bin/thothctl pi rollback --yes\n./bin/thothctl pi maintenance recover --yes",
},
},
{
@@ -151,10 +154,11 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet
settingsPath: "deploy\\pi\\settings.json",
terminal: "PowerShell",
credentialProtection: "a protected host file with a user-only ACL",
restartCommand: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi restart --yes --drain',
updateCommand: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source build --yes --drain',
pullCommand: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain',
recoveryCommands: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi maintenance status\n& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi logs\n& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi rollback --yes\n& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi maintenance recover --yes',
buildCommand: "New-Item -ItemType Directory -Force bin | Out-Null\ngo -C tools/thothctl build -o ../../bin/thothctl.exe ./cmd/thothctl",
restartCommand: ".\\bin\\thothctl.exe pi restart --yes --drain",
updateCommand: ".\\bin\\thothctl.exe pi update",
pullCommand: ".\\bin\\thothctl.exe pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
recoveryCommands: ".\\bin\\thothctl.exe pi maintenance status\n.\\bin\\thothctl.exe pi logs\n.\\bin\\thothctl.exe pi rollback --yes\n.\\bin\\thothctl.exe pi maintenance recover --yes",
},
},
];
@@ -167,7 +171,9 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
return <ol className="grid gap-4 pl-5 marker:font-semibold marker:text-muted-foreground">
<li>
<h4 className="font-semibold text-foreground">Open the project root</h4>
<p className="mt-1 text-muted-foreground">Open {details.terminal} in the ThothII project root. The deploy directory is in the ThothII project root, beside <code>compose.yaml</code>.</p>
<p className="mt-1 text-muted-foreground">Using {details.terminal}, open the current ThothII checkout or worktree root. All commands below start in this project root. The deploy directory is beside <code>compose.yaml</code>. If this checkout has no <code>bin</code> directory, create it and build the local CLI once:</p>
<PiCodeBlock className="mt-2">{details.buildCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">The commands below use the executable built in this checkout, so they cannot accidentally target another worktree.</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Edit the provider catalog</h4>
@@ -188,8 +194,8 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
</dl>
</li>
<li>
<h4 className="font-semibold text-foreground">Set the provider credential</h4>
<p className="mt-1 text-muted-foreground">PI_AUTH_FILE is a setting in the installation environment file. It selects {details.credentialProtection}; Docker mounts the selected host credential file read-only for Pi.</p>
<h4 className="font-semibold text-foreground">Check the provider credential</h4>
<p className="mt-1 text-muted-foreground">Pi reads the provider API key from a protected file on the host. <code>PI_AUTH_FILE</code> tells this installation which file to use; Docker mounts it read-only into the core container. Do not put the key in <code>models.json</code> or <code>settings.json</code>. Keep {details.credentialProtection}.</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Reload Pi configuration</h4>
@@ -198,8 +204,7 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
</li>
<li>
<h4 className="font-semibold text-foreground">Update the Pi version</h4>
<p className="mt-1 text-muted-foreground">Use a build update only when changing the bundled Pi version.</p>
<p className="mt-1 text-muted-foreground">{"<VERSION> is a placeholder. Replace it with the Pi release/version you want to install."}</p>
<p className="mt-1 text-muted-foreground">The command installs the Pi version pinned in <code>docker/core.Dockerfile</code>. Use <code>--version &lt;VERSION&gt;</code> only when you deliberately want another version.</p>
<PiCodeBlock className="mt-2">{details.updateCommand}</PiCodeBlock>
<p className="mt-2 text-[11px] text-muted-foreground">Advanced: pull an immutable, digest-pinned image.</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.pullCommand}</PiCodeBlock>
+59 -2
View File
@@ -114,7 +114,13 @@ test("level one explains the read-only Git sequence and the repository update bu
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).getAllByRole("listitem")[0]).toHaveTextContent(/choosing any local directory you prefer/i);
const repositoryStep = within(overview).getAllByRole("listitem")[0];
expect(repositoryStep).toHaveTextContent(/create a workspace repository/i);
expect(repositoryStep).toHaveTextContent(/one directory for each workspace/i);
expect(repositoryStep).toHaveTextContent(/thoth-workspaces\.yaml/i);
expect(repositoryStep).toHaveTextContent(/database connection/i);
expect(repositoryStep).toHaveTextContent(/Evidence sources/i);
expect(repositoryStep).toHaveTextContent(/vector-database collection/i);
expect(within(overview).getByRole("link", { name: /workspace authoring instructions on GitHub/i })).toHaveAttribute(
"href",
"https://github.com/mptyl/ThothII/blob/main/docs/install/local-workspace-registry.md#prepare-and-publish-a-workspace-source",
@@ -126,7 +132,7 @@ test("level one explains the read-only Git sequence and the repository update bu
await user.click(screen.getByRole("button", { name: "Update workspace repository" }));
expect(await screen.findByText("Workspace repository updated and validated.")).toBeVisible();
expect(screen.getByText(/create a local workspace/i)).toBeInTheDocument();
expect(screen.getByText(/create a workspace repository/i)).toBeInTheDocument();
expect(screen.queryByText(/import|export|bundle/i)).not.toBeInTheDocument();
});
@@ -141,10 +147,61 @@ test("workspace-specific commands remain isolated until a workspace is selected"
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();
const databaseField = screen.getByText("Database").parentElement;
expect(databaseField).not.toBeNull();
expect(databaseField).toHaveTextContent("engine: postgres");
expect(databaseField).toHaveTextContent("database: database");
expect(databaseField).toHaveTextContent("schema: public");
expect(screen.getByRole("button", { name: "Validate workspace source" })).toBeVisible();
expect(screen.getByRole("button", { name: "Test workspace connections" })).toBeVisible();
});
test("keeps validation and connection results inside their respective action cards", async () => {
const user = userEvent.setup();
server.use(
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
activatable: false,
diagnostics: [{ level: "error", code: "connector_unavailable", message: "Connector diagnostic failed." }],
})),
);
renderManager();
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
const validationCard = screen.getByTestId("workspace-validation-card");
const connectionCard = screen.getByTestId("workspace-connection-card");
await user.click(within(validationCard).getByRole("button", { name: "Validate workspace source" }));
const validationStatus = await within(validationCard).findByRole("status");
expect(validationStatus).toHaveTextContent("Workspace source is valid.");
expect(validationStatus).toHaveClass("text-emerald-700");
expect(within(connectionCard).queryByText("Workspace source is valid.")).not.toBeInTheDocument();
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
expect(await within(connectionCard).findByRole("alert")).toHaveTextContent(
"connector_unavailable: Connector diagnostic failed.",
);
expect(within(validationCard).queryByText("connector_unavailable: Connector diagnostic failed.")).not.toBeInTheDocument();
});
test("renders binding_ok as a green connection success", async () => {
const user = userEvent.setup();
server.use(
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
activatable: true,
diagnostics: [{ level: "info", code: "binding_ok", message: "Installation bindings and diagnostics succeeded." }],
})),
);
renderManager();
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
const connectionCard = screen.getByTestId("workspace-connection-card");
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
const connectionStatus = await within(connectionCard).findByRole("status");
expect(connectionStatus).toHaveTextContent("binding_ok: Installation bindings and diagnostics succeeded.");
expect(connectionStatus).toHaveClass("text-emerald-700");
expect(within(connectionCard).queryByRole("alert")).not.toBeInTheDocument();
});
test("secret fields are write-only, clear after blind save, and may be forgotten", async () => {
const user = userEvent.setup();
let savedBody: unknown;
+69 -13
View File
@@ -66,6 +66,10 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
const [notice, setNotice] = useState<string>();
const [diagnostics, setDiagnostics] = useState<string[]>([]);
const [validationNotice, setValidationNotice] = useState<string>();
const [validationDiagnostics, setValidationDiagnostics] = useState<string[]>([]);
const [connectionNotice, setConnectionNotice] = useState<string>();
const [connectionDiagnostics, setConnectionDiagnostics] = useState<string[]>([]);
const [busyAction, setBusyAction] = useState<string>();
const statusQuery = useQuery({
@@ -97,6 +101,15 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
const clearMessages = () => {
setNotice(undefined);
setDiagnostics([]);
setValidationNotice(undefined);
setValidationDiagnostics([]);
setConnectionNotice(undefined);
setConnectionDiagnostics([]);
};
const clearGlobalMessages = () => {
setNotice(undefined);
setDiagnostics([]);
};
const close = () => {
@@ -139,12 +152,14 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
async function validateSource() {
if (!detailQuery.data) return;
setBusyAction("validate");
clearMessages();
clearGlobalMessages();
setValidationNotice(undefined);
setValidationDiagnostics([]);
try {
await validateWorkspace(detailQuery.data.workspace);
setNotice("Workspace source is valid.");
setValidationNotice("Workspace source is valid.");
} catch (error) {
setDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
} finally {
setBusyAction(undefined);
}
@@ -153,17 +168,23 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
async function testConnections() {
if (!selectedId) return;
setBusyAction("test");
clearMessages();
clearGlobalMessages();
setConnectionNotice(undefined);
setConnectionDiagnostics([]);
try {
const result = await testWorkspace(selectedId);
setDiagnostics(result.diagnostics.map(({ code, message }) => `${code}: ${message}`));
if (result.diagnostics.length === 0) {
setNotice(result.activatable
? "Workspace connections are valid."
const issues = result.diagnostics.filter(({ level }) => level !== "info");
const informational = result.diagnostics.find(({ level }) => level === "info");
setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`));
if (issues.length === 0) {
setConnectionNotice(result.activatable
? informational
? `${informational.code}: ${informational.message}`
: "Workspace connections are valid."
: "Workspace connection test completed.");
}
} catch (error) {
setDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
} finally {
setBusyAction(undefined);
}
@@ -293,7 +314,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<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><span className="font-medium text-foreground">Create a local workspace</span> by choosing any local directory you prefer and setting up the workspace there. It must contain <code>workspace.yaml</code> and every required subdirectory, including any versioned Evidence files.</li>
<li><span className="font-medium text-foreground">Create a workspace repository</span> in any local directory you choose. Add one directory for each workspace you want ThothII to manage. At the repository root, <code>thoth-workspaces.yaml</code> lists those workspaces; each workspace directory contains its own <code>workspace.yaml</code>, which declares the database connection, the Evidence sources, and the ThothII vector-database collection used during the process.</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>
@@ -337,21 +358,56 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<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">Database</dt>
<dd className="grid gap-0.5 font-mono text-xs">
<span>engine: {detailQuery.data.workspace.dwh.engine}</span>
<span>database: {detailQuery.data.workspace.dwh.database}</span>
<span>schema: {detailQuery.data.workspace.dwh.schema}</span>
</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">
<div data-testid="workspace-validation-card" 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>
{validationNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{validationNotice}
</p>
)}
{validationDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{validationDiagnostics.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>
)}
<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">
<div data-testid="workspace-connection-card" 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>
{connectionNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{connectionNotice}
</p>
)}
{connectionDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{connectionDiagnostics.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>
)}
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
<FlaskConical />Test workspace connections
</Button>