Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818563c408 | ||
|
|
50c546e42d | ||
|
|
651a5c7902 | ||
|
|
28db30bd78 |
@@ -16,6 +16,8 @@ import { loadSettings } from "./settings/settings-store.js";
|
|||||||
import { ThtRunner, type SessionRow } from "./tht/tht-runner.js";
|
import { ThtRunner, type SessionRow } from "./tht/tht-runner.js";
|
||||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||||
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
|
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
|
||||||
|
import { createCatalogRepository } from "./catalog/repository.js";
|
||||||
|
import type { CatalogRepository } from "./catalog/types.js";
|
||||||
|
|
||||||
type OperatorAction = "maintenance-activate" | "maintenance-deactivate" | "maintenance-status"
|
type OperatorAction = "maintenance-activate" | "maintenance-deactivate" | "maintenance-status"
|
||||||
| "session-inventory" | "workflow-doctor" | "workspace-integrity"
|
| "session-inventory" | "workflow-doctor" | "workspace-integrity"
|
||||||
@@ -30,7 +32,7 @@ const lifecyclePrincipal: PrincipalContext = {
|
|||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function operatorRunner(config: AppConfig): ThtRunner {
|
function operatorRunner(config: AppConfig, catalogRepository: CatalogRepository): ThtRunner {
|
||||||
const workspaceSecretStore = new WorkspaceSecretStore({
|
const workspaceSecretStore = new WorkspaceSecretStore({
|
||||||
root: config.workspaceSecretStoreRoot,
|
root: config.workspaceSecretStoreRoot,
|
||||||
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
||||||
@@ -46,6 +48,7 @@ function operatorRunner(config: AppConfig): ThtRunner {
|
|||||||
secretsFile: config.secretsFile,
|
secretsFile: config.secretsFile,
|
||||||
secretFiles: config.secretFiles,
|
secretFiles: config.secretFiles,
|
||||||
workspaceSecretStore,
|
workspaceSecretStore,
|
||||||
|
catalogRepository,
|
||||||
semanticRuntime: {
|
semanticRuntime: {
|
||||||
internalQdrantUrl: config.internalQdrantUrl,
|
internalQdrantUrl: config.internalQdrantUrl,
|
||||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||||
@@ -55,36 +58,55 @@ function operatorRunner(config: AppConfig): ThtRunner {
|
|||||||
}).withPrincipal(lifecyclePrincipal);
|
}).withPrincipal(lifecyclePrincipal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withOperatorRunner<T>(
|
||||||
|
config: AppConfig,
|
||||||
|
operation: (runner: ThtRunner) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const catalogRepository = createCatalogRepository(config.catalogDatabase);
|
||||||
|
try {
|
||||||
|
return await operation(operatorRunner(config, catalogRepository));
|
||||||
|
} finally {
|
||||||
|
await catalogRepository.close?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sessionInventory(config: AppConfig): Promise<Array<Pick<SessionRow, "status" | "archived">>> {
|
async function sessionInventory(config: AppConfig): Promise<Array<Pick<SessionRow, "status" | "archived">>> {
|
||||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||||
const revisions = await registry.listRetainedSnapshots();
|
const revisions = await registry.listRetainedSnapshots();
|
||||||
const runner = operatorRunner(config);
|
return await withOperatorRunner(config, async (runner) => {
|
||||||
const sessions = new Map<string, SessionRow>();
|
const sessions = new Map<string, SessionRow>();
|
||||||
for (const revision of revisions) {
|
for (const revision of revisions) {
|
||||||
for (const session of await runner.sessionList(revision.snapshotPath)) sessions.set(session.id, session);
|
for (const session of await runner.sessionList(revision.snapshotPath)) sessions.set(session.id, session);
|
||||||
}
|
}
|
||||||
return [...sessions.values()].map(({ status, archived }) => ({ status, archived: archived === true }));
|
return [...sessions.values()].map(({ status, archived }) => ({ status, archived: archived === true }));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function workflowDiagnostics(config: AppConfig): Promise<{ ready: true; workspaces: number }> {
|
async function workflowDiagnostics(config: AppConfig): Promise<{ ready: true; workspaces: number }> {
|
||||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||||
const revisions = await registry.listRetainedSnapshots();
|
const revisions = await registry.listRetainedSnapshots();
|
||||||
if (revisions.length === 0) throw new Error("workflow diagnostics unavailable");
|
if (revisions.length === 0) throw new Error("workflow diagnostics unavailable");
|
||||||
const runner = operatorRunner(config);
|
return await withOperatorRunner(config, async (runner) => {
|
||||||
for (const revision of revisions) {
|
for (const revision of revisions) {
|
||||||
const result = await runner.run(["doctor", "--json"], revision.snapshotPath);
|
const runtime = await runner.acquireWorkspaceRuntime(revision.snapshotPath);
|
||||||
let payload: unknown;
|
try {
|
||||||
try {
|
const result = await runner.run(["doctor", "--json"], runtime.path);
|
||||||
payload = JSON.parse(result.stdout);
|
let payload: unknown;
|
||||||
} catch {
|
try {
|
||||||
throw new Error("workflow diagnostics failed");
|
payload = JSON.parse(result.stdout);
|
||||||
|
} catch {
|
||||||
|
throw new Error("workflow diagnostics failed");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.code !== 0 || !payload || typeof payload !== "object"
|
||||||
|
|| (payload as { ok?: unknown }).ok !== true
|
||||||
|
) throw new Error("workflow diagnostics failed");
|
||||||
|
} finally {
|
||||||
|
runtime.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
return { ready: true, workspaces: revisions.length };
|
||||||
result.code !== 0 || !payload || typeof payload !== "object"
|
});
|
||||||
|| (payload as { ok?: unknown }).ok !== true
|
|
||||||
) throw new Error("workflow diagnostics failed");
|
|
||||||
}
|
|
||||||
return { ready: true, workspaces: revisions.length };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function workspaceIntegrity(config: AppConfig): Promise<{
|
async function workspaceIntegrity(config: AppConfig): Promise<{
|
||||||
|
|||||||
@@ -150,13 +150,34 @@ export function sessionRoutes(
|
|||||||
});
|
});
|
||||||
app.addHook("onResponse", async (req) => { admissionLeases.get(req)?.(); });
|
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. */
|
/** Include retained historical descriptors so removed workspaces remain resumable. */
|
||||||
const sessionRevisions = async () => {
|
const sessionRevisions = async (): Promise<SessionRevisionScan> => {
|
||||||
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||||
if (typeof registry.listRetainedSnapshots === "function") {
|
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 { revisions: await d.workspaceRegistry.list(), retainedComplete: false };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return await d.workspaceRegistry.list();
|
return { revisions: await d.workspaceRegistry.list(), retainedComplete: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNotFound = (error: unknown) =>
|
const isNotFound = (error: unknown) =>
|
||||||
@@ -200,7 +221,7 @@ export function sessionRoutes(
|
|||||||
};
|
};
|
||||||
let revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
|
let revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
|
||||||
try {
|
try {
|
||||||
revisions = await sessionRevisions();
|
({ revisions } = await sessionRevisions());
|
||||||
} catch (registryError) {
|
} catch (registryError) {
|
||||||
// Sessions created before revision pinning still live under the installation's legacy
|
// Sessions created before revision pinning still live under the installation's legacy
|
||||||
// default config. Keep that compatibility path available when a fresh installation has
|
// default config. Keep that compatibility path available when a fresh installation has
|
||||||
@@ -559,8 +580,8 @@ export function sessionRoutes(
|
|||||||
? { ...principal, isAdmin: false }
|
? { ...principal, isAdmin: false }
|
||||||
: ownershipPrincipal(principal, "session.read_all");
|
: ownershipPrincipal(principal, "session.read_all");
|
||||||
const runner = runnerFor(scopedPrincipal);
|
const runner = runnerFor(scopedPrincipal);
|
||||||
const revisions = await sessionRevisions();
|
const revisionScan = await sessionRevisions();
|
||||||
const lists = await Promise.all(revisions
|
const lists = await Promise.all(revisionScan.revisions
|
||||||
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
|
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
|
||||||
const sessions = new Map<string, SessionRow>();
|
const sessions = new Map<string, SessionRow>();
|
||||||
for (const row of lists.flat()) {
|
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
|
// 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.
|
// input for retention. A remote per-user view can never discard another principal's pin.
|
||||||
const reconcileSnapshotRetention = (d.workspaceRegistry as Partial<WorkspaceRegistry>).reconcileSnapshotRetention;
|
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");
|
&& hasPermission(principal, "session.read_all");
|
||||||
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
|
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
|
||||||
const retained = [...new Set(list
|
const retained = [...new Set(list
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import type { AppConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
stderr: "",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../src/catalog/repository.js", () => ({
|
||||||
|
createCatalogRepository: fakes.createCatalogRepository,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../src/tht/tht-runner.js", () => ({
|
||||||
|
ThtRunner: class {
|
||||||
|
constructor(config: Record<string, unknown>) {
|
||||||
|
fakes.runnerConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
run = fakes.run;
|
||||||
|
|
||||||
|
acquireWorkspaceRuntime = fakes.acquireWorkspaceRuntime;
|
||||||
|
|
||||||
|
withPrincipal() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../src/workspaces/registry.js", () => ({
|
||||||
|
WorkspaceRegistry: class {
|
||||||
|
async listRetainedSnapshots() {
|
||||||
|
return [{ snapshotPath: "/data/workspace-registry/snapshots/revision/workspace.yaml" }];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../src/workspaces/secret-store.js", () => ({
|
||||||
|
WorkspaceSecretStore: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { runOperatorAction } from "../src/operator-command.js";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
catalogDatabase: { host: "catalog-db" },
|
||||||
|
workspaceSecretStoreRoot: "/data/workspace-secrets",
|
||||||
|
workspaceSecretRuntimeRoot: "/tmp/workspace-secrets",
|
||||||
|
workspaceRegistry: {
|
||||||
|
installationId: "test",
|
||||||
|
root: "/data/workspace-registry",
|
||||||
|
secretRoots: ["/run/secrets"],
|
||||||
|
},
|
||||||
|
thtBin: "/opt/venv/bin/tht",
|
||||||
|
harnessDir: "/app/harness",
|
||||||
|
dataRoot: "/data",
|
||||||
|
internalQdrantUrl: "http://qdrant:6333",
|
||||||
|
internalEmbeddingUrl: "http://embedding:11434",
|
||||||
|
internalEmbeddingModel: "qwen3-embedding:0.6b",
|
||||||
|
internalEmbeddingDimensions: 1024,
|
||||||
|
} as AppConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fakes.catalogRepository.close.mockClear();
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workflow doctor gives schema-v4 runtime rendering a live Catalog repository", async () => {
|
||||||
|
await expect(runOperatorAction("workflow-doctor", config)).resolves.toEqual({
|
||||||
|
ready: true,
|
||||||
|
workspaces: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -364,6 +364,65 @@ test("retention scans a removed workspace's retained snapshot", async () => {
|
|||||||
expect(response.json()).toEqual([expect.objectContaining({ id: "resumable" })]);
|
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 () => {
|
test("the single local installation listing reconciles its resumable workspace pins", async () => {
|
||||||
const retained = vi.fn(async () => {});
|
const retained = vi.fn(async () => {});
|
||||||
const retainedRevision = "d".repeat(40);
|
const retainedRevision = "d".repeat(40);
|
||||||
|
|||||||
+14
-6
@@ -5,12 +5,20 @@ COPY tools/tht/go.mod tools/tht/go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY tools/tht ./
|
COPY tools/tht ./
|
||||||
|
|
||||||
RUN mkdir -p /out \
|
ARG THT_VERSION=0.0.0-dev
|
||||||
&& CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/tht-windows-amd64.exe ./cmd/tht \
|
ARG THT_COMMIT=unknown
|
||||||
&& CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/tht-darwin-amd64 ./cmd/tht \
|
ARG THT_BUILD_TIME=unknown
|
||||||
&& CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o /out/tht-darwin-arm64 ./cmd/tht \
|
|
||||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/tht-linux-amd64 ./cmd/tht \
|
RUN linker_flags="-s -w \
|
||||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o /out/tht-linux-arm64 ./cmd/tht
|
-X github.com/aritmolab/thothii/tools/tht/internal/version.semanticVersion=${THT_VERSION} \
|
||||||
|
-X github.com/aritmolab/thothii/tools/tht/internal/version.commit=${THT_COMMIT} \
|
||||||
|
-X github.com/aritmolab/thothii/tools/tht/internal/version.buildTime=${THT_BUILD_TIME}" \
|
||||||
|
&& mkdir -p /out \
|
||||||
|
&& CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="$linker_flags" -o /out/tht-windows-amd64.exe ./cmd/tht \
|
||||||
|
&& CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="$linker_flags" -o /out/tht-darwin-amd64 ./cmd/tht \
|
||||||
|
&& CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="$linker_flags" -o /out/tht-darwin-arm64 ./cmd/tht \
|
||||||
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$linker_flags" -o /out/tht-linux-amd64 ./cmd/tht \
|
||||||
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="$linker_flags" -o /out/tht-linux-arm64 ./cmd/tht
|
||||||
|
|
||||||
FROM scratch AS export
|
FROM scratch AS export
|
||||||
COPY --from=build /out/ /
|
COPY --from=build /out/ /
|
||||||
|
|||||||
@@ -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" });
|
const administrationPanel = adminNavigationRail.getByRole("region", { name: "Administration" });
|
||||||
await expect(administrationPanel).toBeVisible();
|
await expect(administrationPanel).toBeVisible();
|
||||||
expect(await administrationPanel.locator(":scope > *").evaluateAll((elements) => elements.map((element) => (
|
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([
|
)))).toEqual([
|
||||||
"Database management",
|
"Database management",
|
||||||
"separator",
|
"separator",
|
||||||
"Workspace management",
|
"Workspace management",
|
||||||
"Pi management",
|
"Pi management",
|
||||||
|
"Workspace preprocessing",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await administration.click();
|
await administration.click();
|
||||||
|
|||||||
@@ -181,6 +181,21 @@
|
|||||||
* Scoped so it never leaks into widgets.
|
* Scoped so it never leaks into widgets.
|
||||||
*/
|
*/
|
||||||
@layer components {
|
@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
|
/* 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
|
mono register so labels/meta read as a distinct typographic layer from the
|
||||||
sans body prose (mono needs less tracking than the sans did). */
|
sans body prose (mono needs less tracking than the sans did). */
|
||||||
|
|||||||
@@ -855,7 +855,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
{/* Right session rail */}
|
{/* Right session rail */}
|
||||||
{(activeSurface === "database-management" || !showActivity) && (
|
{(activeSurface === "database-management" || !showActivity) && (
|
||||||
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
|
<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">
|
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||||
Thoth<span className="text-primary">II</span>
|
Thoth<span className="text-primary">II</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -878,11 +878,11 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 px-4 pb-3">
|
<div className="thot-session-navigation__primary-controls">
|
||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
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}
|
aria-current={currentNavigation === "core" ? "page" : undefined}
|
||||||
data-navigation-state={preprocessingBlocksNewSession
|
data-navigation-state={preprocessingBlocksNewSession
|
||||||
? "unavailable"
|
? "unavailable"
|
||||||
@@ -897,15 +897,16 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<Accordion.Root
|
<Accordion.Root
|
||||||
value={adminNavigationValue}
|
value={adminNavigationValue}
|
||||||
onValueChange={setAdminNavigationValue}
|
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.Item value="administration" className="min-w-0 max-w-full">
|
||||||
<Accordion.Header className="m-0">
|
<Accordion.Header className="m-0 min-w-0 max-w-full">
|
||||||
<Accordion.Trigger
|
<Accordion.Trigger
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
|
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
|
||||||
size: "sm",
|
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"}
|
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
|
||||||
>
|
>
|
||||||
@@ -917,11 +918,11 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
</Accordion.Header>
|
</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
|
<Button
|
||||||
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
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}
|
aria-current={currentNavigation === "database" ? "page" : undefined}
|
||||||
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
|
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
|
||||||
disabled={!canManageDatabase}
|
disabled={!canManageDatabase}
|
||||||
@@ -937,7 +938,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
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-current={currentNavigation === "workspace" ? "page" : undefined}
|
||||||
aria-expanded={activeManagementPanel === "workspace"}
|
aria-expanded={activeManagementPanel === "workspace"}
|
||||||
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
||||||
@@ -952,7 +953,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
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-current={currentNavigation === "pi" ? "page" : undefined}
|
||||||
aria-expanded={activeManagementPanel === "pi"}
|
aria-expanded={activeManagementPanel === "pi"}
|
||||||
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ function StatusIcon({ state }: { state?: WorkspacePreprocessingState }) {
|
|||||||
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
|
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
|
||||||
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
|
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
|
||||||
return (
|
return (
|
||||||
<div className="col-span-2 mt-1 grid gap-1">
|
<div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
|
||||||
<div className="flex items-center justify-between gap-2 text-[9px] leading-none">
|
<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">
|
<span className="min-w-0 truncate font-medium text-foreground">
|
||||||
{PROGRESS_LABELS[progress.stage]}
|
{PROGRESS_LABELS[progress.stage]}
|
||||||
</span>
|
</span>
|
||||||
@@ -240,7 +240,7 @@ export function WorkspacePreprocessingControl({
|
|||||||
<section
|
<section
|
||||||
aria-label="Workspace preprocessing"
|
aria-label="Workspace preprocessing"
|
||||||
aria-busy={state === "running" || loading || clearMutation.isPending}
|
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">
|
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
|
||||||
Preprocessing
|
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>
|
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[0.78fr_1.22fr] gap-1.5">
|
<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 text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
|
<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
|
Cancel
|
||||||
</Button>
|
</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 ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
|
||||||
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
|
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -284,7 +284,7 @@ export function WorkspacePreprocessingControl({
|
|||||||
</div>
|
</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)}`}>
|
<span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}>
|
||||||
<StatusIcon state={state} />
|
<StatusIcon state={state} />
|
||||||
</span>
|
</span>
|
||||||
@@ -293,12 +293,12 @@ export function WorkspacePreprocessingControl({
|
|||||||
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
|
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
|
||||||
</div>
|
</div>
|
||||||
{progress && <PhaseProgress progress={progress} />}
|
{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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
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}
|
disabled={!clearable}
|
||||||
title={!canManage ? "Database management permission is required" : undefined}
|
title={!canManage ? "Database management permission is required" : undefined}
|
||||||
onClick={() => setConfirmingClear(true)}
|
onClick={() => setConfirmingClear(true)}
|
||||||
@@ -309,7 +309,7 @@ export function WorkspacePreprocessingControl({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
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}
|
disabled={!actionable}
|
||||||
title={!canManage ? "Database management permission is required" : undefined}
|
title={!canManage ? "Database management permission is required" : undefined}
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ test("workspace module exposes validation and in-memory coordination but no draf
|
|||||||
expect(workspaceModule).not.toHaveProperty("workspaceDeletionDrafts");
|
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", () => {
|
test("canonical sanitizer rejects unknown fields rather than persisting them", () => {
|
||||||
expect(workspaceModule.sanitizeCanonicalWorkspace({
|
expect(workspaceModule.sanitizeCanonicalWorkspace({
|
||||||
...canonicalWorkspaceFixture("psd-clinical"),
|
...canonicalWorkspaceFixture("psd-clinical"),
|
||||||
|
|||||||
@@ -375,26 +375,48 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
|
|||||||
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
|
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
|
||||||
const source = exactRecord(value, ["workspace", "dwh", "diagnostics", "evidence"]);
|
const source = exactRecord(value, ["workspace", "dwh", "diagnostics", "evidence"]);
|
||||||
const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]);
|
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);
|
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
|
||||||
if (!metadata || !dwh) return undefined;
|
if (!metadata) return undefined;
|
||||||
const id = workspaceId(metadata.id);
|
const id = workspaceId(metadata.id);
|
||||||
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
|
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
|
||||||
const name = text(metadata.name);
|
const name = text(metadata.name);
|
||||||
const language = oneOf(metadata.language, ["en", "it"] as const);
|
const language = oneOf(metadata.language, ["en", "it"] as const);
|
||||||
const description = metadata.description === undefined ? undefined : text(metadata.description);
|
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 (
|
if (
|
||||||
metadata.schema_version !== 4 || !id || !name || !language || (metadata.description !== undefined && !description)
|
metadata.schema_version !== 4 || !id || !name || !language
|
||||||
|| dwh.engine !== "postgres" || !database || !schema || (dwh.port !== undefined && !dwhPort) || (dwh.timeout_ms !== undefined && !dwhTimeout) || !dwhTransports
|
|| (metadata.description !== undefined && !description)
|
||||||
) return undefined;
|
) return undefined;
|
||||||
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
|
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
|
||||||
if (source?.evidence !== undefined && !evidence) 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 {
|
return {
|
||||||
workspace: {
|
workspace: {
|
||||||
schema_version: 4,
|
schema_version: 4,
|
||||||
@@ -403,12 +425,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
|||||||
...(description ? { description } : {}),
|
...(description ? { description } : {}),
|
||||||
language,
|
language,
|
||||||
},
|
},
|
||||||
dwh: {
|
...(dwh ? { dwh } : {}),
|
||||||
engine: "postgres", database, schema,
|
|
||||||
...(dwhPort ? { port: dwhPort } : {}),
|
|
||||||
...(dwhTimeout ? { timeout_ms: dwhTimeout } : {}),
|
|
||||||
supported_transports: dwhTransports,
|
|
||||||
},
|
|
||||||
...(diagnostics ? { diagnostics } : {}),
|
...(diagnostics ? { diagnostics } : {}),
|
||||||
...(evidence ? { evidence } : {}),
|
...(evidence ? { evidence } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-1
@@ -4,6 +4,11 @@ export DOCKER_BUILDKIT=1
|
|||||||
|
|
||||||
repository_root=$(cd "$(dirname "$0")/.." && pwd)
|
repository_root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
output_directory="${THT_THT_OUTPUT_DIRECTORY:-$repository_root/dist/tht}"
|
output_directory="${THT_THT_OUTPUT_DIRECTORY:-$repository_root/dist/tht}"
|
||||||
|
build_commit=$(git -C "$repository_root" rev-parse HEAD)
|
||||||
|
build_time=$(git -C "$repository_root" show -s --format=%cI HEAD)
|
||||||
|
if ! build_version=$(git -C "$repository_root" describe --tags --exact-match HEAD 2>/dev/null); then
|
||||||
|
build_version=0.0.0-dev
|
||||||
|
fi
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -43,4 +48,10 @@ if [[ "$output_directory" != /* || "$output_directory" == / || "$output_director
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$output_directory"
|
mkdir -p "$output_directory"
|
||||||
docker build --file "$repository_root/docker/tht.Dockerfile" --output "type=local,dest=$output_directory" "$repository_root"
|
docker build \
|
||||||
|
--build-arg "THT_VERSION=$build_version" \
|
||||||
|
--build-arg "THT_COMMIT=$build_commit" \
|
||||||
|
--build-arg "THT_BUILD_TIME=$build_time" \
|
||||||
|
--file "$repository_root/docker/tht.Dockerfile" \
|
||||||
|
--output "type=local,dest=$output_directory" \
|
||||||
|
"$repository_root"
|
||||||
|
|||||||
Regular → Executable
@@ -11,6 +11,8 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test -x "$installer" || fail "install-tht.sh must be executable for the documented invocation"
|
||||||
|
|
||||||
sha256() {
|
sha256() {
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
sha256sum "$1" | awk '{print $1}'
|
sha256sum "$1" | awk '{print $1}'
|
||||||
|
|||||||
@@ -22,7 +22,16 @@ printf '%s\n' "$manifest" | grep -Eq 'Platform:[[:space:]]+linux/arm64'
|
|||||||
|
|
||||||
temporary_output=$(mktemp -d)
|
temporary_output=$(mktemp -d)
|
||||||
trap 'rm -rf "$temporary_output"' EXIT HUP INT TERM
|
trap 'rm -rf "$temporary_output"' EXIT HUP INT TERM
|
||||||
docker build --file "$dockerfile" --output "type=local,dest=$temporary_output" "$repository_root" >/dev/null
|
test_version='9.8.7-test'
|
||||||
|
test_commit='0123456789abcdef0123456789abcdef01234567'
|
||||||
|
test_build_time='2026-09-07T01:00:00+02:00'
|
||||||
|
docker build \
|
||||||
|
--build-arg "THT_VERSION=$test_version" \
|
||||||
|
--build-arg "THT_COMMIT=$test_commit" \
|
||||||
|
--build-arg "THT_BUILD_TIME=$test_build_time" \
|
||||||
|
--file "$dockerfile" \
|
||||||
|
--output "type=local,dest=$temporary_output" \
|
||||||
|
"$repository_root" >/dev/null
|
||||||
|
|
||||||
test -s "$temporary_output/tht-windows-amd64.exe"
|
test -s "$temporary_output/tht-windows-amd64.exe"
|
||||||
test -s "$temporary_output/tht-darwin-amd64"
|
test -s "$temporary_output/tht-darwin-amd64"
|
||||||
@@ -30,4 +39,9 @@ test -s "$temporary_output/tht-darwin-arm64"
|
|||||||
test -s "$temporary_output/tht-linux-amd64"
|
test -s "$temporary_output/tht-linux-amd64"
|
||||||
test -s "$temporary_output/tht-linux-arm64"
|
test -s "$temporary_output/tht-linux-arm64"
|
||||||
|
|
||||||
|
version_json=$($temporary_output/tht-linux-amd64 version --json)
|
||||||
|
printf '%s\n' "$version_json" | grep -Fq '"version":"9.8.7-test"'
|
||||||
|
printf '%s\n' "$version_json" | grep -Fq '"commit":"0123456789abcdef0123456789abcdef01234567"'
|
||||||
|
printf '%s\n' "$version_json" | grep -Fq '"buildTime":"2026-09-07T01:00:00+02:00"'
|
||||||
|
|
||||||
echo "tht build contract passed."
|
echo "tht build contract passed."
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
func TestRestoreAuthBearingArchiveRefusesNonRootBeforeTransactionOrWrite(t *testing.T) {
|
func TestRestoreAuthBearingArchiveRefusesNonRootBeforeTransactionOrWrite(t *testing.T) {
|
||||||
installation, archive := projectedRestoreFixture(t)
|
installation, archive := projectedRestoreFixture(t)
|
||||||
deps := restoreTestDependencies(t, newBackupRunner(installation, false))
|
deps := restoreTestDependencies(t, newBackupRunner(installation, false))
|
||||||
|
deps.requireAuthProjection = requireAuthProjectionRestorePrivilege
|
||||||
checkpointCalled := false
|
checkpointCalled := false
|
||||||
beginCalled := false
|
beginCalled := false
|
||||||
writeCalled := false
|
writeCalled := false
|
||||||
|
|||||||
@@ -373,27 +373,97 @@ func filePermissions(installation config.Installation) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateVolumes checks the eight persistent volumes required by a local ThothII installation.
|
type renderedMount struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderedService struct {
|
||||||
|
Volumes []renderedMount `json:"volumes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderedPersistence struct {
|
||||||
|
Volumes map[string]json.RawMessage `json:"volumes"`
|
||||||
|
Services map[string]renderedService `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiredNamedVolumes = []string{
|
||||||
|
"settings", "pi-state", "workspace-registry", "workspace-secrets",
|
||||||
|
"sessions", "qdrant-data", "embedding-models", "auth-state",
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiredPersistentMounts = []struct {
|
||||||
|
service string
|
||||||
|
target string
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{service: "core", target: "/data/settings", label: "settings"},
|
||||||
|
{service: "core", target: "/home/thoth/.pi", label: "pi-state"},
|
||||||
|
{service: "core", target: "/data/workspace-registry", label: "workspace-registry"},
|
||||||
|
{service: "core", target: "/data/workspace-secrets", label: "workspace-secrets"},
|
||||||
|
{service: "core", target: "/data/sessions", label: "sessions"},
|
||||||
|
{service: "qdrant", target: "/qdrant/storage", label: "qdrant-data"},
|
||||||
|
{service: "embedding", target: "/root/.ollama", label: "embedding-models"},
|
||||||
|
{service: "core", target: "/data/auth", label: "auth-state"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateVolumes accepts either the portable named-volume layout or the server layout where a
|
||||||
|
// writable bind root owns several nested persistence paths. Docker Compose omits unused top-level
|
||||||
|
// volume declarations after a server override, so declarations alone cannot validate that profile.
|
||||||
func ValidateVolumes(rendered string) error {
|
func ValidateVolumes(rendered string) error {
|
||||||
var document struct {
|
var document renderedPersistence
|
||||||
Volumes map[string]json.RawMessage `json:"volumes"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(rendered), &document); err != nil {
|
if err := json.Unmarshal([]byte(rendered), &document); err != nil {
|
||||||
return errors.New("Compose returned invalid rendered configuration")
|
return errors.New("Compose returned invalid rendered configuration")
|
||||||
}
|
}
|
||||||
for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"} {
|
missing := ""
|
||||||
|
for _, name := range requiredNamedVolumes {
|
||||||
if _, exists := document.Volumes[name]; !exists {
|
if _, exists := document.Volumes[name]; !exists {
|
||||||
return fmt.Errorf("rendered Compose configuration is missing required volume %s", name)
|
missing = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if missing == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(document.Services) == 0 {
|
||||||
|
return fmt.Errorf("rendered Compose configuration is missing required volume %s", missing)
|
||||||
|
}
|
||||||
|
for _, required := range requiredPersistentMounts {
|
||||||
|
service, exists := document.Services[required.service]
|
||||||
|
if !exists || !hasWritableMountCovering(service.Volumes, required.target) {
|
||||||
|
return fmt.Errorf("rendered Compose configuration is missing persistent mount %s", required.label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func workspaceRegistryDeclared(rendered string) bool {
|
func workspaceRegistryDeclared(rendered string) bool {
|
||||||
var document struct {
|
var document renderedPersistence
|
||||||
Volumes map[string]json.RawMessage `json:"volumes"`
|
if json.Unmarshal([]byte(rendered), &document) != nil {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return json.Unmarshal([]byte(rendered), &document) == nil && document.Volumes["workspace-registry"] != nil
|
if document.Volumes["workspace-registry"] != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasWritableMountCovering(document.Services["core"].Volumes, "/data/workspace-registry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasWritableMountCovering(mounts []renderedMount, target string) bool {
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if mount.ReadOnly || (mount.Type != "bind" && mount.Type != "volume") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mountTarget := filepath.Clean(mount.Target)
|
||||||
|
if !filepath.IsAbs(mountTarget) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(mountTarget, target)
|
||||||
|
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func commandDetail(label string, result compose.Result, err error, secrets []string) string {
|
func commandDetail(label string, result compose.Result, err error, secrets []string) string {
|
||||||
|
|||||||
@@ -89,6 +89,38 @@ func TestValidateVolumesRequiresAuthState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catches rejecting a server installation whose durable state is provided by bind mounts.
|
||||||
|
func TestRunAcceptsServerBindMountPersistence(t *testing.T) {
|
||||||
|
installation := doctorInstallation(t, "")
|
||||||
|
installation.Profile = "server"
|
||||||
|
runner := &doctorRunner{services: healthyServices, rendered: serverRenderedConfig}
|
||||||
|
|
||||||
|
report, err := Run(context.Background(), installation, runner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !report.OK {
|
||||||
|
t.Fatalf("Run() report = %#v, want healthy server bind-mount installation", report)
|
||||||
|
}
|
||||||
|
if checkStatus(report, "configuration") != StatusPassed ||
|
||||||
|
checkStatus(report, "authentication") != StatusPassed ||
|
||||||
|
checkStatus(report, "workspace-registry") != StatusPassed {
|
||||||
|
t.Fatalf("Run() report = %#v, want server configuration and dependent checks passed", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateVolumesRejectsIncompleteServerBindMountPersistence(t *testing.T) {
|
||||||
|
withoutPiState := strings.Replace(
|
||||||
|
serverRenderedConfig,
|
||||||
|
`{"type": "bind", "source": "/srv/thothii/pi-state", "target": "/home/thoth/.pi"}`,
|
||||||
|
`{"type": "bind", "source": "/srv/thothii/pi-state", "target": "/tmp/pi-state"}`,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
if err := ValidateVolumes(withoutPiState); err == nil || !strings.Contains(err.Error(), "pi-state") {
|
||||||
|
t.Fatalf("ValidateVolumes() error = %v, want missing pi-state persistence", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Catches Docker availability short-circuiting a host file-permission failure.
|
// Catches Docker availability short-circuiting a host file-permission failure.
|
||||||
func TestRunChecksUnsafeFilesEvenWhenDockerIsUnavailable(t *testing.T) {
|
func TestRunChecksUnsafeFilesEvenWhenDockerIsUnavailable(t *testing.T) {
|
||||||
installation := doctorInstallation(t, "")
|
installation := doctorInstallation(t, "")
|
||||||
@@ -302,6 +334,7 @@ type doctorRunner struct {
|
|||||||
calls []string
|
calls []string
|
||||||
dockerUnavailable bool
|
dockerUnavailable bool
|
||||||
services string
|
services string
|
||||||
|
rendered string
|
||||||
workflowFailure string
|
workflowFailure string
|
||||||
registryInvalid bool
|
registryInvalid bool
|
||||||
}
|
}
|
||||||
@@ -320,6 +353,9 @@ func (r *doctorRunner) Run(_ context.Context, args []string, _ io.Reader) (compo
|
|||||||
case strings.Contains(call, "config --quiet"):
|
case strings.Contains(call, "config --quiet"):
|
||||||
return compose.Result{}, nil
|
return compose.Result{}, nil
|
||||||
case strings.Contains(call, "config --format json"):
|
case strings.Contains(call, "config --format json"):
|
||||||
|
if r.rendered != "" {
|
||||||
|
return compose.Result{Stdout: r.rendered}, nil
|
||||||
|
}
|
||||||
return compose.Result{Stdout: renderedConfig}, nil
|
return compose.Result{Stdout: renderedConfig}, nil
|
||||||
case strings.Contains(call, "ps --all --format json"):
|
case strings.Contains(call, "ps --all --format json"):
|
||||||
if r.services == "" {
|
if r.services == "" {
|
||||||
@@ -395,6 +431,24 @@ func assertChecklist(t *testing.T, report Report, want []string) {
|
|||||||
|
|
||||||
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||||
|
|
||||||
|
const serverRenderedConfig = `{
|
||||||
|
"volumes": {"catalog-data": {}, "qdrant-data": {}, "embedding-models": {}},
|
||||||
|
"services": {
|
||||||
|
"core": {
|
||||||
|
"image": "thothii-core:server",
|
||||||
|
"environment": {"THT_LLM_URL": "https://llm.example.invalid"},
|
||||||
|
"volumes": [
|
||||||
|
{"type": "bind", "source": "/srv/thothii/data", "target": "/data"},
|
||||||
|
{"type": "bind", "source": "/srv/thothii/pi-state", "target": "/home/thoth/.pi"},
|
||||||
|
{"type": "bind", "source": "/srv/thothii/workspace-registry", "target": "/data/workspace-registry"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"catalog-db": {"volumes": [{"type": "volume", "source": "catalog-data", "target": "/var/lib/postgresql/data"}]},
|
||||||
|
"qdrant": {"volumes": [{"type": "volume", "source": "qdrant-data", "target": "/qdrant/storage"}]},
|
||||||
|
"embedding": {"volumes": [{"type": "volume", "source": "embedding-models", "target": "/root/.ollama"}]}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
const healthyServices = `[
|
const healthyServices = `[
|
||||||
{"Service":"core","State":"running","Health":"healthy"},
|
{"Service":"core","State":"running","Health":"healthy"},
|
||||||
{"Service":"frontend","State":"running","Health":"healthy"},
|
{"Service":"frontend","State":"running","Health":"healthy"},
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ func Render(installation config.Installation) (map[string][]byte, error) {
|
|||||||
}, nil
|
}, 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.
|
// previous directory, so callers never observe a successfully returned mixed generation.
|
||||||
func Generate(installation config.Installation) error {
|
func Generate(installation config.Installation) error {
|
||||||
artifacts, err := Render(installation)
|
artifacts, err := Render(installation)
|
||||||
@@ -152,6 +153,17 @@ func Generate(installation config.Installation) error {
|
|||||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||||
return fmt.Errorf("create model projection parent: %w", err)
|
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-*")
|
candidate, err := os.MkdirTemp(parent, ".model-projections-candidate-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create model projection candidate: %w", err)
|
return fmt.Errorf("create model projection candidate: %w", err)
|
||||||
@@ -161,19 +173,12 @@ func Generate(installation config.Installation) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
info, statErr := os.Lstat(target)
|
|
||||||
if os.IsNotExist(statErr) {
|
if os.IsNotExist(statErr) {
|
||||||
if err := renameProjectionDirectory(candidate, target); err != nil {
|
if err := renameProjectionDirectory(candidate, target); err != nil {
|
||||||
return fmt.Errorf("publish model projection generation: %w", err)
|
return fmt.Errorf("publish model projection generation: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
previous, err := absentTemporaryPath(parent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reserve previous model projection generation: %w", err)
|
return fmt.Errorf("reserve previous model projection generation: %w", err)
|
||||||
@@ -191,6 +196,21 @@ func Generate(installation config.Installation) error {
|
|||||||
return nil
|
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 {
|
func writeProjectionCandidate(directory string, artifacts map[string][]byte) error {
|
||||||
if err := os.Chmod(directory, 0o755); err != nil {
|
if err := os.Chmod(directory, 0o755); err != nil {
|
||||||
return fmt.Errorf("protect model projection candidate: %w", err)
|
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) {
|
func TestGenerateRestoresWholePreviousGenerationWhenPublishFails(t *testing.T) {
|
||||||
installation := projectionFixture(t)
|
installation := projectionFixture(t)
|
||||||
if err := Generate(installation); err != nil {
|
if err := Generate(installation); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user