diff --git a/backend/test/workspace-registry-deployment.test.ts b/backend/test/workspace-registry-deployment.test.ts index 48efbb95..3c205843 100644 --- a/backend/test/workspace-registry-deployment.test.ts +++ b/backend/test/workspace-registry-deployment.test.ts @@ -1,15 +1,141 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { expect, test } from "vitest"; -import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; +import { execFile, execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; +import { WorkspaceRegistry } from "../src/workspaces/registry.js"; +import { parseWorkspaceYaml, serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js"; +import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; -test("declares a durable isolated registry volume and only read-only Git credential mounts", () => { +const runFile = promisify(execFile); +const temporaryRoots: string[] = []; + +afterEach(() => { + temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +function readFixture(name: string): string { + return readFileSync(new URL(`../../scripts/fixtures/${name}`, import.meta.url), "utf8"); +} + +function catalogYaml(workspace: CanonicalWorkspace): string { + const { id, name, description } = workspace.workspace; + return `schema_version: 1 +workspaces: + - id: ${id} + name: ${name}${description ? ` + description: ${description}` : ""} +`; +} + +async function git(cwd: string, args: string[]): Promise { + await runFile("git", args, { cwd }); +} + +async function gitOutput(cwd: string, args: string[]): Promise { + const { stdout } = await runFile("git", args, { cwd }); + return stdout.trim(); +} + +async function commitAll(cwd: string, message: string): Promise { + await git(cwd, ["add", "-A"]); + await git(cwd, [ + "-c", + "user.name=Workspace Registry Deployment Test", + "-c", + "user.email=workspace-registry-deployment@example.invalid", + "commit", + "-m", + message, + ]); + await git(cwd, ["push", "origin", "main"]); + return await gitOutput(cwd, ["rev-parse", "HEAD"]); +} + +function registryConfig(root: string, remoteUrl: string): WorkspaceRegistryConfig { + return { + root, + remoteUrl, + branch: "main", + gitAuthorName: "Workspace Registry Deployment Test", + gitAuthorEmail: "workspace-registry-deployment@example.invalid", + installationId: "deployment-test", + secretRoots: [], + maxImportBytes: 1024, + maxImportEntries: 4, + }; +} + +function workspaceVariant( + workspace: CanonicalWorkspace, + changes: Partial, +): CanonicalWorkspace { + const id = changes.id ?? workspace.workspace.id; + return { + ...workspace, + workspace: { ...workspace.workspace, ...changes, id }, + semantic_index: { + ...workspace.semantic_index, + vector_store: { ...workspace.semantic_index.vector_store, collection: id }, + }, + ...(workspace.evidence?.source.type === "filesystem" + ? { + evidence: { + ...workspace.evidence, + source: { ...workspace.evidence.source, uri: `${id}/evidence` }, + }, + } + : {}), + }; +} + +async function createRegistryFixture(workspace: CanonicalWorkspace): Promise<{ + root: string; + source: string; + remote: string; + registry: WorkspaceRegistry; +}> { + const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-deployment-")); + temporaryRoots.push(root); + const source = join(root, "source"); + const remote = join(root, "remote.git"); + mkdirSync(source, { recursive: true }); + await git(root, ["init", "--bare", "--initial-branch=main", remote]); + await git(source, ["init", "--initial-branch=main"]); + await git(source, ["config", "user.name", "Workspace Registry Deployment Test"]); + await git(source, ["config", "user.email", "workspace-registry-deployment@example.invalid"]); + + const id = workspace.workspace.id; + mkdirSync(join(source, id), { recursive: true }); + writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml(workspace)); + writeFileSync(join(source, id, "workspace.yaml"), serializeWorkspaceYaml(workspace)); + if (workspace.evidence?.source.type === "filesystem") { + mkdirSync(join(source, id, "evidence"), { recursive: true }); + writeFileSync(join(source, id, "evidence", "guide.md"), "guide v1\n"); + } + await git(source, ["add", "-A"]); + await git(source, ["commit", "-m", "Seed workspace registry deployment fixture"]); + await git(source, ["remote", "add", "origin", remote]); + await git(source, ["push", "origin", "main"]); + + return { + root, + source, + remote, + registry: new WorkspaceRegistry(registryConfig(join(root, "registry"), remote)), + }; +} + +test("declares a durable isolated registry volume and installs fixtures under the root catalog contract", () => { const compose = readFileSync(new URL("../../compose.yaml", import.meta.url), "utf8"); const development = readFileSync(new URL("../../docker-compose.dev.yml", import.meta.url), "utf8"); const gitHttps = readFileSync(new URL("../../deploy/compose.git-https.yaml", import.meta.url), "utf8"); const gitSsh = readFileSync(new URL("../../deploy/compose.git-ssh.yaml", import.meta.url), "utf8"); const dockerfile = readFileSync(new URL("../../docker/core.Dockerfile", import.meta.url), "utf8"); const smoke = readFileSync(new URL("../../scripts/workspace-registry-smoke.sh", import.meta.url), "utf8"); + const unified = readFileSync(new URL("../../scripts/unified-deployment-smoke.sh", import.meta.url), "utf8"); + const windows = readFileSync(new URL("../../scripts/test-windows-clone-contract.ps1", import.meta.url), "utf8"); for (const source of [compose, development]) { expect(source).toContain("THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry"); @@ -23,123 +149,163 @@ test("declares a durable isolated registry volume and only read-only Git credent expect(gitSsh).toMatch(/workspace-registry-git-known-hosts:ro/); expect(dockerfile).toMatch(/mkdir -p[^\n]*\/data\/workspace-registry/); expect(dockerfile).toMatch(/chown -R thoth:thoth \/home\/thoth\/\.pi \/data/); + + expect(smoke).toContain('--fixtures-only'); + expect(smoke).toContain('thoth-workspaces.yaml'); + expect(smoke).toContain('$seed/$workspace_registry_smoke_id/workspace.yaml'); + expect(smoke).toContain('$seed/$workspace_registry_smoke_id/evidence/guide.md'); + expect(smoke).not.toContain('"$seed/workspaces/local.yaml"'); + expect(smoke).not.toContain('workspace-content/local'); expect(smoke).toContain('core_remote="/fixtures/offline.git"'); expect(smoke).toContain('"degraded":true'); - expect(smoke).toContain('core_remote="/fixtures/remote.git"'); - expect(smoke).toContain( - 'cp "$root/scripts/fixtures/workspace-registry-smoke.yaml" "$seed/workspaces/local.yaml"', - ); - expect(smoke).not.toMatch(/npm\s+--prefix\s+[^\n]*backend[^\n]*\srun\s+build/); - expect(smoke).not.toMatch(/migrate-(?:legacy|v2-qdrant)/); - expect(smoke).toContain("<<'COMPOSE_YAML'"); - expect(smoke).toContain('context: "${SMOKE_ROOT:?}"'); - expect(smoke).toContain('image: "${SMOKE_IMAGE:?}"'); - expect(smoke).toContain('THT_WORKSPACE_GIT_REMOTE: "${SMOKE_CORE_REMOTE:?}"'); - expect(smoke).toContain('THT_WORKSPACE_GIT_BRANCH: "${SMOKE_BRANCH:?}"'); - expect(smoke).toContain('source: "${SMOKE_REMOTE:?}"'); - expect(smoke).toContain("type: bind"); - expect(smoke).toContain("read_only: true"); - expect(smoke).not.toContain("context: $root"); - expect(smoke).not.toContain("image: $image"); - expect(smoke).not.toContain("- $remote:/fixtures/remote.git:ro"); - expect(smoke).toContain("compose-config-contract)"); - expect(smoke).toContain("cleanup-failure-path)"); + expect(smoke).toContain('compose-config-contract)'); + expect(smoke).toContain('cleanup-failure-path)'); + + expect(unified).toContain('--fixtures-only'); + expect(unified).toContain('thoth-workspaces.yaml'); + expect(unified).toContain('$TASK13_SEED/$TASK13_WORKSPACE_ID/workspace.yaml'); + expect(unified).toContain('$TASK13_SEED/$TASK13_WORKSPACE_ID/evidence/guide.md'); + expect(unified).not.toContain('"$TASK13_SEED/workspaces/task13-smoke.yaml"'); + expect(unified).not.toContain('workspace-content/task13-smoke'); + + expect(windows).toContain('thoth-workspaces.yaml'); + expect(windows).toContain('$workspaceDestination = Join-Path $workspaceDirectory "workspace.yaml"'); + expect(windows).toContain('Join-Path $workspaceEvidence "guide.md"'); + expect(windows).toContain('legacy flat descriptor path must not be used'); + expect(windows).not.toContain('workspace-content'); }); -test("shared workspace registry smoke fixture parses as schema v3 internal semantic identity", () => { - const source = readFileSync( - new URL("../../scripts/fixtures/workspace-registry-smoke.yaml", import.meta.url), - "utf8", - ); - const descriptor = parseWorkspaceYaml(source); +test("shared deployment fixtures remain valid standalone descriptors with canonical filesystem Evidence", () => { + const smoke = parseWorkspaceYaml(readFixture("workspace-registry-smoke.yaml")); + const task13 = parseWorkspaceYaml(readFixture("workspace-registry-task13.yaml")); + const windows = parseWorkspaceYaml(readFixture("workspace-registry-windows.yaml")); - expect(descriptor).toMatchObject({ - workspace: { schema_version: 3, id: "local", name: "Local" }, - dwh: { - engine: "postgres", - database: "postgres", - schema: "public", - supported_transports: ["postgres_direct", "rest_api"], + expect(smoke).toMatchObject({ + workspace: { + schema_version: 3, + id: "local", + name: "Local", + description: "Isolated workspace registry smoke fixture.", }, - semantic_index: { - vector_store: { - engine: "qdrant", - collection: "local", - dimensions: 1024, - distance: "cosine", - }, - embedding: { - provider: "ollama_internal", - model: "qwen3-embedding:0.6b", - dimensions: 1024, - }, + evidence: { + source: { type: "filesystem", uri: "local/evidence", patterns: ["**/*.md"] }, + policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, }, - llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, - diagnostics: { - dwh_rest: { - method: "GET", - path: "/health", - auth: "none", - response: { database: "database", schema: "schema" }, - }, + }); + expect(task13).toMatchObject({ + workspace: { schema_version: 3, id: "task13-smoke", name: "Task 13 Smoke" }, + evidence: { + source: { type: "filesystem", uri: "task13-smoke/evidence", patterns: ["**/*.md"] }, + policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, + }, + }); + expect(windows).toMatchObject({ + workspace: { schema_version: 3, id: "task13-windows", name: "Task 13 Windows" }, + evidence: { + source: { type: "filesystem", uri: "task13-windows/evidence", patterns: ["**/*.md"] }, + policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, }, }); - expect(descriptor).not.toHaveProperty("evidence"); }); -test("Windows clone contract copies the shared complete schema v3 descriptor", () => { - const fixture = readFileSync( - new URL("../../scripts/fixtures/workspace-registry-windows.yaml", import.meta.url), - "utf8", +test("registry boots from the nested catalog layout, accepts co-committed display metadata, and advances on evidence-only commits", async () => { + const workspace = parseWorkspaceYaml(readFixture("workspace-registry-smoke.yaml")); + const fixture = await createRegistryFixture(workspace); + + const bootstrapped = await fixture.registry.bootstrap(); + expect(bootstrapped.head).toMatch(/^[0-9a-f]{40}$/); + expect(await fixture.registry.listCatalog()).toContainEqual( + expect.objectContaining({ + id: "local", + name: "Local", + description: "Isolated workspace registry smoke fixture.", + configurationState: "ready", + }), ); - const descriptor = parseWorkspaceYaml(fixture); + + const coCommitted = workspaceVariant(workspace, { name: "Local Updated" }); + writeFileSync( + join(fixture.source, "local", "workspace.yaml"), + serializeWorkspaceYaml(coCommitted), + ); + writeFileSync(join(fixture.source, "thoth-workspaces.yaml"), catalogYaml(coCommitted)); + const metadataCommit = await commitAll(fixture.source, "Update catalog and descriptor together"); + const metadataStatus = await fixture.registry.pull(); + expect(metadataStatus.head).toBe(metadataCommit); + const metadataRevision = await fixture.registry.read("local"); + expect(metadataRevision.workspace.workspace.name).toBe("Local Updated"); + + writeFileSync(join(fixture.source, "local", "evidence", "guide.md"), "guide v2\n"); + const evidenceCommit = await commitAll(fixture.source, "Update curated evidence only"); + const evidenceStatus = await fixture.registry.pull(); + expect(evidenceStatus.head).toBe(evidenceCommit); + expect(evidenceStatus.head).not.toBe(metadataCommit); + const evidenceRevision = await fixture.registry.read("local"); + expect(evidenceRevision.workspace.workspace.name).toBe("Local Updated"); + expect(evidenceRevision.revision.commit).toBe(evidenceCommit); + expect(evidenceRevision.revision.commit).not.toBe(metadataRevision.revision.commit); + expect(evidenceRevision.revision.blob).toBe(metadataRevision.revision.blob); +}); + +test("registry rejects orphan descriptors, metadata mismatches, and the retired flat layout while keeping the last active snapshot", async () => { + const workspace = parseWorkspaceYaml(readFixture("workspace-registry-smoke.yaml")); + + const expectRejectedMutation = async (mutate: (fixture: Awaited>) => Promise | void) => { + const fixture = await createRegistryFixture(workspace); + await fixture.registry.bootstrap(); + const active = await fixture.registry.read("local"); + await mutate(fixture); + await commitAll(fixture.source, "Apply invalid registry mutation"); + await expect(fixture.registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" }); + const retained = await fixture.registry.read("local"); + expect(retained.revision.commit).toBe(active.revision.commit); + expect(retained.workspace.workspace.name).toBe("Local"); + }; + + await expectRejectedMutation((fixture) => { + const mismatched = workspaceVariant(workspace, { name: "Local Mismatched" }); + writeFileSync(join(fixture.source, "local", "workspace.yaml"), serializeWorkspaceYaml(mismatched)); + }); + + await expectRejectedMutation((fixture) => { + const orphan = workspaceVariant(workspace, { id: "orphan", name: "Orphan Workspace" }); + mkdirSync(join(fixture.source, "orphan", "evidence"), { recursive: true }); + writeFileSync(join(fixture.source, "orphan", "workspace.yaml"), serializeWorkspaceYaml(orphan)); + writeFileSync(join(fixture.source, "orphan", "evidence", "guide.md"), "orphan guide\n"); + }); + + await expectRejectedMutation((fixture) => { + mkdirSync(join(fixture.source, "workspaces"), { recursive: true }); + mkdirSync(join(fixture.source, "workspace-content", "local", "evidence"), { recursive: true }); + writeFileSync(join(fixture.source, "workspaces", "local.yaml"), serializeWorkspaceYaml(workspace)); + writeFileSync(join(fixture.source, "workspace-content", "local", "evidence", "guide.md"), "legacy guide\n"); + }); +}); + +test("Windows clone contract copies the shared complete schema v3 descriptor into the nested registry layout", () => { + const descriptor = parseWorkspaceYaml(readFixture("workspace-registry-windows.yaml")); const windows = readFileSync( new URL("../../scripts/test-windows-clone-contract.ps1", import.meta.url), "utf8", ); expect(windows).toContain('"scripts/fixtures/workspace-registry-windows.yaml"'); - expect(windows).toContain("Copy-Item -LiteralPath $workspaceFixture -Destination $workspaceDestination"); - expect(windows).not.toContain("schema_version:"); - expect(descriptor).toEqual({ + expect(windows).toContain('thoth-workspaces.yaml'); + expect(windows).toContain('$workspaceDestination = Join-Path $workspaceDirectory "workspace.yaml"'); + expect(windows).toContain('Join-Path $workspaceEvidence "guide.md"'); + expect(windows).not.toContain('schema_version: 3'); + expect(descriptor).toMatchObject({ workspace: { schema_version: 3, id: "task13-windows", name: "Task 13 Windows", language: "en", }, - dwh: { - engine: "postgres", - database: "warehouse", - schema: "public", - port: 5432, - timeout_ms: 5000, - supported_transports: ["postgres_direct", "rest_api"], - }, - semantic_index: { - vector_store: { - engine: "qdrant", - collection: "task13-windows", - dimensions: 1024, - distance: "cosine", - }, - embedding: { - provider: "ollama_internal", - model: "qwen3-embedding:0.6b", - dimensions: 1024, - }, - }, - llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, - diagnostics: { - dwh_rest: { - method: "GET", - path: "/health", - auth: "none", - response: { database: "database", schema: "schema" }, - }, + evidence: { + source: { type: "filesystem", uri: "task13-windows/evidence", patterns: ["**/*.md"] }, + policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, }, }); - expect(descriptor).not.toHaveProperty("evidence"); }); test("workspace registry smoke image cleanup is scoped to the per-run image identity", () => { diff --git a/scripts/fixtures/workspace-registry-smoke.yaml b/scripts/fixtures/workspace-registry-smoke.yaml index d2dda9b3..0a5e5a4e 100644 --- a/scripts/fixtures/workspace-registry-smoke.yaml +++ b/scripts/fixtures/workspace-registry-smoke.yaml @@ -31,6 +31,17 @@ llm_policy: allowed: - zai/glm-5.2 +evidence: + source: + type: filesystem + uri: local/evidence + patterns: + - "**/*.md" + max_bytes: 10485760 + policy: + max_chunk_chars: 4000 + retain_published_generations: 3 + diagnostics: dwh_rest: method: GET diff --git a/scripts/fixtures/workspace-registry-task13.yaml b/scripts/fixtures/workspace-registry-task13.yaml index 9b2b2253..d9924b86 100644 --- a/scripts/fixtures/workspace-registry-task13.yaml +++ b/scripts/fixtures/workspace-registry-task13.yaml @@ -3,11 +3,14 @@ workspace: id: task13-smoke name: Task 13 Smoke language: en + dwh: engine: postgres database: warehouse schema: analytics - supported_transports: [postgres_direct] + supported_transports: + - postgres_direct + semantic_index: vector_store: engine: qdrant @@ -18,6 +21,19 @@ semantic_index: provider: ollama_internal model: qwen3-embedding:0.6b dimensions: 1024 + llm_policy: default: local-qwen/task13-smoke - allowed: [local-qwen/task13-smoke] + allowed: + - local-qwen/task13-smoke + +evidence: + source: + type: filesystem + uri: task13-smoke/evidence + patterns: + - "**/*.md" + max_bytes: 10485760 + policy: + max_chunk_chars: 4000 + retain_published_generations: 3 diff --git a/scripts/fixtures/workspace-registry-windows.yaml b/scripts/fixtures/workspace-registry-windows.yaml index d0c03de3..f9a93547 100644 --- a/scripts/fixtures/workspace-registry-windows.yaml +++ b/scripts/fixtures/workspace-registry-windows.yaml @@ -30,6 +30,17 @@ llm_policy: allowed: - zai/glm-5.2 +evidence: + source: + type: filesystem + uri: task13-windows/evidence + patterns: + - "**/*.md" + max_bytes: 10485760 + policy: + max_chunk_chars: 4000 + retain_published_generations: 3 + diagnostics: dwh_rest: method: GET