docs: plan Git workspace registry

This commit is contained in:
2026-08-03 21:05:31 +02:00
parent a6e6bc6800
commit 7ad0199f9b
@@ -0,0 +1,906 @@
# Git-backed Workspace Registry Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a Git-backed, portable Workspace Registry with a right-sidebar CRUD experience, deterministic installation bindings, revision-pinned sessions, and detailed tested installation manuals for local and server Docker deployments.
**Architecture:** A versioned canonical YAML repository is the shared source of truth. The Fastify backend owns schema validation, a persistent Git checkout, immutable runtime snapshots, binding resolution, diagnostics, and publish conflict handling; it renders compatible harness runtime YAML from the validated snapshot. Browser-local storage owns anonymous preferences and drafts, while sessions record the resolved workspace revision and model configuration.
**Tech Stack:** Node.js 22, TypeScript 5.6, Fastify 5, React 18, Vite, TanStack Query, Vitest, MSW, Python 3.12/Pydantic harness, Git CLI, Docker Compose.
## Global Constraints
- The remote Git repository is the sole shared source of truth; GitHub, Gitea, GitLab, and generic SSH/HTTPS remotes are supported through standard Git commands only.
- Never persist secret values in Git, API responses, logs, browser storage, generated documentation, or export bundles. Secret inputs use fixed `THT_WS_<WORKSPACE_NAMESPACE>_*_FILE` names.
- Workspace IDs match `^[a-z][a-z0-9-]{2,62}$`, are immutable, and generate a stable uppercase underscore namespace.
- Keep DWH, vector collection, embedding model, dimensions, and distance metric in shared workspace configuration. Keep active workspace, LLM choice, reasoning level, and drafts in browser-local storage until identity support exists.
- A vector collection and its embedding contract are atomic: dimensions and metric must agree; a user may not switch embeddings for the same collection.
- Support `postgres_direct`, `rest_api`, and `ssh_tunnel` for DWH; support direct, REST, and SSH-tunnel bindings for the vector store.
- A portable workspace may be valid but not activatable on an installation missing its local bindings. Only session start requires operational validation.
- Publish uses a short repository lock, optimistic base-commit/blob checks, field-level HTTP 409 conflicts, and no automatic YAML merge.
- The server and local Docker profiles use a persistent `/data/workspace-registry` volume; the container image and Git repository checkout are separate.
- Snapshot activation is atomic. New sessions record workspace ID and immutable Git revision; resume uses that revision.
- UI labels remain English. Generated workspace documentation remains in the workspace language.
- Preserve the existing `tht` contract: `-c` is a per-command option appended after the subcommand. `--json` stdout remains pristine JSON.
- Use test-first development for every behavioral change. Run `backend` Vitest and `tsc --noEmit`, `frontend` Vitest and `tsc -b`, and relevant harness `pytest` gates before each task commit.
---
## File Structure
| Path | Responsibility |
|---|---|
| `backend/src/workspaces/types.ts` | Shared registry DTOs, canonical workspace types, stable error codes, API request/response types. |
| `backend/src/workspaces/schema.ts` | YAML parse/serialize, schema/version validation, static semantic validation, canonical renderer, generated docs/contract. |
| `backend/src/workspaces/bindings.ts` | Deterministic variable naming and sanitized local binding resolution. |
| `backend/src/workspaces/git-repository.ts` | Safe Git CLI wrapper, checkout bootstrap, fetch/pull/commit/push, lock, status and blob inspection. |
| `backend/src/workspaces/registry.ts` | CRUD, optimistic publish, snapshots, legacy migration and export/import orchestration. |
| `backend/src/workspaces/diagnostics.ts` | Direct/REST/SSH connector diagnostics and semantic-index probes. |
| `backend/src/workspaces/runtime-renderer.ts` | Renders a validated canonical workspace + bindings to the harness-compatible runtime YAML. |
| `backend/src/routes/workspaces.ts` | Registry HTTP API and strict request validation. |
| `backend/src/config.ts`, `backend/src/app.ts` | Registry configuration and dependency injection. |
| `backend/src/routes/sessions.ts`, `backend/src/tht/tht-runner.ts` | Revision-pinned session start/resume and snapshot config resolution. |
| `harness/tht/session/models.py`, `harness/tht/session/store.py` | Persist and surface `workspace_id` and `workspace_revision`. |
| `frontend/src/api/workspaces.ts` | Typed registry client, multipart import/download helpers. |
| `frontend/src/workspaces/drafts.ts` | Browser-local draft/preference persistence and stale-draft detection. |
| `frontend/src/shell/WorkspaceManager.tsx` | Right-sidebar entry point and page shell. |
| `frontend/src/shell/WorkspaceEditor.tsx` | Sectioned CRUD form, closed choices, field errors, validation and diagnostics view. |
| `frontend/src/shell/WorkspacePublishDialog.tsx` | Diff, pull/publish, conflicts, delete confirmation, import/export controls. |
| `compose.yaml`, `docker-compose.dev.yml`, `docker/core.Dockerfile` | Persistent registry volume, Git/SSH runtime tools, mounted Git trust and secrets. |
| `docs/install/local-workspace-registry.md` | Detailed PC/Mac local Docker installation manual. |
| `docs/install/server-workspace-registry.md` | Detailed server installation manual. |
## Task 1: Establish backend dependencies and registry configuration
**Files:**
- Modify: `backend/package.json`
- Modify: `backend/package-lock.json`
- Modify: `backend/src/config.ts`
- Modify: `backend/test/config.test.ts`
- Create: `backend/src/workspaces/types.ts`
- Test: `backend/test/workspaces-config.test.ts`
**Interfaces:**
- Produces `WorkspaceRegistryConfig`:
```ts
export interface WorkspaceRegistryConfig {
root: string;
remoteUrl?: string;
branch: string;
gitAuthorName: string;
gitAuthorEmail: string;
installationId: string;
secretRoots: readonly string[];
maxImportBytes: number;
maxImportEntries: number;
}
```
- Produces shared error shape:
```ts
export type WorkspaceErrorCode =
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
| "workspace_stale" | "workspace_conflict" | "git_unavailable"
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
| "connector_unavailable" | "semantic_index_incompatible";
```
- Consumed by Tasks 2–11.
- [ ] **Step 1: Write failing configuration tests**
```ts
test("loads a safe Git workspace registry configuration", () => {
const cfg = loadConfig({
THT_WORKSPACE_REGISTRY_ROOT: "/data/workspace-registry",
THT_WORKSPACE_GIT_REMOTE: "ssh://git@gitea.example/thoth/workspaces.git",
THT_WORKSPACE_GIT_BRANCH: "main",
THT_WORKSPACE_INSTALLATION_ID: "server-psd-1",
THT_WORKSPACE_SECRET_ROOTS: "/run/secrets,/data/secrets",
});
expect(cfg.workspaceRegistry).toMatchObject({ root: "/data/workspace-registry", branch: "main" });
});
test("rejects a relative registry root and invalid import limits", () => {
expect(() => loadConfig({ THT_WORKSPACE_REGISTRY_ROOT: "registry" })).toThrow(/registry/i);
expect(() => loadConfig({ THT_WORKSPACE_REGISTRY_ROOT: "/data/registry", THT_WORKSPACE_MAX_IMPORT_BYTES: "0" })).toThrow(/import/i);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/workspaces-config.test.ts` in `backend/`
Expected: FAIL because `workspaceRegistry` does not exist on `AppConfig`.
- [ ] **Step 3: Add minimal dependencies and configuration**
Add runtime dependencies `yaml`, `zod`, `yauzl`, and `yazl`; add `@types/yauzl` as a development dependency. Extend `AppConfig` and `loadConfig` with absolute-root, branch, installation-ID, positive-limit, and absolute-secret-root validation. Default the root to `/data/workspace-registry`, branch to `main`, and import limits to 10 MiB/32 entries. Define the DTO and error-code module exactly as above.
- [ ] **Step 4: Run the focused test and typecheck**
Run: `npx vitest run test/workspaces-config.test.ts && npx tsc --noEmit -p .` in `backend/`
Expected: PASS with zero TypeScript errors.
- [ ] **Step 5: Commit**
```bash
git add backend/package.json backend/package-lock.json backend/src/config.ts backend/src/workspaces/types.ts backend/test/workspaces-config.test.ts
git commit -m "feat: configure Git workspace registry"
```
## Task 2: Implement canonical workspace schema, contracts, and generated documentation
**Files:**
- Create: `backend/src/workspaces/schema.ts`
- Create: `backend/src/workspaces/contracts.ts`
- Create: `backend/test/workspaces-schema.test.ts`
- Create: `backend/test/workspaces-contracts.test.ts`
**Interfaces:**
- Produces:
```ts
export interface CanonicalWorkspace {
workspace: { schema_version: 1; id: string; name: string; description?: string; language: "en" | "it" };
dwh: { engine: "postgres"; database: string; schema: string; supported_transports: DwhTransport[] };
semantic_index: {
vector_store: { engine: "pgvector"; collection: string; dimensions: number; distance: "cosine" | "l2" | "inner_product"; supported_transports: VectorTransport[] };
embedding: { provider: "ollama_compatible" | "openai_compatible"; model: string; dimensions: number };
};
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
}
export function parseWorkspaceYaml(source: string): CanonicalWorkspace;
export function serializeWorkspaceYaml(workspace: CanonicalWorkspace): string;
export function buildInstallationContract(workspace: CanonicalWorkspace): InstallationContract;
export function renderWorkspaceDocs(workspace: CanonicalWorkspace): { envExample: string; markdown: string };
```
- Consumed by Tasks 3–10.
- [ ] **Step 1: Write failing schema and contract tests**
```ts
test("rejects a workspace whose embedding dimensions differ from its collection", () => {
expect(() => parseWorkspaceYaml(validYaml.replace("dimensions: 768", "dimensions: 1536"))).toThrow(/dimensions/i);
});
test("rejects an LLM default outside its allowlist", () => {
expect(() => parseWorkspaceYaml(validYaml.replace("- zai/glm-5.2", "- openai/gpt-5"))).toThrow(/allowlist/i);
});
test("generates stable FILE-based secret requirements from an immutable ID", () => {
const contract = buildInstallationContract(validWorkspace);
expect(contract.variables.map((v) => v.name)).toContain("THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE");
expect(renderWorkspaceDocs(validWorkspace).envExample).not.toContain("secret-value");
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/workspaces-schema.test.ts test/workspaces-contracts.test.ts` in `backend/`
Expected: FAIL because schema and contract modules do not exist.
- [ ] **Step 3: Implement the canonical schema**
Use Zod strict objects to reject unknown keys. Enforce workspace ID syntax, positive dimensions/ports/timeouts, allowed enum values, matching vector/embedding dimensions, and default-in-allowlist. Use `yaml` with sorted canonical keys for serialization. Generate a contract with role/suffix metadata, not arbitrary variable names. Generate English UI-oriented documentation and workspace-language prose; render all secret requirements as `*_FILE` variables.
- [ ] **Step 4: Run focused tests and backend typecheck**
Run: `npx vitest run test/workspaces-schema.test.ts test/workspaces-contracts.test.ts && npx tsc --noEmit -p .` in `backend/`
Expected: PASS; repeated serialize/parse returns the same canonical object.
- [ ] **Step 5: Commit**
```bash
git add backend/src/workspaces/schema.ts backend/src/workspaces/contracts.ts backend/test/workspaces-schema.test.ts backend/test/workspaces-contracts.test.ts
git commit -m "feat: add canonical workspace schema"
```
## Task 3: Resolve local bindings and render harness-compatible runtime configuration
**Files:**
- Create: `backend/src/workspaces/bindings.ts`
- Create: `backend/src/workspaces/runtime-renderer.ts`
- Create: `backend/test/workspaces-bindings.test.ts`
- Create: `backend/test/workspace-runtime-renderer.test.ts`
- Modify: `backend/src/tht/tht-runner.ts`
**Interfaces:**
- Consumes `CanonicalWorkspace` and `InstallationContract` from Task 2.
- Produces:
```ts
export interface ResolvedBinding { transport: DwhTransport | VectorTransport; values: Record<string, string>; missing: string[]; }
export function resolveBinding(workspace: CanonicalWorkspace, role: "DWH" | "VECTOR" | "EMBEDDING", env: NodeJS.ProcessEnv, secretRoots: readonly string[]): ResolvedBinding;
export function renderRuntimeConfig(workspace: CanonicalWorkspace, bindings: RuntimeBindings, paths: RuntimePaths): string;
```
- Extends `ThtRunner.buildArgv(args, workspaceConfigPath?)` so it accepts an absolute immutable snapshot file and still appends `-c <path>` after the `tht` subcommand.
- [ ] **Step 1: Write failing binding and renderer tests**
```ts
test("marks a portable workspace non-activatable when its local REST key file is absent", () => {
const result = resolveBinding(workspace, "DWH", { THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api" }, ["/run/secrets"]);
expect(result.missing).toContain("THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE");
});
test("renders a direct PostgreSQL binding to the legacy harness shape", () => {
const yaml = renderRuntimeConfig(workspace, directBindings, runtimePaths);
expect(yaml).toContain("type: postgres_direct");
expect(yaml).toContain("schema: datawarehouse");
});
test("passes an absolute snapshot config after the tht subcommand", () => {
expect(runner.buildArgv(["session", "new"], "/data/workspace-registry/snapshots/a/psd-clinical.yaml")).toEqual([
"session", "new", "-c", "/data/workspace-registry/snapshots/a/psd-clinical.yaml",
]);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/workspaces-bindings.test.ts test/workspace-runtime-renderer.test.ts` in `backend/`
Expected: FAIL because binding resolution and runtime rendering do not exist.
- [ ] **Step 3: Implement bindings and renderer**
Normalize ID namespaces by uppercasing and replacing `-` with `_`. Require `*_FILE` paths to be absolute, regular/readable, and within configured secret roots; return names only in diagnostics. Render legacy `database`, `rest`, `vector_db`, `embeddings`, and `paths` fields required by the current harness from canonical schema plus local binding values. Implement direct and REST first; implement SSH as a temporary local port created by Task 5 diagnostics. Do not alter existing `-c` ordering.
- [ ] **Step 4: Run focused tests, current ThtRunner tests, and typecheck**
Run: `npx vitest run test/workspaces-bindings.test.ts test/workspace-runtime-renderer.test.ts test/tht-runner.test.ts && npx tsc --noEmit -p .` in `backend/`
Expected: PASS with no secret value present in assertions or output.
- [ ] **Step 5: Commit**
```bash
git add backend/src/workspaces/bindings.ts backend/src/workspaces/runtime-renderer.ts backend/src/tht/tht-runner.ts backend/test/workspaces-bindings.test.ts backend/test/workspace-runtime-renderer.test.ts
git commit -m "feat: resolve workspace bindings into runtime configs"
```
## Task 4: Implement a safe persistent Git repository and immutable snapshots
**Files:**
- Create: `backend/src/workspaces/git-repository.ts`
- Create: `backend/src/workspaces/registry.ts`
- Create: `backend/test/workspaces-git-repository.test.ts`
- Create: `backend/test/workspace-registry.test.ts`
- Modify: `backend/src/app.ts`
**Interfaces:**
- Produces:
```ts
export interface GitStatus { branch: string; head?: string; ahead: number; behind: number; degraded: boolean; lastError?: WorkspaceErrorCode; }
export interface WorkspaceRevision { id: string; commit: string; blob: string; snapshotPath: string; }
export class WorkspaceRegistry {
bootstrap(): Promise<GitStatus>;
list(): Promise<WorkspaceRevision[]>;
read(id: string): Promise<{ workspace: CanonicalWorkspace; revision: WorkspaceRevision }>;
pull(): Promise<GitStatus>;
publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision>;
}
```
- `buildApp` receives an injected registry in tests and creates the configured registry in production.
- [ ] **Step 1: Write failing Git lifecycle tests using a temporary bare remote**
```ts
test("bootstraps a checkout and activates a validated immutable snapshot", async () => {
const registry = await registryFor(tempBareRemote);
const status = await registry.bootstrap();
expect(status.head).toMatch(/[0-9a-f]{40}/);
expect(await exists(registry.snapshotPath(status.head!, "psd-clinical"))).toBe(true);
});
test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => {
await pushInvalidWorkspace(tempBareRemote);
await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(await registry.read("psd-clinical")).toMatchObject({ revision: { commit: initialCommit } });
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/workspaces-git-repository.test.ts test/workspace-registry.test.ts` in `backend/`
Expected: FAIL because `GitWorkspaceRepository` and `WorkspaceRegistry` do not exist.
- [ ] **Step 3: Implement Git operations and snapshots**
Use `spawn`/`execFile` with fixed argument arrays and `cwd` pinned under the registry root. Create `repo`, `snapshots`, `state`, and `locks` at bootstrap. Clone only when checkout is absent; otherwise fetch and fast-forward. Validate every workspace and generated artifact before atomically writing `state/active.json` and snapshot directories. Use a promise-based in-process lock plus an advisory lock file for publish/pull. Classify Git stderr into stable sanitized error codes. Keep the last active state when clone/fetch/pull fails.
- [ ] **Step 4: Run focused tests and full backend test suite**
Run: `npx vitest run test/workspaces-git-repository.test.ts test/workspace-registry.test.ts && npx vitest run && npx tsc --noEmit -p .` in `backend/`
Expected: PASS; tests prove no shell interpolation and active snapshot fallback.
- [ ] **Step 5: Commit**
```bash
git add backend/src/workspaces/git-repository.ts backend/src/workspaces/registry.ts backend/src/app.ts backend/test/workspaces-git-repository.test.ts backend/test/workspace-registry.test.ts
git commit -m "feat: manage workspace Git checkout and snapshots"
```
## Task 5: Add diagnostics for direct, REST, SSH, vector, and embedding bindings
**Files:**
- Create: `backend/src/workspaces/diagnostics.ts`
- Create: `backend/test/workspaces-diagnostics.test.ts`
- Modify: `docker/core.Dockerfile`
- Modify: `backend/src/config.ts`
**Interfaces:**
- Produces:
```ts
export interface Diagnostic { level: "error" | "warning" | "info"; code: WorkspaceErrorCode | "binding_ok"; field?: string; message: string; }
export interface WorkspaceDiagnostics { activatable: boolean; diagnostics: Diagnostic[]; }
export async function diagnoseWorkspace(workspace: CanonicalWorkspace, bindings: RuntimeBindings, options: { writeProbe: boolean }): Promise<WorkspaceDiagnostics>;
```
- Consumed by the registry API and frontend.
- [ ] **Step 1: Write failing adapter tests**
```ts
test("reports the missing vector collection dimensions as semantic-index incompatibility", async () => {
const result = await diagnoseWorkspace(workspace, fakeBindings({ vectorDimensions: 1536 }), { writeProbe: false });
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "semantic_index_incompatible" }));
});
test("refuses an SSH tunnel when known-hosts is missing", async () => {
const result = await diagnoseWorkspace(workspace, sshBindingsWithoutKnownHosts, { writeProbe: false });
expect(result.activatable).toBe(false);
expect(result.diagnostics[0].field).toContain("SSH_KNOWN_HOSTS_FILE");
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/workspaces-diagnostics.test.ts` in `backend/`
Expected: FAIL because diagnostics adapters do not exist.
- [ ] **Step 3: Implement sanitized diagnostic adapters**
Add `git` and `openssh-client` to the Debian runtime image. Implement injectable adapter interfaces so tests use fakes. Direct and REST diagnostics must test resolution, TLS, authentication, and logical resource metadata without returning response bodies. SSH diagnostics must require explicit known-hosts verification and create a temporary loopback tunnel only for the probe. Vector diagnostics must compare collection dimensions/metric; embedding diagnostics must check model availability and probe vector dimensions. Add an explicit write-probe path that writes and removes only a random diagnostic record; ordinary validation remains read-only.
- [ ] **Step 4: Run tests, Docker build, and typecheck**
Run: `npx vitest run test/workspaces-diagnostics.test.ts && npx tsc --noEmit -p .` in `backend/`
Run: `docker build -f docker/core.Dockerfile .` from repository root
Expected: PASS; the image contains `git` and `ssh` while still running as non-root.
- [ ] **Step 5: Commit**
```bash
git add backend/src/workspaces/diagnostics.ts backend/test/workspaces-diagnostics.test.ts backend/src/config.ts docker/core.Dockerfile
git commit -m "feat: diagnose workspace connector bindings"
```
## Task 6: Expose validated registry CRUD, pull/publish, conflict, and bundle APIs
**Files:**
- Create: `backend/src/routes/workspaces.ts`
- Create: `backend/test/routes-workspaces.test.ts`
- Modify: `backend/src/routes/meta.ts`
- Modify: `backend/src/app.ts`
- Modify: `backend/package.json` only if a Fastify multipart plugin is required
- Modify: `backend/package-lock.json` only if dependencies change
**Interfaces:**
- Replaces metadata-only workspace listing with:
```ts
GET /workspace-registry/status
POST /workspace-registry/pull
GET /workspaces
GET /workspaces/:id
POST /workspaces/validate
POST /workspaces/:id/test
POST /workspaces/publish
GET /workspaces/:id/export
POST /workspaces/import
```
- `POST /workspaces/publish` accepts:
```ts
type PublishWorkspaceRequest =
| { action: "create"; workspace: CanonicalWorkspace; baseCommit: string }
| { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string }
| { action: "delete"; id: string; baseCommit: string; baseBlob: string };
```
- [ ] **Step 1: Write failing route tests**
```ts
test("returns a 409 field conflict instead of overwriting a changed workspace", async () => {
const res = await app.inject({ method: "POST", url: "/workspaces/publish", payload: staleUpdate });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ code: "workspace_conflict", fields: ["semantic_index.embedding.model"] });
});
test("rejects a zip-slip import without writing a checkout file", async () => {
const res = await importBundle(app, zipWith("../escape.yaml", "bad"));
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ code: "workspace_invalid" });
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/routes-workspaces.test.ts` in `backend/`
Expected: FAIL because registry routes do not exist.
- [ ] **Step 3: Implement routes and secure archive handling**
Use schema-validated JSON payloads; never accept raw target paths. Register multipart parsing with a 10 MiB upload limit. Use `yauzl` lazy entry enumeration and reject absolute names, `..`, backslashes, symlinks, extra entries, and checksum/schema failures before creating a browser draft response. Use `yazl` to create `manifest.json`, `workspace.yaml`, `contract.env.example`, and `README.md`; set attachment headers. Publish generated docs with the YAML in one commit. Preserve `/models` and existing workspace selector compatibility by returning summary records from `GET /workspaces`.
- [ ] **Step 4: Run route tests, full backend suite, and typecheck**
Run: `npx vitest run test/routes-workspaces.test.ts && npx vitest run && npx tsc --noEmit -p .` in `backend/`
Expected: PASS; error bodies are sanitized and status/pull endpoints do not expose Git credentials.
- [ ] **Step 5: Commit**
```bash
git add backend/src/routes/workspaces.ts backend/src/routes/meta.ts backend/src/app.ts backend/test/routes-workspaces.test.ts backend/package.json backend/package-lock.json
git commit -m "feat: expose workspace registry API"
```
## Task 7: Pin sessions to canonical workspace snapshots and move preferences to the browser
**Files:**
- Modify: `backend/src/routes/sessions.ts`
- Modify: `backend/src/tht/tht-runner.ts`
- Modify: `backend/src/settings/settings-store.ts`
- Modify: `backend/src/routes/settings.ts`
- Modify: `backend/test/routes-sessions.test.ts`
- Modify: `backend/test/routes-settings.test.ts`
- Modify: `harness/tht/session/models.py`
- Modify: `harness/tht/session/store.py`
- Modify: `harness/tests/test_session_documents.py`
**Interfaces:**
- New session request becomes:
```ts
interface CreateSessionRequest {
question: string;
name?: string;
workspaceId: string;
provider?: string;
model?: string;
thinking?: string;
}
```
- Session manifest adds optional legacy-compatible fields:
```python
workspace_id: str | None = None
workspace_revision: str | None = None
```
- [ ] **Step 1: Write failing session and manifest tests**
```ts
test("creates a session from the active immutable workspace revision", async () => {
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" } });
expect(runner.sessionNew).toHaveBeenCalledWith(expect.objectContaining({ workspaceConfigPath: "/data/workspace-registry/snapshots/abc/psd-clinical.yaml" }));
});
```
```python
def test_manifest_persists_workspace_revision():
manifest = new_session_manifest("q", db, workspace_id="psd-clinical", workspace_revision="a" * 40)
assert manifest.workspace_id == "psd-clinical"
assert manifest.workspace_revision == "a" * 40
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/routes-sessions.test.ts test/routes-settings.test.ts` in `backend/`
Run: `.venv/bin/pytest tests/test_session_documents.py -q` in `harness/`
Expected: FAIL because session requests and manifests do not carry workspace revisions.
- [ ] **Step 3: Implement revision pinning and browser preference contract**
Resolve `workspaceId` from the active registry snapshot, perform operational validation before creating a session, validate the selected LLM against `llm_policy`, then pass the absolute snapshot config to `ThtRunner`. Persist ID/revision with provider/model/thinking. Resume uses the manifest revision and fails with a sanitized compatibility error only if the retained snapshot is unavailable. Remove server-global workspace/model/thinking persistence from the settings flow; retain only installation-wide defaults required for backward compatibility. Keep legacy session behavior when manifest revision is absent and emit a warning in its response.
- [ ] **Step 4: Run backend and harness verification**
Run: `npx vitest run test/routes-sessions.test.ts test/routes-settings.test.ts && npx tsc --noEmit -p .` in `backend/`
Run: `.venv/bin/pytest tests/test_session_documents.py tests/test_session_mutations.py -q` in `harness/`
Expected: PASS; manifest serialization remains backward compatible.
- [ ] **Step 5: Commit**
```bash
git add backend/src/routes/sessions.ts backend/src/tht/tht-runner.ts backend/src/settings/settings-store.ts backend/src/routes/settings.ts backend/test/routes-sessions.test.ts backend/test/routes-settings.test.ts harness/tht/session/models.py harness/tht/session/store.py harness/tests/test_session_documents.py
git commit -m "feat: pin sessions to workspace revisions"
```
## Task 8: Implement browser-local workspace preferences, drafts, and typed registry API client
**Files:**
- Modify: `frontend/src/api/workspaces.ts`
- Modify: `frontend/src/api/client.ts`
- Create: `frontend/src/workspaces/drafts.ts`
- Create: `frontend/src/api/workspaces.test.ts`
- Create: `frontend/src/workspaces/drafts.test.ts`
- Modify: `frontend/src/api/sessions.ts`
- Modify: `frontend/src/shell/SteerInput.tsx`
- Modify: `frontend/src/shell/SteerInput.test.tsx`
**Interfaces:**
- Produces:
```ts
export interface WorkspacePreference { workspaceId?: string; provider?: string; model?: string; thinking?: string; }
export interface WorkspaceDraft { workspaceId: string; baseCommit: string; baseBlob?: string; workspace: CanonicalWorkspace; updatedAt: string; }
export const workspacePreferences = { load(): WorkspacePreference; save(value: WorkspacePreference): void; };
export const workspaceDrafts = { load(id: string): WorkspaceDraft | undefined; save(draft: WorkspaceDraft): void; discard(id: string): void; };
```
- [ ] **Step 1: Write failing API and browser-storage tests**
```ts
test("keeps an anonymous user's model selection in browser storage", () => {
workspacePreferences.save({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium" });
expect(workspacePreferences.load()).toMatchObject({ model: "glm-5.2" });
});
test("uploads a workspace bundle without JSON content type", async () => {
await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip"));
expect(request.headers.get("content-type")).toMatch(/multipart\/form-data/);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/api/workspaces.test.ts src/workspaces/drafts.test.ts src/shell/SteerInput.test.tsx` in `frontend/`
Expected: FAIL because preference/draft modules and multipart client support do not exist.
- [ ] **Step 3: Implement local preference and draft storage**
Namespace LocalStorage keys by `thothii.workspace-registry.v1`. Store only canonical drafts, revision metadata, and non-secret display preferences. Add multipart-aware `apiFetch` behavior that does not override `FormData` content type. Update the composer footer to load workspace policy and model choices from the registry client, store its choice locally, and pass the explicit selection to session creation. Do not put secrets or diagnostics response bodies in LocalStorage.
- [ ] **Step 4: Run focused tests, all frontend tests, and typecheck**
Run: `npx vitest run src/api/workspaces.test.ts src/workspaces/drafts.test.ts src/shell/SteerInput.test.tsx && npx vitest run && npx tsc -b` in `frontend/`
Expected: PASS; existing session creation tests update their payload expectation to include `workspaceId`.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/api/workspaces.ts frontend/src/api/client.ts frontend/src/workspaces/drafts.ts frontend/src/api/workspaces.test.ts frontend/src/workspaces/drafts.test.ts frontend/src/api/sessions.ts frontend/src/shell/SteerInput.tsx frontend/src/shell/SteerInput.test.tsx
git commit -m "feat: store workspace preferences and drafts locally"
```
## Task 9: Build the right-sidebar Workspace Management CRUD page
**Files:**
- Create: `frontend/src/shell/WorkspaceManager.tsx`
- Create: `frontend/src/shell/WorkspaceEditor.tsx`
- Create: `frontend/src/shell/WorkspaceManager.test.tsx`
- Create: `frontend/src/shell/WorkspaceEditor.test.tsx`
- Modify: `frontend/src/shell/AppShell.tsx`
- Modify: `frontend/src/shell/ModelActivityPanel.tsx`
**Interfaces:**
- `WorkspaceManager` receives `open: boolean`, `onClose(): void`, and uses registry React Query keys `workspace-registry-status`, `workspaces`, and `workspace:<id>`.
- `WorkspaceEditor` receives `{ draft?: WorkspaceDraft; onSaveDraft(draft): void; onPublish(request): Promise<void> }`.
- [ ] **Step 1: Write failing interaction tests**
```tsx
test("opens Workspace management from the right-side activity panel", async () => {
render(<AppShell />);
await user.click(screen.getByRole("button", { name: "Workspace management" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
});
test("uses closed choices for transport and rejects an invalid free-form port before save", async () => {
render(<WorkspaceEditor draft={draft} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
expect(screen.getByRole("combobox", { name: "DWH transport" })).toHaveTextContent("postgres_direct");
await user.clear(screen.getByLabelText("DWH port"));
await user.type(screen.getByLabelText("DWH port"), "70000");
await user.click(screen.getByRole("button", { name: "Save draft" }));
expect(screen.getByText("Port must be between 1 and 65535")).toBeVisible();
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/shell/WorkspaceManager.test.tsx src/shell/WorkspaceEditor.test.tsx` in `frontend/`
Expected: FAIL because the manager/editor components do not exist.
- [ ] **Step 3: Implement the page and editor**
Add a right-panel header button labelled `Workspace management` to `ModelActivityPanel`; `AppShell` opens the dedicated manager without interrupting active session streams. Implement a list/detail page with General, DWH, Semantic index, LLM policy, Installation requirements, and Git status/history sections. Use native/select component controls for every enum; use typed numeric/URL/text fields for free values. Show client validation immediately, server validation after `validate`, and diagnostics only as sanitized codes/messages. `New` generates an ID proposal, `Duplicate` requires a new immutable ID, `Delete` creates a deletion draft, and `Save draft` only writes browser storage.
- [ ] **Step 4: Run focused tests and full frontend gates**
Run: `npx vitest run src/shell/WorkspaceManager.test.tsx src/shell/WorkspaceEditor.test.tsx && npx vitest run && npx tsc -b` in `frontend/`
Expected: PASS; the active session and right-panel resize controls retain existing behavior.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/shell/WorkspaceManager.tsx frontend/src/shell/WorkspaceEditor.tsx frontend/src/shell/WorkspaceManager.test.tsx frontend/src/shell/WorkspaceEditor.test.tsx frontend/src/shell/AppShell.tsx frontend/src/shell/ModelActivityPanel.tsx
git commit -m "feat: add workspace management editor"
```
## Task 10: Add publish, pull, conflict, import/export, and diagnostics user flows
**Files:**
- Create: `frontend/src/shell/WorkspacePublishDialog.tsx`
- Create: `frontend/src/shell/WorkspacePublishDialog.test.tsx`
- Modify: `frontend/src/shell/WorkspaceManager.tsx`
- Modify: `frontend/src/shell/WorkspaceEditor.tsx`
**Interfaces:**
- `WorkspacePublishDialog` consumes:
```ts
interface WorkspaceConflict {
code: "workspace_conflict";
base: CanonicalWorkspace;
local: CanonicalWorkspace;
remote: CanonicalWorkspace;
fields: string[];
}
```
- Produces a publish request only after explicit confirmation.
- [ ] **Step 1: Write failing publish-flow tests**
```tsx
test("shows a field-level conflict and does not overwrite the remote workspace", async () => {
server.use(http.post("*/workspaces/publish", () => HttpResponse.json(conflict, { status: 409 })));
render(<WorkspacePublishDialog request={request} onPublished={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Publish" }));
expect(await screen.findByText("semantic_index.embedding.model")).toBeVisible();
expect(screen.queryByText("Published")).not.toBeInTheDocument();
});
test("imports a bundle as a local draft and never publishes it automatically", async () => {
render(<WorkspaceManager open onClose={vi.fn()} />);
await user.upload(screen.getByLabelText("Import workspace bundle"), bundleFile);
expect(await screen.findByText("Imported draft" )).toBeVisible();
expect(publishSpy).not.toHaveBeenCalled();
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/shell/WorkspacePublishDialog.test.tsx` in `frontend/`
Expected: FAIL because the publish dialog and flows do not exist.
- [ ] **Step 3: Implement collaboration and fallback controls**
Display status (active commit, ahead/behind, degraded) and provide Pull before Publish. Render canonical field-level diffs and HTTP 409 base/local/remote comparisons; allow the user to choose remote or local value per conflicting field, then save a revised browser draft. Download export bundles through a Blob URL and revoke it. Upload imports as `FormData`, save the returned draft locally, and require normal validation/publish. Offer `Test on this installation` and render activatable/degraded diagnostics without secret details.
- [ ] **Step 4: Run focused tests and full frontend gates**
Run: `npx vitest run src/shell/WorkspacePublishDialog.test.tsx && npx vitest run && npx tsc -b` in `frontend/`
Expected: PASS; no automatic publish occurs on import or stale-draft detection.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/shell/WorkspacePublishDialog.tsx frontend/src/shell/WorkspacePublishDialog.test.tsx frontend/src/shell/WorkspaceManager.tsx frontend/src/shell/WorkspaceEditor.tsx
git commit -m "feat: publish and synchronize workspace drafts"
```
## Task 11: Migrate existing workspace descriptors and deploy persistent registry storage
**Files:**
- Create: `backend/src/workspaces/migrate-legacy.ts`
- Create: `backend/test/workspaces-migrate-legacy.test.ts`
- Modify: `compose.yaml`
- Modify: `docker-compose.dev.yml`
- Modify: `.env.example`
- Modify: `deploy/thothii.env.example`
- Create: `deploy/workspace-registry.env.example`
- Create: `scripts/workspace-registry-smoke.sh`
**Interfaces:**
- Produces CLI entry point:
```text
node dist/workspaces/migrate-legacy.js --input <legacy-workspace.yaml> --output <repository-root>
```
- The smoke script accepts `WORKSPACE_GIT_REMOTE`, initializes an isolated Compose project, proves persistence, Git pull, and last-valid-snapshot fallback.
- [ ] **Step 1: Write failing migration and Compose-contract tests**
```ts
test("migrates the current local PSD descriptor without copying secret values", () => {
const result = migrateLegacyWorkspace(readFixture("local.yaml"));
expect(result.workspace.workspace.id).toBe("local");
expect(JSON.stringify(result)).not.toMatch(/password:|api_key:/i);
});
```
```sh
./scripts/workspace-registry-smoke.sh
# Expected before implementation: fail because no workspace registry volume/configuration exists.
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/workspaces-migrate-legacy.test.ts` in `backend/`
Run: `./scripts/workspace-registry-smoke.sh` from repository root
Expected: FAIL because the migration CLI and registry deployment contract do not exist.
- [ ] **Step 3: Implement migration and container configuration**
Translate current `harness/workspaces/*.yaml` and `deploy/workspaces/*.yaml` into canonical documents while replacing runtime secrets with binding requirements. Add `THT_WORKSPACE_REGISTRY_ROOT=/data/workspace-registry`, remote/branch/installation-ID variables, and an explicit persistent mount to server and local Compose files. Mount Git credentials, CA, SSH key, and known-hosts files read-only from installation secrets. Do not mount the canonical repository into the image. Ensure Compose examples distinguish server external networks from local loopback deployment.
- [ ] **Step 4: Run migration, smoke, Docker build, and test gates**
Run: `npx vitest run test/workspaces-migrate-legacy.test.ts && npx tsc --noEmit -p .` in `backend/`
Run: `./scripts/workspace-registry-smoke.sh` from repository root
Run: `docker compose config && docker compose -f docker-compose.dev.yml config` from repository root
Expected: PASS; a replaced core container retains its checkout and last valid snapshot.
- [ ] **Step 5: Commit**
```bash
git add backend/src/workspaces/migrate-legacy.ts backend/test/workspaces-migrate-legacy.test.ts compose.yaml docker-compose.dev.yml .env.example deploy/thothii.env.example deploy/workspace-registry.env.example scripts/workspace-registry-smoke.sh
git commit -m "feat: deploy portable workspace registry"
```
## Task 12: Produce detailed local and server installation manuals and verify them
**Files:**
- Create: `docs/install/local-workspace-registry.md`
- Create: `docs/install/server-workspace-registry.md`
- Create: `docs/install/examples/local-compose.workspace-registry.yaml`
- Create: `docs/install/examples/server-compose.workspace-registry.yaml`
- Create: `scripts/verify-workspace-install-docs.sh`
- Modify: `README.md`
- Test: `scripts/workspace-registry-smoke.sh`
**Interfaces:**
- The manual verifier accepts:
```text
./scripts/verify-workspace-install-docs.sh --profile local
./scripts/verify-workspace-install-docs.sh --profile server
```
- It extracts only marked fenced commands from the corresponding manual, validates Compose, and runs bootstrap/recovery smoke fixtures without contacting production services.
- [ ] **Step 1: Write failing documentation-verification tests**
```sh
./scripts/verify-workspace-install-docs.sh --profile local
# Expected before implementation: fail because the local manual and runnable example do not exist.
./scripts/verify-workspace-install-docs.sh --profile server
# Expected before implementation: fail because the server manual and runnable example do not exist.
```
- [ ] **Step 2: Run commands to verify they fail**
Run: `./scripts/verify-workspace-install-docs.sh --profile local` from repository root
Run: `./scripts/verify-workspace-install-docs.sh --profile server` from repository root
Expected: both FAIL with a missing-manual error.
- [ ] **Step 3: Write complete manuals and verifier**
Write the local PC/Mac manual with Docker Desktop/local-engine prerequisites, clone or remote bootstrap, Git SSH/HTTPS setup, persistent volume, local binding file, secret file permissions, direct/REST/SSH examples, startup, first pull, diagnostics, publish, update, backup, remote-outage recovery, and rollback. Write the server manual with service account ownership, persistent bind/volume layout, Gitea/remote setup, outbound firewall requirements, CA/known-hosts/secret mounts, same-origin reverse proxy, startup, health/status, pull/publish, upgrade, backup, degraded recovery, and snapshot rollback. In both manuals explicitly separate Git-shared values from installation-local variables and secret files, document all stable error codes, and include runnable marked Compose examples. Implement a shell verifier that checks required headings/commands, runs Compose config, runs the isolated smoke script, and rejects examples containing secret literals.
- [ ] **Step 4: Run manual verification and all final gates**
Run: `./scripts/verify-workspace-install-docs.sh --profile local && ./scripts/verify-workspace-install-docs.sh --profile server` from repository root
Run: `npx vitest run && npx tsc --noEmit -p .` in `backend/`
Run: `npx vitest run && npx tsc -b` in `frontend/`
Run: `.venv/bin/pytest -q` in `harness/`
Expected: PASS; manuals are complete, runnable against fixtures, and contain no credential values.
- [ ] **Step 5: Commit**
```bash
git add docs/install/local-workspace-registry.md docs/install/server-workspace-registry.md docs/install/examples/local-compose.workspace-registry.yaml docs/install/examples/server-compose.workspace-registry.yaml scripts/verify-workspace-install-docs.sh README.md
git commit -m "docs: add workspace registry installation manuals"
```
## Task 13: Final migration rehearsal, end-to-end regression, and release verification
**Files:**
- Modify: `PROJECT_STATE.md`
- Modify: `README.md`
- Test: `backend/test/routes-workspaces.test.ts`
- Test: `backend/test/routes-sessions.test.ts`
- Test: `frontend/src/shell/WorkspaceManager.test.tsx`
- Test: `harness/tests/test_session_documents.py`
**Interfaces:**
- Verifies the public contract produced by Tasks 1–12; no new production interface is introduced.
- [ ] **Step 1: Write failing cross-layer regression tests**
```ts
test("a session created before a workspace pull resumes from its original snapshot", async () => {
const created = await createSessionAtRevision("a".repeat(40));
await publishWorkspaceRevision("b".repeat(40));
await resumeSession(created.id);
expect(runner.reopenSession).toHaveBeenCalledWith(created.id, expect.stringContaining(`/snapshots/${"a".repeat(40)}/`));
});
```
```tsx
test("a local installation can pull a Git workspace, configure bindings, validate it, and create a revision-pinned session", async () => {
let created: unknown;
server.use(
http.post("*/workspace-registry/pull", () => HttpResponse.json({ head: "a".repeat(40), degraded: false })),
http.post("*/workspaces/psd-clinical/test", () => HttpResponse.json({ activatable: true, diagnostics: [] })),
http.post("*/sessions", async ({ request }) => {
created = await request.json();
return HttpResponse.json({ id: "s1" });
}),
);
render(<WorkspaceManager open onClose={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "Pull" }));
await user.click(screen.getByRole("button", { name: "Test on this installation" }));
expect(await screen.findByText("This installation can activate this workspace")).toBeVisible();
await createSession({ question: "count patients", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" });
expect(created).toMatchObject({ workspaceId: "psd-clinical", model: "glm-5.2" });
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run test/routes-sessions.test.ts test/routes-workspaces.test.ts` in `backend/`
Run: `npx vitest run src/shell/WorkspaceManager.test.tsx` in `frontend/`
Expected: FAIL until snapshot retention and the full UI/API flow are connected.
- [ ] **Step 3: Implement retention, regression fixes, and operator state**
Add snapshot-retention logic that preserves revisions referenced by resumable manifests. Repair only issues revealed by the cross-layer tests. Record active remote/branch, migration state, manual locations, and tested commands in `PROJECT_STATE.md`; add README links to the two manuals and the workspace registry operator workflow.
- [ ] **Step 4: Run complete verification**
Run: `git diff --check` from repository root
Run: `npx vitest run && npx tsc --noEmit -p .` in `backend/`
Run: `npx vitest run && npx tsc -b` in `frontend/`
Run: `.venv/bin/pytest -q` in `harness/`
Run: `./scripts/workspace-registry-smoke.sh && ./scripts/verify-workspace-install-docs.sh --profile local && ./scripts/verify-workspace-install-docs.sh --profile server` from repository root
Expected: every command exits 0; server/local deployment examples, Git fallback, workspace conflict handling, semantic-index validation, and session revision pinning are covered.
- [ ] **Step 5: Commit**
```bash
git add PROJECT_STATE.md README.md backend/test/routes-sessions.test.ts backend/test/routes-workspaces.test.ts frontend/src/shell/WorkspaceManager.test.tsx harness/tests/test_session_documents.py
git commit -m "test: verify portable workspace registry end to end"
```
## Plan Self-Review
### Spec coverage
- Git source of truth, generic remote support, server/local persistent checkout, snapshots, offline bundles, conflicts, and Git failure behavior are covered by Tasks 1, 4, 6, 10, and 11.
- Canonical workspace YAML, deterministic secret-variable contracts, generic DWH/vector/embedding/LLM model, and semantic-index invariants are covered by Tasks 2 and 3.
- Direct, REST, and SSH connectivity checks are covered by Task 5.
- Browser-local preferences/drafts and no-auth behavior are covered by Task 8.
- The right-sidebar CRUD, closed lists, free fields, three validation levels, diagnostics, and delete behavior are covered by Tasks 6, 9, and 10.
- Session revision pinning and legacy compatibility are covered by Task 7 and verified by Task 13.
- Docker server/local wiring, migration, detailed manuals, runnable examples, and documentation verification are covered by Tasks 11 and 12.
### Placeholder scan
The scan found no placeholder markers or vague test instructions.
### Type consistency
`CanonicalWorkspace`, `InstallationContract`, `WorkspaceRevision`, `WorkspaceDraft`, `WorkspaceErrorCode`, and `PublishWorkspaceRequest` are introduced before later tasks consume them. Snapshot paths are provided by `WorkspaceRegistry`, and `ThtRunner` only receives an absolute rendered snapshot config path.