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"; 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 }, ...(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"); expect(source).toContain("THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE}"); expect(source).toContain("workspace-registry:/data/workspace-registry"); } expect(compose).not.toMatch(/workspace-registry-git-(?:credentials|ca|ssh-key|known-hosts):ro/); expect(gitHttps).toMatch(/workspace-registry-git-credentials:ro/); expect(gitHttps).toMatch(/workspace-registry-git-ca:ro/); expect(gitSsh).toMatch(/workspace-registry-git-ssh-key:ro/); 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('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 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(smoke).toMatchObject({ workspace: { schema_version: 4, id: "local", name: "Local", description: "Isolated workspace registry smoke fixture.", }, evidence: { source: { type: "filesystem", uri: "local/evidence", patterns: ["**/*.md"] }, policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, }, }); expect(task13).toMatchObject({ workspace: { schema_version: 4, 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: 4, 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 }, }, }); }); 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 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); }, 15_000); 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"); }); }, 15_000); test("Windows clone contract copies the shared complete schema v4 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('thoth-workspaces.yaml'); expect(windows).toContain('$workspaceDestination = Join-Path $workspaceDirectory "workspace.yaml"'); expect(windows).toContain('Join-Path $workspaceEvidence "guide.md"'); expect(windows).toContain('THT_AUTH_CONFIG_ROOT=$authConfigRoot'); expect(windows).toContain('"catalog-db,core,embedding,embedding-model-init,frontend,qdrant"'); expect(windows).toContain('"catalog-db,core,embedding,frontend,qdrant"'); expect(windows).not.toContain('schema_version: 3'); expect(descriptor).toMatchObject({ workspace: { schema_version: 4, id: "task13-windows", name: "Task 13 Windows", language: "en", }, evidence: { source: { type: "filesystem", uri: "task13-windows/evidence", patterns: ["**/*.md"] }, policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, }, }); }); test("workspace registry smoke image cleanup is scoped to the per-run image identity", () => { const output = execFileSync("bash", ["scripts/workspace-registry-smoke.sh"], { cwd: new URL("../..", import.meta.url), env: { ...process.env, WORKSPACE_REGISTRY_SMOKE_SELF_TEST: "image-cleanup-identity" }, encoding: "utf8", }); expect(output).toContain("workspace registry smoke image cleanup identity self-test passed"); }); test("workspace registry smoke cleanup failure-path self-test preserves status and retention", () => { const output = execFileSync("bash", ["scripts/workspace-registry-smoke.sh"], { cwd: new URL("../..", import.meta.url), env: { ...process.env, WORKSPACE_REGISTRY_SMOKE_SELF_TEST: "cleanup-failure-path" }, encoding: "utf8", }); expect(output).toContain("workspace registry smoke cleanup failure-path self-test passed"); }); test("workspace migration source modules are absent from the live backend boundary", () => { expect(existsSync(new URL("../src/workspaces/migrate-legacy.ts", import.meta.url))).toBe(false); expect(existsSync(new URL("../src/workspaces/migrate-v2-qdrant.ts", import.meta.url))).toBe(false); });