From 2087fbb0c9221a8ea9263016973009497cb60880 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 3 Aug 2026 22:21:10 +0200 Subject: [PATCH] feat: manage workspace Git checkout and snapshots --- backend/src/app.ts | 6 + backend/src/workspaces/git-repository.ts | 230 ++++++++++++++++++ backend/src/workspaces/registry.ts | 230 ++++++++++++++++++ backend/test/workspace-registry.test.ts | 134 ++++++++++ .../test/workspaces-git-repository.test.ts | 107 ++++++++ 5 files changed, 707 insertions(+) create mode 100644 backend/src/workspaces/git-repository.ts create mode 100644 backend/src/workspaces/registry.ts create mode 100644 backend/test/workspace-registry.test.ts create mode 100644 backend/test/workspaces-git-repository.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index b2d5d074..f683b7ab 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,6 +15,7 @@ import { settingsRoutes, effectiveSettings } from "./routes/settings.js"; import { createPiModelLister } from "./pi/list-models.js"; import { loadSettings, saveSettings, type Settings } from "./settings/settings-store.js"; import { ReadinessManager } from "./runtime/readiness-manager.js"; +import { WorkspaceRegistry } from "./workspaces/registry.js"; export interface BuildAppDeps { thtRunner?: ThtRunner; @@ -24,6 +25,7 @@ export interface BuildAppDeps { getSettings?: (principal?: PrincipalContext) => Settings | Promise; readiness?: ReadinessManager; hub?: SseHub; + workspaceRegistry?: WorkspaceRegistry; } export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance { @@ -47,6 +49,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc }); const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); const hub = deps?.hub ?? new SseHub(); + // Routes are introduced in Task 6; construction here keeps production and injected-test + // dependencies on the same registry lifecycle without performing Git I/O at startup. + const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry); + void workspaceRegistry; const readiness = deps?.readiness ?? new ReadinessManager( tht as ThtRunner, Math.round(config.ollamaEnsureTimeoutMs / 1000), diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts new file mode 100644 index 00000000..d2fe8e98 --- /dev/null +++ b/backend/src/workspaces/git-repository.ts @@ -0,0 +1,230 @@ +import { execFile } from "node:child_process"; +import { constants, lstatSync, mkdirSync, openSync, closeSync, unlinkSync } from "node:fs"; +import { access, lstat, mkdir } from "node:fs/promises"; +import { basename, isAbsolute, join } from "node:path"; +import { promisify } from "node:util"; +import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js"; + +const execFileAsync = promisify(execFile); + +export interface GitStatus { + branch: string; + head?: string; + ahead: number; + behind: number; + degraded: boolean; + lastError?: WorkspaceErrorCode; +} + +export class WorkspaceRegistryError extends Error { + constructor(readonly code: WorkspaceErrorCode, message: string) { + super(message); + this.name = "WorkspaceRegistryError"; + } +} + +function isMissing(path: string): boolean { + try { + lstatSync(path); + return false; + } catch { + return true; + } +} + +function assertDirectory(path: string): void { + const entry = lstatSync(path); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new WorkspaceRegistryError("git_unavailable", "Workspace registry path is unavailable"); + } +} + +function gitErrorCode(error: unknown): WorkspaceErrorCode { + const detail = [ + error instanceof Error ? error.message : "", + typeof error === "object" && error !== null && "stderr" in error + ? String((error as { stderr?: unknown }).stderr ?? "") + : "", + ].join("\n").toLowerCase(); + if (/authentication failed|could not read username|permission denied|publickey/.test(detail)) { + return "git_auth_failed"; + } + if (/non-fast-forward|not possible to fast-forward|fast-forward/.test(detail)) { + return "git_non_fast_forward"; + } + if (/remote rejected|pre-receive hook declined|push.*rejected/.test(detail)) { + return "git_push_rejected"; + } + return "git_unavailable"; +} + +/** + * A persistent checkout that executes Git only through fixed argument vectors. Git's stdout and + * stderr are intentionally never exposed: they can contain remote URLs or credential hints. + */ +export class GitWorkspaceRepository { + readonly root: string; + readonly repoPath: string; + readonly snapshotsPath: string; + readonly statePath: string; + readonly locksPath: string; + private readonly hooksPath: string; + + constructor(private readonly config: WorkspaceRegistryConfig) { + if (!isAbsolute(config.root)) { + throw new WorkspaceRegistryError("git_unavailable", "Workspace registry root is unavailable"); + } + this.root = config.root; + this.repoPath = join(this.root, "repo"); + this.snapshotsPath = join(this.root, "snapshots"); + this.statePath = join(this.root, "state"); + this.locksPath = join(this.root, "locks"); + this.hooksPath = join(this.locksPath, "empty-hooks"); + } + + async ensureLayout(): Promise { + for (const path of [this.root, this.snapshotsPath, this.statePath, this.locksPath, this.hooksPath]) { + await mkdir(path, { recursive: true, mode: 0o700 }); + assertDirectory(path); + } + } + + async bootstrap(): Promise { + await this.ensureLayout(); + if (isMissing(this.repoPath)) { + if (!this.config.remoteUrl) { + throw new WorkspaceRegistryError("git_unavailable", "Workspace registry remote is unavailable"); + } + await this.clone(); + } else { + assertDirectory(this.repoPath); + await this.refresh(); + } + return await this.status(); + } + + async pull(): Promise { + await this.ensureLayout(); + if (isMissing(this.repoPath)) return await this.bootstrap(); + assertDirectory(this.repoPath); + await this.refresh(); + return await this.status(); + } + + async status(): Promise { + const head = (await this.git(["rev-parse", "HEAD"])).trim(); + const tracking = await this.gitOptional(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]); + const [ahead = "0", behind = "0"] = tracking ? tracking.trim().split(/\s+/) : []; + return { + branch: this.config.branch, + head, + ahead: Number(ahead), + behind: Number(behind), + degraded: false, + }; + } + + async workspacePaths(): Promise { + const output = await this.git(["ls-tree", "-r", "--name-only", "HEAD", "--", "workspaces"]); + const paths = output.trim() === "" ? [] : output.trim().split("\n"); + for (const path of paths) { + if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path"); + } + } + return paths; + } + + async readWorkspace(path: string): Promise { + if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); + } + return await this.git(["show", `HEAD:${path}`]); + } + + async blob(path: string): Promise { + if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); + } + return (await this.git(["rev-parse", `HEAD:${path}`])).trim(); + } + + private async clone(): Promise { + try { + await execFileAsync("git", [ + "-c", `core.hooksPath=${this.hooksPath}`, + "clone", "--branch", this.config.branch, "--single-branch", "--", this.config.remoteUrl!, this.repoPath, + ], { cwd: this.root, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); + assertDirectory(this.repoPath); + } catch (error) { + throw this.sanitizeGitError(error); + } + } + + private async refresh(): Promise { + if (this.config.remoteUrl) { + await this.git(["remote", "set-url", "origin", "--", this.config.remoteUrl]); + } + await this.git(["fetch", "--no-tags", "origin", this.config.branch]); + await this.git(["merge", "--ff-only", "FETCH_HEAD"]); + } + + private async git(args: string[]): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["-c", `core.hooksPath=${this.hooksPath}`, ...args], + { cwd: this.repoPath, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, + ); + return stdout; + } catch (error) { + throw this.sanitizeGitError(error); + } + } + + private async gitOptional(args: string[]): Promise { + try { + return await this.git(args); + } catch (error) { + if (error instanceof WorkspaceRegistryError && error.code === "git_unavailable") return undefined; + throw error; + } + } + + private sanitizeGitError(error: unknown): WorkspaceRegistryError { + return new WorkspaceRegistryError(gitErrorCode(error), "Workspace Git operation failed"); + } +} + +export class WorkspaceRepositoryLock { + private queue = Promise.resolve(); + + constructor(private readonly locksPath: string) {} + + async run(operation: () => Promise): Promise { + const previous = this.queue; + let releaseQueue!: () => void; + this.queue = new Promise((resolve) => { releaseQueue = resolve; }); + await previous; + + mkdirSync(this.locksPath, { recursive: true, mode: 0o700 }); + assertDirectory(this.locksPath); + let descriptor: number | undefined; + const lockPath = join(this.locksPath, "repository.lock"); + try { + descriptor = openSync(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + return await operation(); + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") { + throw new WorkspaceRegistryError("workspace_stale", "Workspace registry is busy"); + } + throw error; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + if (descriptor !== undefined) { + try { unlinkSync(lockPath); } catch { /* stale lock cleanup is retried by the operator */ } + } + releaseQueue(); + } + } +} diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts new file mode 100644 index 00000000..cd6efe3f --- /dev/null +++ b/backend/src/workspaces/registry.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import { lstatSync } from "node:fs"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js"; +import { + GitWorkspaceRepository, + WorkspaceRegistryError, + WorkspaceRepositoryLock, + type GitStatus, +} from "./git-repository.js"; +import { parseWorkspaceYaml, serializeWorkspaceYaml, type CanonicalWorkspace } from "./schema.js"; +import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js"; + +export type { GitStatus } from "./git-repository.js"; + +export interface WorkspaceRevision { + id: string; + commit: string; + blob: string; + snapshotPath: string; +} + +export type PublishWorkspaceRequest = + | { action: "create"; workspace: CanonicalWorkspace; baseCommit: string } + | { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string } + | { action: "delete"; id: string; baseCommit: string; baseBlob: string }; + +interface ActiveState { + head: string; + revisions: WorkspaceRevision[]; +} + +function workspacePath(id: string): string { + if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID is invalid"); + } + return `workspaces/${id}.yaml`; +} + +function safeCommit(commit: string): string { + if (!/^[0-9a-f]{40}$/.test(commit)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); + } + return commit; +} + +function workspaceError(error: unknown): WorkspaceRegistryError { + if (error instanceof WorkspaceRegistryError) return error; + return new WorkspaceRegistryError("workspace_invalid", "Workspace repository content is invalid"); +} + +/** Immutable canonical workspace snapshots backed by the configured Git checkout. */ +export class WorkspaceRegistry { + private readonly repository: GitWorkspaceRepository; + private readonly lock: WorkspaceRepositoryLock; + + constructor(private readonly config: WorkspaceRegistryConfig) { + this.repository = new GitWorkspaceRepository(config); + this.lock = new WorkspaceRepositoryLock(this.repository.locksPath); + } + + snapshotPath(commit: string, id: string): string { + return join(this.repository.snapshotsPath, safeCommit(commit), `${workspacePath(id).slice("workspaces/".length)}`); + } + + async bootstrap(): Promise { + await this.repository.ensureLayout(); + return await this.lock.run(async () => { + try { + const status = await this.repository.bootstrap(); + await this.activate(status.head!); + return status; + } catch (error) { + return await this.gitFallback(error); + } + }); + } + + async pull(): Promise { + await this.repository.ensureLayout(); + return await this.lock.run(async () => { + try { + const status = await this.repository.pull(); + await this.activate(status.head!); + return status; + } catch (error) { + return await this.gitFallback(error); + } + }); + } + + async list(): Promise { + return (await this.activeState()).revisions; + } + + async read(id: string): Promise<{ workspace: CanonicalWorkspace; revision: WorkspaceRevision }> { + const state = await this.activeState(); + const revision = state.revisions.find((candidate) => candidate.id === id); + if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable"); + try { + const source = await readFile(revision.snapshotPath, "utf8"); + return { workspace: parseWorkspaceYaml(source), revision }; + } catch (error) { + throw workspaceError(error); + } + } + + /** Publication is deliberately deferred until Task 6 adds validated route-level concurrency controls. */ + async publish(_request: PublishWorkspaceRequest): Promise { + throw new WorkspaceRegistryError("workspace_stale", "Workspace publication is unavailable"); + } + + private async activate(commit: string): Promise { + const safeHead = safeCommit(commit); + const files = await this.repository.workspacePaths(); + if (files.length === 0) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains no workspaces"); + } + + const snapshots: Array<{ id: string; source: string; workspace: CanonicalWorkspace; blob: string }> = []; + try { + for (const path of files) { + const id = path.slice("workspaces/".length, -".yaml".length); + const source = await this.repository.readWorkspace(path); + const workspace = parseWorkspaceYaml(source); + if (workspace.workspace.id !== id) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path"); + } + buildInstallationContract(workspace); + renderWorkspaceDocs(workspace); + snapshots.push({ id, source: serializeWorkspaceYaml(workspace), workspace, blob: await this.repository.blob(path) }); + } + } catch (error) { + throw workspaceError(error); + } + + const snapshotDirectory = join(this.repository.snapshotsPath, safeHead); + if (!this.pathExists(snapshotDirectory)) { + const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`); + await mkdir(staging, { mode: 0o700 }); + try { + const revisions: WorkspaceRevision[] = []; + for (const snapshot of snapshots) { + const path = join(staging, `${snapshot.id}.yaml`); + const docs = renderWorkspaceDocs(snapshot.workspace); + await writeFile(path, snapshot.source, { encoding: "utf8", mode: 0o400 }); + await writeFile(join(staging, `${snapshot.id}.env.example`), docs.envExample, { encoding: "utf8", mode: 0o400 }); + await writeFile(join(staging, `${snapshot.id}.md`), docs.markdown, { encoding: "utf8", mode: 0o400 }); + revisions.push({ id: snapshot.id, commit: safeHead, blob: snapshot.blob, snapshotPath: this.snapshotPath(safeHead, snapshot.id) }); + } + await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions }), { + encoding: "utf8", mode: 0o400, + }); + await rename(staging, snapshotDirectory); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } + } + + const revisions = snapshots.map((snapshot) => ({ + id: snapshot.id, + commit: safeHead, + blob: snapshot.blob, + snapshotPath: this.snapshotPath(safeHead, snapshot.id), + })); + await this.writeActiveState({ head: safeHead, revisions }); + } + + private async gitFallback(error: unknown): Promise { + const safeError = workspaceError(error); + if (safeError.code !== "git_unavailable" && safeError.code !== "git_auth_failed") throw safeError; + const active = await this.tryActiveState(); + if (!active) throw safeError; + return { + branch: this.config.branch, + head: active.head, + ahead: 0, + behind: 0, + degraded: true, + lastError: safeError.code, + }; + } + + private async activeState(): Promise { + const active = await this.tryActiveState(); + if (!active) throw new WorkspaceRegistryError("workspace_invalid", "No active workspace snapshot is available"); + return active; + } + + private async tryActiveState(): Promise { + const file = join(this.repository.statePath, "active.json"); + try { + const state = JSON.parse(await readFile(file, "utf8")) as ActiveState; + safeCommit(state.head); + if (!Array.isArray(state.revisions) || state.revisions.length === 0) throw new Error("bad state"); + for (const revision of state.revisions) { + safeCommit(revision.commit); + workspacePath(revision.id); + if (!isAbsolute(revision.snapshotPath) || revision.snapshotPath !== this.snapshotPath(revision.commit, revision.id)) { + throw new Error("bad snapshot path"); + } + } + return state; + } catch { + return undefined; + } + } + + private async writeActiveState(state: ActiveState): Promise { + const target = join(this.repository.statePath, "active.json"); + const staging = join(this.repository.statePath, `.active-${randomUUID()}.json`); + await writeFile(staging, JSON.stringify(state), { encoding: "utf8", mode: 0o600 }); + await rename(staging, target); + } + + private pathExists(path: string): boolean { + try { + const entry = lstatSync(path); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot path is invalid"); + } + return true; + } catch (error) { + if (error instanceof WorkspaceRegistryError) throw error; + return false; + } + } +} diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts new file mode 100644 index 00000000..46e31c29 --- /dev/null +++ b/backend/test/workspace-registry.test.ts @@ -0,0 +1,134 @@ +import { execFile } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, 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 type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; + +const validYaml = `workspace: + schema_version: 1 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct] +semantic_index: + vector_store: + engine: pgvector + collection: clinical_documents + dimensions: 768 + distance: cosine + supported_transports: [pgvector_direct] + embedding: + provider: ollama_compatible + model: nomic-embed-text-v2-moe + dimensions: 768 +llm_policy: + allowed: [zai/glm-5.2] +`; + +const runFile = promisify(execFile); +const temporaryRoots: string[] = []; + +afterEach(() => { + temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +async function git(cwd: string, args: string[]): Promise { + await runFile("git", args, { cwd }); +} + +async function fixture(): Promise<{ + root: string; remote: string; source: string; initialCommit: string; +}> { + const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-")); + temporaryRoots.push(root); + const remote = join(root, "remote.git"); + const source = join(root, "source"); + await git(root, ["init", "--bare", "--initial-branch=main", remote]); + mkdirSync(source); + await git(source, ["init", "--initial-branch=main"]); + await git(source, ["config", "user.name", "Workspace Registry Test"]); + await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); + mkdirSync(join(source, "workspaces")); + writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml); + await git(source, ["add", "workspaces/psd-clinical.yaml"]); + await git(source, ["commit", "-m", "Initial workspace"]); + await git(source, ["remote", "add", "origin", remote]); + await git(source, ["push", "origin", "main"]); + const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: source }); + return { root, remote, source, initialCommit: stdout.trim() }; +} + +function config(root: string, remoteUrl: string): WorkspaceRegistryConfig { + return { + root, + remoteUrl, + branch: "main", + gitAuthorName: "Workspace Registry Test", + gitAuthorEmail: "workspace-registry@example.invalid", + installationId: "test", + secretRoots: [], + maxImportBytes: 1024, + maxImportEntries: 1, + }; +} + +async function pushInvalidWorkspace(source: string): Promise { + writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), "workspace: invalid\n"); + await git(source, ["add", "workspaces/psd-clinical.yaml"]); + await git(source, ["commit", "-m", "Invalid workspace"]); + await git(source, ["push", "origin", "main"]); +} + +test("bootstraps a checkout and activates a validated immutable snapshot", async () => { + const remote = await fixture(); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + + const status = await registry.bootstrap(); + + expect(status.head).toMatch(/^[0-9a-f]{40}$/); + expect(existsSync(registry.snapshotPath(status.head!, "psd-clinical"))).toBe(true); + await expect(registry.read("psd-clinical")).resolves.toMatchObject({ + revision: { commit: remote.initialCommit, id: "psd-clinical" }, + }); +}); + +test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => { + const remote = await fixture(); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + await registry.bootstrap(); + await pushInvalidWorkspace(remote.source); + + await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" }); + await expect(registry.read("psd-clinical")).resolves.toMatchObject({ + revision: { commit: remote.initialCommit }, + }); +}); + +test("does not bypass an existing advisory repository lock", async () => { + const remote = await fixture(); + const root = join(remote.root, "registry"); + const registry = new WorkspaceRegistry(config(root, remote.remote)); + mkdirSync(join(root, "locks"), { recursive: true }); + writeFileSync(join(root, "locks", "repository.lock"), "held"); + + await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_stale" }); +}); + +test("rejects a symbolic-link registry root before creating a lock below it", async () => { + const remote = await fixture(); + const target = join(remote.root, "registry-target"); + const root = join(remote.root, "registry-link"); + mkdirSync(target); + symlinkSync(target, root); + const registry = new WorkspaceRegistry(config(root, remote.remote)); + + await expect(registry.bootstrap()).rejects.toMatchObject({ code: "git_unavailable" }); + expect(existsSync(join(target, "locks"))).toBe(false); +}); diff --git a/backend/test/workspaces-git-repository.test.ts b/backend/test/workspaces-git-repository.test.ts new file mode 100644 index 00000000..ef0460d6 --- /dev/null +++ b/backend/test/workspaces-git-repository.test.ts @@ -0,0 +1,107 @@ +import { execFile } from "node:child_process"; +import { mkdtempSync, mkdirSync, 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 { GitWorkspaceRepository } from "../src/workspaces/git-repository.js"; +import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; + +const validYaml = `workspace: + schema_version: 1 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct] +semantic_index: + vector_store: + engine: pgvector + collection: clinical_documents + dimensions: 768 + distance: cosine + supported_transports: [pgvector_direct] + embedding: + provider: ollama_compatible + model: nomic-embed-text-v2-moe + dimensions: 768 +llm_policy: + allowed: [zai/glm-5.2] +`; + +const runFile = promisify(execFile); +const temporaryRoots: string[] = []; + +afterEach(() => { + temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +async function git(cwd: string, args: string[]): Promise { + await runFile("git", args, { cwd }); +} + +async function temporaryRemote(): Promise<{ root: string; remote: string; initialCommit: string }> { + const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-")); + temporaryRoots.push(root); + const remote = join(root, "remote.git"); + const source = join(root, "source"); + await git(root, ["init", "--bare", "--initial-branch=main", remote]); + mkdirSync(source); + await git(source, ["init", "--initial-branch=main"]); + await git(source, ["config", "user.name", "Workspace Registry Test"]); + await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); + mkdirSync(join(source, "workspaces")); + writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml); + await git(source, ["add", "workspaces/psd-clinical.yaml"]); + await git(source, ["commit", "-m", "Initial workspace"]); + await git(source, ["remote", "add", "origin", remote]); + await git(source, ["push", "origin", "main"]); + const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: source }); + return { root, remote, initialCommit: stdout.trim() }; +} + +function config(root: string, remoteUrl: string): WorkspaceRegistryConfig { + return { + root, + remoteUrl, + branch: "main", + gitAuthorName: "Workspace Registry Test", + gitAuthorEmail: "workspace-registry@example.invalid", + installationId: "test", + secretRoots: [], + maxImportBytes: 1024, + maxImportEntries: 1, + }; +} + +test("bootstraps a persistent checkout from a local bare repository", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + + const status = await repository.bootstrap(); + + expect(status).toMatchObject({ + branch: "main", + head: fixture.initialCommit, + ahead: 0, + behind: 0, + degraded: false, + }); +}); + +test("redacts failed Git checkout details behind a stable error code", async () => { + const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-missing-")); + temporaryRoots.push(root); + const remote = join(root, "missing.git"); + const repository = new GitWorkspaceRepository(config(join(root, "registry"), remote)); + + const error = await repository.bootstrap().catch((error: unknown) => error); + expect(error).toMatchObject({ + code: "git_unavailable", + message: "Workspace Git operation failed", + }); + expect((error as Error).message).not.toContain(remote); +});