Compare commits

13 changed files with 379 additions and 62 deletions
+6 -1
View File
@@ -88,7 +88,9 @@ async function workflowDiagnostics(config: AppConfig): Promise<{ ready: true; wo
if (revisions.length === 0) throw new Error("workflow diagnostics unavailable");
return await withOperatorRunner(config, async (runner) => {
for (const revision of revisions) {
const result = await runner.run(["doctor", "--json"], revision.snapshotPath);
const runtime = await runner.acquireWorkspaceRuntime(revision.snapshotPath);
try {
const result = await runner.run(["doctor", "--json"], runtime.path);
let payload: unknown;
try {
payload = JSON.parse(result.stdout);
@@ -99,6 +101,9 @@ async function workflowDiagnostics(config: AppConfig): Promise<{ ready: true; wo
result.code !== 0 || !payload || typeof payload !== "object"
|| (payload as { ok?: unknown }).ok !== true
) throw new Error("workflow diagnostics failed");
} finally {
runtime.release();
}
}
return { ready: true, workspaces: revisions.length };
});
+29 -7
View File
@@ -150,13 +150,34 @@ export function sessionRoutes(
});
app.addHook("onResponse", async (req) => { admissionLeases.get(req)?.(); });
type SessionRevisionScan = {
revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
retainedComplete: boolean;
};
let retainedSnapshotWarning: string | null = null;
/** Include retained historical descriptors so removed workspaces remain resumable. */
const sessionRevisions = async () => {
const sessionRevisions = async (): Promise<SessionRevisionScan> => {
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
if (typeof registry.listRetainedSnapshots === "function") {
return await registry.listRetainedSnapshots();
try {
const revisions = await registry.listRetainedSnapshots();
retainedSnapshotWarning = null;
return { revisions, retainedComplete: true };
} catch (error) {
// A legacy/corrupt historical snapshot must not make active sessions (and their SSE
// reviewer gates) unreachable. The registry still rejects that snapshot; this fallback
// exposes only descriptors from the verified active state and deliberately disables
// retention reconciliation because the resulting session view is incomplete.
const detail = error instanceof Error ? error.message : "unknown error";
if (retainedSnapshotWarning !== detail) {
console.warn("[sessions] retained snapshot discovery failed; using active snapshots:", detail);
retainedSnapshotWarning = detail;
}
return await d.workspaceRegistry.list();
return { revisions: await d.workspaceRegistry.list(), retainedComplete: false };
}
}
return { revisions: await d.workspaceRegistry.list(), retainedComplete: true };
};
const isNotFound = (error: unknown) =>
@@ -200,7 +221,7 @@ export function sessionRoutes(
};
let revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
try {
revisions = await sessionRevisions();
({ revisions } = await sessionRevisions());
} catch (registryError) {
// Sessions created before revision pinning still live under the installation's legacy
// default config. Keep that compatibility path available when a fresh installation has
@@ -559,8 +580,8 @@ export function sessionRoutes(
? { ...principal, isAdmin: false }
: ownershipPrincipal(principal, "session.read_all");
const runner = runnerFor(scopedPrincipal);
const revisions = await sessionRevisions();
const lists = await Promise.all(revisions
const revisionScan = await sessionRevisions();
const lists = await Promise.all(revisionScan.revisions
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
const sessions = new Map<string, SessionRow>();
for (const row of lists.flat()) {
@@ -570,7 +591,8 @@ export function sessionRoutes(
// Only an administrator-visible complete list (or the single local principal) is safe
// input for retention. A remote per-user view can never discard another principal's pin.
const reconcileSnapshotRetention = (d.workspaceRegistry as Partial<WorkspaceRegistry>).reconcileSnapshotRetention;
const hasCompleteRetentionView = (scope === "all" || principal.issuer === "local")
const hasCompleteRetentionView = revisionScan.retainedComplete
&& (scope === "all" || principal.issuer === "local")
&& hasPermission(principal, "session.read_all");
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
const retained = [...new Set(list
+18
View File
@@ -5,6 +5,8 @@ const fakes = vi.hoisted(() => ({
catalogRepository: { close: vi.fn(async () => {}) },
createCatalogRepository: vi.fn(),
runnerConfig: undefined as Record<string, unknown> | undefined,
acquireWorkspaceRuntime: vi.fn(),
releaseWorkspaceRuntime: vi.fn(),
run: vi.fn(async () => ({
code: 0,
stdout: JSON.stringify({ ok: true }),
@@ -24,6 +26,8 @@ vi.mock("../src/tht/tht-runner.js", () => ({
run = fakes.run;
acquireWorkspaceRuntime = fakes.acquireWorkspaceRuntime;
withPrincipal() {
return this;
}
@@ -67,6 +71,12 @@ beforeEach(() => {
fakes.createCatalogRepository.mockReset();
fakes.createCatalogRepository.mockReturnValue(fakes.catalogRepository);
fakes.run.mockClear();
fakes.acquireWorkspaceRuntime.mockReset();
fakes.acquireWorkspaceRuntime.mockResolvedValue({
path: "/data/workspace-registry/snapshots/runtime/workspace.yaml",
release: fakes.releaseWorkspaceRuntime,
});
fakes.releaseWorkspaceRuntime.mockClear();
fakes.runnerConfig = undefined;
});
@@ -78,5 +88,13 @@ test("workflow doctor gives schema-v4 runtime rendering a live Catalog repositor
expect(fakes.createCatalogRepository).toHaveBeenCalledWith(config.catalogDatabase);
expect(fakes.runnerConfig?.catalogRepository).toBe(fakes.catalogRepository);
expect(fakes.acquireWorkspaceRuntime).toHaveBeenCalledWith(
"/data/workspace-registry/snapshots/revision/workspace.yaml",
);
expect(fakes.run).toHaveBeenCalledWith(
["doctor", "--json"],
"/data/workspace-registry/snapshots/runtime/workspace.yaml",
);
expect(fakes.releaseWorkspaceRuntime).toHaveBeenCalledOnce();
expect(fakes.catalogRepository.close).toHaveBeenCalledOnce();
});
+59
View File
@@ -364,6 +364,65 @@ test("retention scans a removed workspace's retained snapshot", async () => {
expect(response.json()).toEqual([expect.objectContaining({ id: "resumable" })]);
});
test("active sessions remain available when retained snapshot discovery is unreadable", async () => {
const activeSnapshot = "/registry/snapshots/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/active.yaml";
const retainedFailure = "invalid retained snapshot /registry/snapshots/secret/legacy.yaml";
const reconcileSnapshotRetention = vi.fn(async () => {});
const subscribed = vi.fn();
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
const manifest = {
id: "live-session", status: "open", archived: false,
workspace_id: "active", workspace_revision: "a".repeat(40),
};
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
withPrincipal: () => ({
sessionList: async (snapshotPath: string) => {
expect(snapshotPath).toBe(activeSnapshot);
return [manifest];
},
sessionShow: async (id: string, snapshotPath?: string) => {
if (id === manifest.id && snapshotPath === activeSnapshot) return manifest;
throw new Error("session not found");
},
}),
} as any,
mgr: { get: () => undefined } as any,
hub: {
subscribe: (_id: string, _send: unknown, options: { close?: () => void }) => {
subscribed();
options.close?.();
return () => {};
},
} as any,
workspaceRegistry: {
listRetainedSnapshots: async () => { throw new Error(retainedFailure); },
list: async () => [{
id: "active", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: activeSnapshot,
}],
reconcileSnapshotRetention,
} as any,
});
try {
const list = await app.inject({ method: "GET", url: "/sessions", headers: aliceHeaders });
const detail = await app.inject({ method: "GET", url: `/sessions/${manifest.id}`, headers: aliceHeaders });
const events = await app.inject({ method: "GET", url: `/sessions/${manifest.id}/events`, headers: aliceHeaders });
expect(list.statusCode).toBe(200);
expect(list.json()).toEqual([expect.objectContaining({ id: manifest.id, active: false })]);
expect(detail.statusCode).toBe(200);
expect(detail.json()).toMatchObject(manifest);
expect(events.statusCode).toBe(200);
expect(subscribed).toHaveBeenCalledOnce();
expect(reconcileSnapshotRetention).not.toHaveBeenCalled();
expect(list.body + detail.body + events.body).not.toContain(retainedFailure);
} finally {
warning.mockRestore();
await app.close();
}
});
test("the single local installation listing reconciles its resumable workspace pins", async () => {
const retained = vi.fn(async () => {});
const retainedRevision = "d".repeat(40);
@@ -0,0 +1,107 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
test.describe.configure({ mode: "serial" });
test.use({ viewport: { width: 1660, height: 822 } });
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
function json(route: Route, body: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function signInAsAdmin(page: Page) {
const account = stack.localAccount("admin");
await page.getByLabel("Username").fill(account.username);
await page.locator('input[name="password"]').fill(account.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
}
test.beforeAll(async () => {
stack = await createAuthenticationStack({ withF1Workspace: true });
await stack.useLocalMode();
});
test.afterAll(async () => {
await stack?.close();
});
test("Administration stays within its rail when hosted by the Omics portal", async ({ page }) => {
await page.route("**/api/settings", (route) => json(route, {
workspace: "fixture-workspace",
provider: "zai",
model: "glm-5.2",
thinking: "medium",
}));
await page.route("**/api/workspaces", (route) => json(route, [{
id: "fixture-workspace",
name: "fixture-workspace",
file: "fixture-workspace/workspace.yaml",
displayName: "Fixture workspace",
configurationState: "ready",
revision: {
id: "fixture-workspace",
commit: "a".repeat(40),
blob: "b".repeat(40),
snapshotPath: `/snapshots/${"a".repeat(40)}/fixture-workspace.yaml`,
},
}]));
await page.route("**/api/workspaces/fixture-workspace/preprocessing", (route) => json(route, {
schemaVersion: 1,
workspaceId: "fixture-workspace",
state: "running",
actionable: false,
clearable: false,
detail: "Building schema and LSH.",
progress: {
stage: "schema_index",
step: 2,
totalSteps: 4,
},
}));
await page.goto(stack.publicUrl);
await signInAsAdmin(page);
await expect(page.getByRole("combobox", { name: "Workspace" })).toHaveValue("fixture-workspace");
// Hyper/Bootstrap and Thoth currently share the portal document. These
// utility names are therefore resolved with the portal's !important values.
await page.addStyleTag({ content: `
.px-2 { padding-right: 0.75rem !important; padding-left: 0.75rem !important; }
.px-3 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; }
.px-4 { padding-right: 2.25rem !important; padding-left: 2.25rem !important; }
` });
const rail = page.getByRole("complementary", { name: "Session navigation" });
await rail.getByRole("button", { name: "Administration", exact: true }).click();
const administration = rail.getByRole("region", { name: "Administration" });
const boundary = rail.getByTestId("administration-boundary");
await expect(administration).toBeVisible();
await expect(rail.getByText("2 / 4", { exact: true })).toBeVisible();
const containment = await administration.evaluate((panel) => {
const elements = Array.from(panel.querySelectorAll<HTMLElement>('button, [role="progressbar"], span'))
.filter((element) => element.matches('button, [role="progressbar"]') || element.textContent?.trim() === "2 / 4");
return elements.map((element) => {
const box = element.getBoundingClientRect();
return {
label: element.textContent?.trim() || element.getAttribute("aria-label") || element.tagName,
left: box.left,
right: box.right,
};
});
});
const boundaryBox = await boundary.boundingBox();
expect(boundaryBox).not.toBeNull();
if (!boundaryBox) return;
for (const item of containment) {
expect.soft(item.left, `${item.label} crosses the Administration left edge`).toBeGreaterThanOrEqual(boundaryBox.x - 1);
expect.soft(item.right, `${item.label} crosses the Administration right edge`).toBeLessThanOrEqual(boundaryBox.x + boundaryBox.width + 1);
}
});
@@ -426,12 +426,15 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
const administrationPanel = adminNavigationRail.getByRole("region", { name: "Administration" });
await expect(administrationPanel).toBeVisible();
expect(await administrationPanel.locator(":scope > *").evaluateAll((elements) => elements.map((element) => (
element.getAttribute("role") === "separator" ? "separator" : element.textContent?.trim()
element.getAttribute("role") === "separator"
? "separator"
: element.getAttribute("aria-label") ?? element.textContent?.trim()
)))).toEqual([
"Database management",
"separator",
"Workspace management",
"Pi management",
"Workspace preprocessing",
]);
await administration.click();
+15
View File
@@ -181,6 +181,21 @@
* Scoped so it never leaks into widgets.
*/
@layer components {
/* Omics Portal and Thoth share one document in the embedded deployment.
Keep rail geometry on product-specific class names so Hyper/Bootstrap's
global spacing utilities (for example .px-4 !important) cannot resize it. */
.thot-session-navigation__header {
padding: 1.25rem 1rem 0.75rem;
}
.thot-session-navigation__primary-controls {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
padding: 0 1rem 0.75rem;
}
/* Standard micro-label: panel headers, nav sections, meta rows. Set in the
mono register so labels/meta read as a distinct typographic layer from the
sans body prose (mono needs less tracking than the sans did). */
+12 -11
View File
@@ -855,7 +855,7 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Right session rail */}
{(activeSurface === "database-management" || !showActivity) && (
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
<div className="relative px-4 pb-3 pt-5 text-center">
<div className="thot-session-navigation__header relative text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
</h1>
@@ -878,11 +878,11 @@ export function AppShell({ canLogout }: AppShellProps) {
)}
</div>
<div className="flex flex-col gap-2 px-4 pb-3">
<div className="thot-session-navigation__primary-controls">
<Button
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "core" ? "page" : undefined}
data-navigation-state={preprocessingBlocksNewSession
? "unavailable"
@@ -897,15 +897,16 @@ export function AppShell({ canLogout }: AppShellProps) {
<Accordion.Root
value={adminNavigationValue}
onValueChange={setAdminNavigationValue}
className="rounded-lg border border-border/80 bg-card/45 p-1.5"
data-testid="administration-boundary"
className="min-w-0 max-w-full rounded-lg border border-border/80 bg-card/45 p-1.5"
>
<Accordion.Item value="administration">
<Accordion.Header className="m-0">
<Accordion.Item value="administration" className="min-w-0 max-w-full">
<Accordion.Header className="m-0 min-w-0 max-w-full">
<Accordion.Trigger
className={buttonVariants({
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
size: "sm",
className: "w-full justify-between px-2.5",
className: "w-full min-w-0 max-w-full justify-between px-2.5",
})}
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
>
@@ -917,11 +918,11 @@ export function AppShell({ canLogout }: AppShellProps) {
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="grid gap-1.5 px-0.5 pt-1.5">
<Accordion.Panel className="grid min-w-0 max-w-full gap-1.5 px-0.5 pt-1.5">
<Button
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "database" ? "page" : undefined}
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
disabled={!canManageDatabase}
@@ -937,7 +938,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<Button
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-expanded={activeManagementPanel === "workspace"}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
@@ -952,7 +953,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
size="sm"
className="w-full"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "pi" ? "page" : undefined}
aria-expanded={activeManagementPanel === "pi"}
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
@@ -57,8 +57,8 @@ function StatusIcon({ state }: { state?: WorkspacePreprocessingState }) {
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
return (
<div className="col-span-2 mt-1 grid gap-1">
<div className="flex items-center justify-between gap-2 text-[9px] leading-none">
<div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-[9px] leading-none">
<span className="min-w-0 truncate font-medium text-foreground">
{PROGRESS_LABELS[progress.stage]}
</span>
@@ -240,7 +240,7 @@ export function WorkspacePreprocessingControl({
<section
aria-label="Workspace preprocessing"
aria-busy={state === "running" || loading || clearMutation.isPending}
className="mt-0.5 border-t border-border/90 px-0.5 pt-2"
className="mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
>
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
Preprocessing
@@ -272,11 +272,11 @@ export function WorkspacePreprocessingControl({
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
</div>
</div>
<div className="grid grid-cols-[0.78fr_1.22fr] gap-1.5">
<Button type="button" variant="outline" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
<div className="grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
Cancel
</Button>
<Button type="button" variant="destructive" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
</Button>
@@ -284,7 +284,7 @@ export function WorkspacePreprocessingControl({
</div>
) : (
<>
<div className="grid grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5">
<div className="grid min-w-0 max-w-full grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5">
<span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}>
<StatusIcon state={state} />
</span>
@@ -293,12 +293,12 @@ export function WorkspacePreprocessingControl({
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
</div>
{progress && <PhaseProgress progress={progress} />}
<div className="col-span-2 mt-0.5 grid grid-cols-[0.78fr_1.22fr] gap-1.5">
<div className="col-span-2 mt-0.5 grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button
type="button"
variant="outline"
size="xs"
className="h-7 px-2 text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
disabled={!clearable}
title={!canManage ? "Database management permission is required" : undefined}
onClick={() => setConfirmingClear(true)}
@@ -309,7 +309,7 @@ export function WorkspacePreprocessingControl({
type="button"
variant="outline"
size="xs"
className="h-7 px-2 text-[10px]"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px]"
disabled={!actionable}
title={!canManage ? "Database management permission is required" : undefined}
onClick={() => mutation.mutate()}
+6
View File
@@ -10,6 +10,12 @@ test("workspace module exposes validation and in-memory coordination but no draf
expect(workspaceModule).not.toHaveProperty("workspaceDeletionDrafts");
});
test("canonical sanitizer accepts schema-v4 workspaces without an authored dwh block", () => {
const { dwh: _catalogOwnedDwh, ...workspace } = canonicalWorkspaceFixture("psd-clinical");
expect(workspaceModule.sanitizeCanonicalWorkspace(workspace)).toEqual(workspace);
});
test("canonical sanitizer rejects unknown fields rather than persisting them", () => {
expect(workspaceModule.sanitizeCanonicalWorkspace({
...canonicalWorkspaceFixture("psd-clinical"),
+33 -16
View File
@@ -375,26 +375,48 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
const source = exactRecord(value, ["workspace", "dwh", "diagnostics", "evidence"]);
const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]);
const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]);
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
if (!metadata || !dwh) return undefined;
if (!metadata) return undefined;
const id = workspaceId(metadata.id);
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
const name = text(metadata.name);
const language = oneOf(metadata.language, ["en", "it"] as const);
const description = metadata.description === undefined ? undefined : text(metadata.description);
const database = identifier(dwh.database);
const schema = identifier(dwh.schema);
const dwhPort = dwh.port === undefined ? undefined : positiveInteger(dwh.port, 65_535);
const dwhTimeout = dwh.timeout_ms === undefined ? undefined : positiveInteger(dwh.timeout_ms);
const dwhTransports = uniqueChoices(dwh.supported_transports, ["postgres_direct", "rest_api", "ssh_tunnel"] as const);
if (
metadata.schema_version !== 4 || !id || !name || !language || (metadata.description !== undefined && !description)
|| dwh.engine !== "postgres" || !database || !schema || (dwh.port !== undefined && !dwhPort) || (dwh.timeout_ms !== undefined && !dwhTimeout) || !dwhTransports
metadata.schema_version !== 4 || !id || !name || !language
|| (metadata.description !== undefined && !description)
) return undefined;
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
if (source?.evidence !== undefined && !evidence) return undefined;
if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined;
let dwh: CanonicalWorkspace["dwh"];
if (source?.dwh !== undefined) {
const rawDwh = exactRecord(
source.dwh,
["engine", "database", "schema", "port", "timeout_ms", "supported_transports"],
);
if (!rawDwh) return undefined;
const database = identifier(rawDwh.database);
const schema = identifier(rawDwh.schema);
const port = rawDwh.port === undefined ? undefined : positiveInteger(rawDwh.port, 65_535);
const timeout = rawDwh.timeout_ms === undefined ? undefined : positiveInteger(rawDwh.timeout_ms);
const transports = uniqueChoices(
rawDwh.supported_transports,
["postgres_direct", "rest_api", "ssh_tunnel"] as const,
);
if (
rawDwh.engine !== "postgres" || !database || !schema || !transports
|| (rawDwh.port !== undefined && !port)
|| (rawDwh.timeout_ms !== undefined && !timeout)
) return undefined;
dwh = {
engine: "postgres", database, schema,
...(port ? { port } : {}),
...(timeout ? { timeout_ms: timeout } : {}),
supported_transports: transports,
};
}
if (diagnostics?.dwh_rest && !dwh?.supported_transports.includes("rest_api")) return undefined;
return {
workspace: {
schema_version: 4,
@@ -403,12 +425,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
...(description ? { description } : {}),
language,
},
dwh: {
engine: "postgres", database, schema,
...(dwhPort ? { port: dwhPort } : {}),
...(dwhTimeout ? { timeout_ms: dwhTimeout } : {}),
supported_transports: dwhTransports,
},
...(dwh ? { dwh } : {}),
...(diagnostics ? { diagnostics } : {}),
...(evidence ? { evidence } : {}),
};
@@ -140,7 +140,8 @@ func Render(installation config.Installation) (map[string][]byte, error) {
}, nil
}
// Generate publishes all adapters as one directory generation. A failed replacement restores the
// Generate publishes all adapters as one directory generation. An unchanged generation stays in
// place so active file bind mounts keep their source identity. A failed replacement restores the
// previous directory, so callers never observe a successfully returned mixed generation.
func Generate(installation config.Installation) error {
artifacts, err := Render(installation)
@@ -152,6 +153,17 @@ func Generate(installation config.Installation) error {
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("create model projection parent: %w", err)
}
info, statErr := os.Lstat(target)
if statErr == nil {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("current model projection path is not a regular directory")
}
if projectionMatches(target, artifacts) {
return nil
}
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("inspect current model projection generation: %w", statErr)
}
candidate, err := os.MkdirTemp(parent, ".model-projections-candidate-*")
if err != nil {
return fmt.Errorf("create model projection candidate: %w", err)
@@ -161,19 +173,12 @@ func Generate(installation config.Installation) error {
return err
}
info, statErr := os.Lstat(target)
if os.IsNotExist(statErr) {
if err := renameProjectionDirectory(candidate, target); err != nil {
return fmt.Errorf("publish model projection generation: %w", err)
}
return nil
}
if statErr != nil {
return fmt.Errorf("inspect current model projection generation: %w", statErr)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("current model projection path is not a regular directory")
}
previous, err := absentTemporaryPath(parent)
if err != nil {
return fmt.Errorf("reserve previous model projection generation: %w", err)
@@ -191,6 +196,21 @@ func Generate(installation config.Installation) error {
return nil
}
func projectionMatches(directory string, artifacts map[string][]byte) bool {
for _, relative := range sortedArtifactPaths(artifacts) {
path := filepath.Join(directory, filepath.FromSlash(relative))
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
return false
}
actual, err := os.ReadFile(path)
if err != nil || !bytes.Equal(actual, artifacts[relative]) {
return false
}
}
return true
}
func writeProjectionCandidate(directory string, artifacts map[string][]byte) error {
if err := os.Chmod(directory, 0o755); err != nil {
return fmt.Errorf("protect model projection candidate: %w", err)
@@ -56,6 +56,50 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
}
}
func TestGenerateDoesNotReplaceUnchangedProjection(t *testing.T) {
installation := projectionFixture(t)
if err := Generate(installation); err != nil {
t.Fatalf("Generate() initial error = %v", err)
}
paths := []string{
installation.GeneratedModelCatalogPath(),
installation.GeneratedPiModelsPath(),
installation.GeneratedPiSettingsPath(),
installation.ModelProjectionComposePath(),
}
before := make(map[string]os.FileInfo, len(paths))
for _, path := range paths {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
before[path] = info
}
originalRename := renameProjectionDirectory
t.Cleanup(func() { renameProjectionDirectory = originalRename })
renames := 0
renameProjectionDirectory = func(oldPath, newPath string) error {
renames++
return os.Rename(oldPath, newPath)
}
if err := Generate(installation); err != nil {
t.Fatalf("Generate() repeated error = %v", err)
}
if renames != 0 {
t.Fatalf("Generate() replaced an unchanged generation with %d renames", renames)
}
for _, path := range paths {
after, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if !os.SameFile(before[path], after) {
t.Fatalf("Generate() replaced unchanged artifact %q", path)
}
}
}
func TestGenerateRestoresWholePreviousGenerationWhenPublishFails(t *testing.T) {
installation := projectionFixture(t)
if err := Generate(installation); err != nil {