test: verify portable workspace registry end to end

This commit is contained in:
2026-08-04 08:42:33 +02:00
parent 3d5d26c20c
commit 8b046f9fb2
9 changed files with 143 additions and 2 deletions
+10
View File
@@ -399,6 +399,16 @@ export function sessionRoutes(
.filter((revision) => revision.state === "operational")
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
const list = lists.flat();
// 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.isAdmin) || principal.issuer === "local";
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
const retained = [...new Set(list
.filter((row) => row.status !== "finalized" && !row.archived && typeof row.workspace_revision === "string")
.map((row) => row.workspace_revision!))];
await reconcileSnapshotRetention.call(d.workspaceRegistry, retained);
}
// Annotate each row with whether a live Pi runtime is currently bound. The client
// opens an `active` session straight into its live view (reconnecting to its pending
// gate), while a cold session keeps its explicit Resume affordance — so a mere click
+3
View File
@@ -24,6 +24,9 @@ export interface SessionRow {
created_at: string;
updated_at: string | null;
author: string | null;
workspace_id?: string | null;
workspace_revision?: string | null;
archived?: boolean;
}
export interface SessionDocument {
+25 -1
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { lstatSync } from "node:fs";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
import {
@@ -164,6 +164,30 @@ export class WorkspaceRegistry {
}
}
/**
* Garbage-collect obsolete immutable snapshots without breaking cold Resume.
* Callers must supply revisions collected from an administrator-visible complete session list;
* a partial, per-user list could otherwise remove another user's resumable workspace pin.
*/
async reconcileSnapshotRetention(referencedCommits: readonly string[]): Promise<void> {
const retained = new Set(referencedCommits.map(safeCommit));
await this.repository.ensureLayout();
await this.lock.run(async () => {
retained.add((await this.activeState()).head);
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
for (const entry of entries) {
// Leave staging and unexpected entries untouched: this cleanup only owns finalized,
// commit-addressed snapshot directories.
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[0-9a-f]{40}$/.test(entry.name)) continue;
if (retained.has(entry.name)) continue;
const path = join(this.repository.snapshotsPath, entry.name);
const current = lstatSync(path);
if (!current.isDirectory() || current.isSymbolicLink()) continue;
await rm(path, { recursive: true, force: true });
}
});
}
/**
* Publish canonical YAML and derived public documentation as one optimistic Git revision.
* The browser never provides paths or generated artifacts; those are derived server-side.
+39
View File
@@ -112,6 +112,45 @@ test("session listing permits all scope only to admins", async () => {
expect(seen).toEqual([false, true]);
});
test("an administrator session listing retains revisions referenced by resumable manifests", async () => {
const retained = vi.fn(async () => {});
const retainedRevision = "a".repeat(40);
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
withPrincipal: () => ({ sessionList: async () => [
{ id: "open", status: "open", archived: false, workspace_revision: retainedRevision },
{ id: "finalized", status: "finalized", archived: false, workspace_revision: "b".repeat(40) },
{ id: "archived", status: "closed", archived: true, workspace_revision: "c".repeat(40) },
] }),
} as any,
workspaceRegistry: { reconcileSnapshotRetention: retained } as any,
});
const response = await app.inject({
method: "GET", url: "/sessions?scope=all",
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
});
expect(response.statusCode).toBe(200);
expect(retained).toHaveBeenCalledWith([retainedRevision]);
});
test("the single local installation listing reconciles its resumable workspace pins", async () => {
const retained = vi.fn(async () => {});
const retainedRevision = "d".repeat(40);
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionList: async () => [{ id: "open", status: "closed", archived: false, workspace_revision: retainedRevision }],
} as any,
workspaceRegistry: { reconcileSnapshotRetention: retained } as any,
});
const response = await app.inject({ method: "GET", url: "/sessions" });
expect(response.statusCode).toBe(200);
expect(retained).toHaveBeenCalledWith([retainedRevision]);
});
test("A, B, and admin requests preserve owner isolation through session route mutations", async () => {
const owners = new Map([["a", "alice"], ["b", "bob"]]);
const closed: Array<{ id: string; subject: string }> = [];
+24
View File
@@ -498,6 +498,30 @@ test("keeps the last valid snapshot when a pulled commit has invalid YAML", asyn
});
});
test("retains a historical snapshot while a resumable manifest still references its revision", async () => {
const remote = await fixture();
const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap();
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace(
"name: Policlinico San Donato", "name: Updated Policlinico San Donato",
));
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]);
await git(remote.source, ["commit", "-m", "Update workspace"]);
await git(remote.source, ["push", "origin", "main"]);
const currentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
await registry.pull();
await registry.reconcileSnapshotRetention([remote.initialCommit]);
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(true);
expect(existsSync(registry.snapshotPath(currentCommit, "psd-clinical"))).toBe(true);
await registry.reconcileSnapshotRetention([]);
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(false);
expect(existsSync(registry.snapshotPath(currentCommit, "psd-clinical"))).toBe(true);
});
test("does not bypass an existing live advisory repository lock", async () => {
const remote = await fixture();
const root = join(remote.root, "registry");