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
+26
View File
@@ -3,6 +3,32 @@
> Starting-point snapshot for new sessions. Last updated: 2026-07-23 (session summary redesign live).
> Point a fresh session here ("read PROJECT_STATE.md") before substantial work.
## Portable Git workspace registry — source integration (2026-08-04)
- **Source of truth and scope.** The canonical workspace repository is a generic Git remote,
configured only by `THT_WORKSPACE_GIT_REMOTE` and `THT_WORKSPACE_GIT_BRANCH` (there is no
committed PSD/Chirone remote or branch default). Both a local Docker installation and a server
persist its checkout, validated snapshots, state, and locks at `/data/workspace-registry`.
Connector endpoints, transport choices, and secret-file paths remain local bindings; secret
contents are never stored in Git, API responses, browser storage, diagnostics, or bundles.
- **Migration and session safety.** Schema-v2 descriptors are operational; legacy descriptors are
visible as `migration_required` until migrated by the documented operator workflow. New sessions
persist workspace ID and immutable Git revision. Resume resolves that historical snapshot, while
retention preserves every revision referenced by an open, closed, or failed unarchived manifest.
Reconciliation runs only with a complete local installation list or an administrator's complete
server list, never from a remote user's partial view.
- **Operator manuals.** Follow [the local manual](docs/install/local-workspace-registry.md) for
macOS/Windows/Linux Docker Desktop deployment and [the server manual](docs/install/server-workspace-registry.md)
for Gitea-compatible remotes, reverse proxy, migration, backup, and recovery. The release
workflow is Git review/push → installation pull → validate → local diagnostic test → browser-local
workspace/model/reasoning selection → revision-pinned session.
- **Verification recorded for this source branch.** `git diff --check` passed; backend Vitest
**365/365** and TypeScript passed; frontend Vitest **398/398** and TypeScript passed; the
harness document regression passed **10/10**. `./scripts/workspace-registry-smoke.sh`, both
installation-document verifier profiles, and a final unrestricted full harness run remain the
release commands to execute in the deployment environment; the local long-running harness run
was intentionally cancelled before it produced a final result.
## Session summary redesign — LIVE 2026-07-23
- Session documents are projected at read time in outcome-first order: original question,
+8
View File
@@ -69,6 +69,14 @@ for the Gitea, reverse-proxy, backup, migration, and recovery workflow. The isol
exercise is `./scripts/workspace-registry-smoke.sh`; both manuals are checked with
`./scripts/verify-workspace-install-docs.sh --profile local` or `--profile server`.
The operator workflow is: update and review canonical YAML in the shared Git remote, **Pull latest
registry** from each ThothII installation, run **Validate workspace** and **Test on this
installation**, then select the workspace locally before creating sessions. Each new session pins
the Git revision it used; a later pull or publish cannot change a Resume. Snapshot cleanup retains
every revision referenced by an open, closed, or failed unarchived session. It reconciles from the
single local installation list or from a server administrator's complete session list, never from
a remote user's partial list.
`docker-compose.dev.yml` is deliberately local: both published ports bind to `127.0.0.1`,
`THT_SESSION_STORAGE=local`, and `THT_HOME=/data/local-home`. Do not set
`THOTH_PUBLIC_EXPOSURE=true` for that profile; the backend rejects that public/local combination
+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");
+4
View File
@@ -291,6 +291,10 @@ def test_cli_documents_json(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
from tht.cli import session_cmd
# The CLI resolves its local principal through THT_HOME. Keep this test hermetic instead
# of changing the developer's real identity directory while exercising JSON output.
monkeypatch.setenv("THT_HOME", str(tmp_path / "thoth-home"))
class FakePaths:
sessions = tmp_path
+4 -1
View File
@@ -142,6 +142,8 @@ def _list_sessions(sessions_root: Path) -> list[dict]:
"name": m.name,
"group": m.group,
"archived": m.archived,
"workspace_id": m.workspace_id,
"workspace_revision": m.workspace_revision,
})
out.sort(key=lambda r: r["created_at"], reverse=True)
return out
@@ -159,7 +161,8 @@ def list_cmd(
"summary": s.manifest.summary, "created_at": s.manifest.created_at.isoformat(),
"updated_at": s.manifest.updated_at.isoformat() if s.manifest.updated_at else None,
"author": s.manifest.author, "name": s.manifest.name, "group": s.manifest.group,
"archived": s.manifest.archived}
"archived": s.manifest.archived, "workspace_id": s.manifest.workspace_id,
"workspace_revision": s.manifest.workspace_revision}
for s in session_repository(cfg).list()
]
if json_out: