diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6218edd0..7839dc1b 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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, diff --git a/README.md b/README.md index a515f541..a3d5b19c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index a55973b1..afe2b7b8 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -399,6 +399,16 @@ export function sessionRoutes( .filter((revision) => revision.state === "operational") .map((revision) => runner.sessionList(revision.snapshotPath) as Promise)); 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).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 diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 49909c5e..8a6644f3 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -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 { diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 91fb8a39..a9c6a8b0 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -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 { + 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. diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index a69c3afd..a1d0697a 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -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 }> = []; diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 459a6ce6..eafc48eb 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -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"); diff --git a/harness/tests/test_session_documents.py b/harness/tests/test_session_documents.py index 91ea0fb2..67ec41d5 100644 --- a/harness/tests/test_session_documents.py +++ b/harness/tests/test_session_documents.py @@ -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 diff --git a/harness/tht/cli/session_cmd.py b/harness/tht/cli/session_cmd.py index 216c3e89..10d8819f 100644 --- a/harness/tht/cli/session_cmd.py +++ b/harness/tht/cli/session_cmd.py @@ -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: