test: verify portable workspace registry end to end
This commit is contained in:
@@ -3,6 +3,32 @@
|
|||||||
> Starting-point snapshot for new sessions. Last updated: 2026-07-23 (session summary redesign live).
|
> 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.
|
> 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 summary redesign — LIVE 2026-07-23
|
||||||
|
|
||||||
- Session documents are projected at read time in outcome-first order: original question,
|
- Session documents are projected at read time in outcome-first order: original question,
|
||||||
|
|||||||
@@ -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
|
exercise is `./scripts/workspace-registry-smoke.sh`; both manuals are checked with
|
||||||
`./scripts/verify-workspace-install-docs.sh --profile local` or `--profile server`.
|
`./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`,
|
`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
|
`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
|
`THOTH_PUBLIC_EXPOSURE=true` for that profile; the backend rejects that public/local combination
|
||||||
|
|||||||
@@ -399,6 +399,16 @@ export function sessionRoutes(
|
|||||||
.filter((revision) => revision.state === "operational")
|
.filter((revision) => revision.state === "operational")
|
||||||
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
|
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
|
||||||
const list = lists.flat();
|
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
|
// 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
|
// 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
|
// gate), while a cold session keeps its explicit Resume affordance — so a mere click
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface SessionRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
|
workspace_id?: string | null;
|
||||||
|
workspace_revision?: string | null;
|
||||||
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionDocument {
|
export interface SessionDocument {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { lstatSync } from "node:fs";
|
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 { isAbsolute, join } from "node:path";
|
||||||
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
|
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
|
||||||
import {
|
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.
|
* 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.
|
* The browser never provides paths or generated artifacts; those are derived server-side.
|
||||||
|
|||||||
@@ -112,6 +112,45 @@ test("session listing permits all scope only to admins", async () => {
|
|||||||
expect(seen).toEqual([false, true]);
|
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 () => {
|
test("A, B, and admin requests preserve owner isolation through session route mutations", async () => {
|
||||||
const owners = new Map([["a", "alice"], ["b", "bob"]]);
|
const owners = new Map([["a", "alice"], ["b", "bob"]]);
|
||||||
const closed: Array<{ id: string; subject: string }> = [];
|
const closed: Array<{ id: string; subject: string }> = [];
|
||||||
|
|||||||
@@ -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 () => {
|
test("does not bypass an existing live advisory repository lock", async () => {
|
||||||
const remote = await fixture();
|
const remote = await fixture();
|
||||||
const root = join(remote.root, "registry");
|
const root = join(remote.root, "registry");
|
||||||
|
|||||||
@@ -291,6 +291,10 @@ def test_cli_documents_json(tmp_path, monkeypatch):
|
|||||||
m = create_session("q", _db(), tmp_path)
|
m = create_session("q", _db(), tmp_path)
|
||||||
from tht.cli import session_cmd
|
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:
|
class FakePaths:
|
||||||
sessions = tmp_path
|
sessions = tmp_path
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ def _list_sessions(sessions_root: Path) -> list[dict]:
|
|||||||
"name": m.name,
|
"name": m.name,
|
||||||
"group": m.group,
|
"group": m.group,
|
||||||
"archived": m.archived,
|
"archived": m.archived,
|
||||||
|
"workspace_id": m.workspace_id,
|
||||||
|
"workspace_revision": m.workspace_revision,
|
||||||
})
|
})
|
||||||
out.sort(key=lambda r: r["created_at"], reverse=True)
|
out.sort(key=lambda r: r["created_at"], reverse=True)
|
||||||
return out
|
return out
|
||||||
@@ -159,7 +161,8 @@ def list_cmd(
|
|||||||
"summary": s.manifest.summary, "created_at": s.manifest.created_at.isoformat(),
|
"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,
|
"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,
|
"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()
|
for s in session_repository(cfg).list()
|
||||||
]
|
]
|
||||||
if json_out:
|
if json_out:
|
||||||
|
|||||||
Reference in New Issue
Block a user