diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts index c197a2df..c273902a 100644 --- a/backend/src/workspaces/git-repository.ts +++ b/backend/src/workspaces/git-repository.ts @@ -1,7 +1,7 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { lstatSync, mkdirSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; import { promisify, TextDecoder } from "node:util"; import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js"; @@ -387,55 +387,6 @@ export class GitWorkspaceRepository { } } - /** Write only a validated API-owned artifact below the checked-out repository. */ - async writeRegistryFile(path: string, source: string): Promise { - this.assertRegistryArtifactPath(path); - const target = join(this.repoPath, path); - await mkdir(dirname(target), { recursive: true, mode: 0o700 }); - await writeFile(target, source, { encoding: "utf8", mode: 0o600 }); - } - - /** Create a descriptor only when no filesystem entry exists at its exact path. */ - async createRegistryFile(path: string, source: string): Promise { - this.assertRegistryArtifactPath(path); - if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { - throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor path is invalid"); - } - const target = join(this.repoPath, path); - await mkdir(dirname(target), { recursive: true, mode: 0o700 }); - try { - await writeFile(target, source, { encoding: "utf8", mode: 0o600, flag: "wx" }); - } catch { - throw new WorkspaceRegistryError("workspace_curator_owned", "Workspace descriptor is curator-owned"); - } - } - - async removeRegistryFile(path: string): Promise { - this.assertRegistryArtifactPath(path); - await rm(join(this.repoPath, path), { force: true }); - } - - private pendingPublicationPaths: string[] = []; - - /** Commit and push a fixed set of validated artifact paths without exposing Git output. */ - async commitAndPush(paths: readonly string[], message: string): Promise { - if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) { - throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); - } - this.pendingPublicationPaths = [...paths]; - try { - await this.git(["add", "--", ...paths]); - await this.git(["commit", "-m", message], this.publicationIdentity()); - await this.git(["push", "origin", `HEAD:${this.config.branch}`]); - return await this.status(); - } catch (error) { - // A failed commit leaves staged/working changes; a failed push leaves an ahead commit. - // Restore the last fetched remote revision so the next refresh or explicit retry starts clean. - await this.restoreFailedPublication(); - throw error; - } - } - private async clone(): Promise { try { await execFileAsync("git", [ @@ -448,17 +399,6 @@ export class GitWorkspaceRepository { } } - private isRegistryArtifactPath(path: string): boolean { - return /^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path) - || /^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path); - } - - private assertRegistryArtifactPath(path: string): void { - if (!this.isRegistryArtifactPath(path)) { - throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); - } - } - private async refresh(): Promise { if ((await this.git(["status", "--porcelain"])).trim() !== "") { throw new WorkspaceRegistryError("workspace_stale", "Workspace checkout has local changes"); @@ -481,37 +421,6 @@ export class GitWorkspaceRepository { } } - private publicationIdentity(): NodeJS.ProcessEnv { - return { - GIT_AUTHOR_NAME: this.config.gitAuthorName, - GIT_AUTHOR_EMAIL: this.config.gitAuthorEmail, - GIT_COMMITTER_NAME: this.config.gitAuthorName, - GIT_COMMITTER_EMAIL: this.config.gitAuthorEmail, - }; - } - - private async restoreFailedPublication(): Promise { - try { - await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]); - // Remove only the exact untracked files this publication created, never curated content. - const untracked = this.pendingPublicationPaths.filter((path) => { - try { - lstatSync(join(this.repoPath, path)); - return true; - } catch { - return false; - } - }); - if (untracked.length > 0) { - await this.git(["clean", "-fd", "--", ...untracked]); - } - this.pendingPublicationPaths = []; - } catch { - // Keep the original sanitized publish failure. A future refresh will surface any recovery - // problem without leaking the Git failure details through the API. - } - } - private async git( args: string[], env: NodeJS.ProcessEnv = {}, diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index a9bbdd1c..31def9fb 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -17,7 +17,6 @@ import { parseWorkspaceYaml, serializeWorkspaceYaml, validateOperationalWorkspace, - type CanonicalWorkspace, type WorkspaceDescriptor, } from "./schema.js"; import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js"; @@ -40,25 +39,6 @@ export interface SessionRevisionLease { abort(): Promise; } -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 }; - -export class WorkspaceConflictError extends WorkspaceRegistryError { - constructor( - readonly fields: string[], - readonly expected: { commit: string; blob?: string }, - readonly actual: { commit: string; blob?: string }, - readonly base?: CanonicalWorkspace, - readonly local?: CanonicalWorkspace, - readonly remote?: CanonicalWorkspace, - ) { - super("workspace_conflict", "Workspace revision conflicts with the active registry"); - this.name = "WorkspaceConflictError"; - } -} - interface ActiveState { head: string; revisions: WorkspaceRevision[]; @@ -139,69 +119,17 @@ export class WorkspaceRegistry { return await this.lock.run(async () => { try { const status = await this.repository.pull(); - const head = await this.reconcileGeneratedDocs(status.head!); - await this.activate(head); - return head === status.head ? status : { ...status, head }; + await this.activate(status.head!); + return status; } catch (error) { return await this.gitFallback(error); } }); } - /** - * Reconcile API-owned generated documentation against the active catalog/descriptors at an - * exact commit. Startup/status paths never push; only an explicit operator pull may produce a - * single deterministic docs-only follow-up commit. Curator catalog/descriptor/Evidence bytes - * are never modified. - */ - private async reconcileGeneratedDocs(commit: string): Promise { - const safeHead = safeCommit(commit); - const catalog = parseWorkspaceCatalogYaml(await this.repository.readCatalog(safeHead)); - const catalogById = new Map(catalog.workspaces.map((entry) => [entry.id, entry])); - const expected = new Map(); - for (const id of catalogById.keys()) { - const path = workspacePath(id); - const type = await this.repository.gitObjectType(safeHead, path); - if (type !== "blob") continue; - const workspace = parseWorkspaceYaml(await this.repository.readWorkspace(path, safeHead)); - assertCatalogMatchesDescriptor(catalogById.get(id)!, workspace); - expected.set(id, renderWorkspaceDocs(workspace)); - } - - const docPaths = this.documentationPaths; - const writes: string[] = []; - const removals: string[] = []; - for (const [id, docs] of expected) { - for (const [kind, contents] of [["contract", docs.envExample], ["readme", docs.markdown]] as const) { - const path = docPaths(id)[kind === "contract" ? "contract" : "readme"]; - const current = await this.repository.readObjectOrAbsent(safeHead, path); - if (current !== contents) { - await this.repository.writeRegistryFile(path, contents); - writes.push(path); - } - } - } - const presentDocs = new Set(); - for (const path of await this.repository.workspaceDocsPaths(safeHead)) { - const id = path.slice("workspace-docs/".length, path.lastIndexOf("/")); - if (!expected.has(id)) { - await this.repository.removeRegistryFile(path); - removals.push(path); - } else { - presentDocs.add(path); - } - } - if (writes.length === 0 && removals.length === 0) return safeHead; - const next = await this.repository.commitAndPush( - [...writes, ...removals], - "Synchronize generated workspace documentation", - ); - return next.head!; - } - async listCatalog(): Promise> { const active = await this.tryActiveState(); if (!active) { @@ -211,11 +139,8 @@ export class WorkspaceRegistry { const catalog = active.catalog ?? { schema_version: 1 as const, workspaces: [] }; return catalog.workspaces.map((entry) => ({ ...entry, - configurationState: active.revisions.some((revision) => revision.id === entry.id) - ? "ready" as const : "configuration_required" as const, - ...(active.revisions.find((revision) => revision.id === entry.id) - ? { revision: active.revisions.find((revision) => revision.id === entry.id) } - : {}), + configurationState: "ready" as const, + revision: active.revisions.find((revision) => revision.id === entry.id)!, })); } @@ -427,120 +352,6 @@ export class WorkspaceRegistry { return leases; } - /** - * Publish canonical YAML and derived public documentation as one optimistic Git revision. - * The browser never provides paths or generated artifacts; those are derived server-side. - */ - async publish(request: PublishWorkspaceRequest): Promise { - await this.repository.ensureLayout(); - return await this.lock.run(async () => { - if (request.action !== "create") { - throw new WorkspaceRegistryError( - "workspace_curator_owned", - "Workspace descriptors are curator-owned and must be changed through Git", - ); - } - const status = await this.repository.pull(); - await this.activate(status.head!); - const current = await this.activeState(); - const id = request.workspace.workspace.id; - const existing = current.revisions.find((revision) => revision.id === id); - if (existing) { - throw new WorkspaceRegistryError( - "workspace_curator_owned", - "Workspace descriptor is curator-owned and must be changed through Git", - ); - } - if (request.baseCommit !== status.head) { - throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale"); - } - const catalog = current.catalog ?? { schema_version: 1 as const, workspaces: [] }; - const entry = catalog.workspaces.find((candidate) => candidate.id === id); - if (!entry) { - throw new WorkspaceRegistryError( - "workspace_invalid", - "Workspace is not listed in the root catalog", - ); - } - assertCatalogMatchesDescriptor(entry, request.workspace); - await this.assertEvidenceContext(request.workspace, status.head!); - - const yamlPath = workspacePath(id); - const docPaths = this.documentationPaths(id); - const canonical = request.workspace; - const source = serializeWorkspaceYaml(canonical); - const docs = renderWorkspaceDocs(canonical); - await this.repository.createRegistryFile(yamlPath, source); - await this.repository.writeRegistryFile(docPaths.contract, docs.envExample); - await this.repository.writeRegistryFile(docPaths.readme, docs.markdown); - - const next = await this.repository.commitAndPush( - [yamlPath, docPaths.contract, docPaths.readme], - `Publish workspace ${id}`, - ); - await this.activate(next.head!); - return (await this.activeState()).revisions.find((revision) => revision.id === id); - }); - } - - private documentationPaths(id: string): { contract: string; readme: string } { - workspacePath(id); - const directory = `workspace-docs/${id}`; - return { contract: `${directory}/contract.env.example`, readme: `${directory}/README.md` }; - } - - private async conflictFor( - request: PublishWorkspaceRequest, - currentCommit: string, - existing: WorkspaceRevision | undefined, - local: CanonicalWorkspace | undefined, - ): Promise { - const id = request.action === "delete" ? request.id : request.workspace.workspace.id; - const base = await this.readSnapshotCanonical(request.baseCommit, id); - let remote: CanonicalWorkspace | undefined; - if (existing) { - remote = (await this.read(id)).workspace; - } - return new WorkspaceConflictError( - this.changedFields(base, remote), - { commit: request.baseCommit, ...(request.action === "create" ? {} : { blob: request.baseBlob }) }, - { commit: currentCommit, ...(existing ? { blob: existing.blob } : {}) }, - base, - local, - remote, - ); - } - - private async readSnapshotCanonical(commit: string, id: string): Promise { - try { - const source = await readFile(this.snapshotPath(commit, id), "utf8"); - return parseWorkspaceYaml(source); - } catch { - return undefined; - } - } - - private changedFields( - base: unknown, - remote: unknown, - prefix = "", - ): string[] { - if (base === undefined || remote === undefined) { - return base === remote ? [] : [prefix || "workspace.id"]; - } - if (Array.isArray(base) || Array.isArray(remote) || typeof base !== "object" || typeof remote !== "object") { - return JSON.stringify(base) === JSON.stringify(remote) ? [] : [prefix]; - } - const baseObject = base as Record; - const remoteObject = remote as Record; - const keys = new Set([...Object.keys(baseObject), ...Object.keys(remoteObject)]); - return [...keys].flatMap((key) => this.changedFields( - baseObject[key], - remoteObject[key], - prefix ? `${prefix}.${key}` : key, - )); - } - private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise { if (workspace.evidence?.source.type !== "filesystem") return; // P6 owns recursive containment. Here we deliberately validate only the declared root object. @@ -564,6 +375,15 @@ export class WorkspaceRegistry { } } const files = await this.repository.workspacePaths(); + const descriptorIds = new Set(files.map((path) => path.slice(0, -"/workspace.yaml".length))); + for (const id of catalogById.keys()) { + if (!descriptorIds.has(id)) { + throw new WorkspaceRegistryError( + "workspace_invalid", + "Every catalog workspace must have a published descriptor", + ); + } + } const snapshots: Array<{ id: string; diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 550b9285..283a6450 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -202,14 +202,11 @@ async function fixture(workspaceSource = validYaml): Promise<{ mkdirSync(join(source, "psd-clinical"), { recursive: true }); writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml([ { id: "psd-clinical", name: workspace?.workspace.name ?? "Policlinico San Donato", ...(workspace?.workspace.description ? { description: workspace.workspace.description } : {}) }, - { id: "research", name: "research" }, ])); writeFileSync(join(source, "psd-clinical", "workspace.yaml"), workspaceSource); if (workspaceSource.includes("type: filesystem")) { mkdirSync(join(source, "psd-clinical", "evidence"), { recursive: true }); - mkdirSync(join(source, "research", "evidence"), { recursive: true }); writeFileSync(join(source, "psd-clinical", "evidence", "guide.md"), "guide v1\n"); - writeFileSync(join(source, "research", "evidence", "guide.md"), "research guide\n"); await git(source, ["add", "-A"]); } else { await git(source, ["add", "thoth-workspaces.yaml", "psd-clinical/workspace.yaml"]); @@ -293,38 +290,6 @@ function config( }; } -function workspaceWith( - id: string, - changes: Partial> = {}, -): CanonicalWorkspace { - const workspace = parseWorkspaceYaml(validYaml) as CanonicalWorkspace; - return { - ...workspace, - workspace: { ...workspace.workspace, id, name: id, ...changes }, - semantic_index: { - ...workspace.semantic_index, - vector_store: { ...workspace.semantic_index.vector_store, collection: id }, - }, - }; -} - -function filesystemWorkspace(id: string): CanonicalWorkspace { - return parseWorkspaceYaml(withFilesystemEvidence( - validYaml - .replace("id: psd-clinical", `id: ${id}`) - .replace("name: Policlinico San Donato", `name: ${id}`) - .replace("collection: psd-clinical", `collection: ${id}`), - id, - )) as CanonicalWorkspace; -} - -async function checkoutStatus(checkout: string): Promise<{ porcelain: string; divergence: string }> { - return { - porcelain: await gitOutput(checkout, ["status", "--porcelain"]), - divergence: await gitOutput(checkout, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]), - }; -} - async function pushInvalidWorkspace(source: string): Promise { writeFileSync(join(source, "psd-clinical", "workspace.yaml"), "workspace: invalid\n"); await git(source, ["add", "psd-clinical/workspace.yaml"]); @@ -373,38 +338,12 @@ function persistedState(root: string, commit: string): { active: any; manifest: }; } -test("allows bootstrap creation from a catalog-only base and refuses later curator-owned writes", async () => { +test("rejects a catalog entry without a descriptor instead of creating a bootstrap slot", async () => { const remote = await contentOnlyFixture(); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); - await expect(registry.bootstrap()).resolves.toMatchObject({ head: remote.initialCommit }); - await expect(registry.list()).resolves.toEqual([]); - await expect(registry.listCatalog()).resolves.toEqual([ - expect.objectContaining({ id: "p1-filesystem", configurationState: "configuration_required" }), - ]); - - const created = await registry.publish({ - action: "create", - workspace: filesystemWorkspace("p1-filesystem"), - baseCommit: remote.initialCommit, - }); - expect(created).toMatchObject({ id: "p1-filesystem" }); - await expect(registry.list()).resolves.toEqual([ - expect.objectContaining({ id: "p1-filesystem", commit: created!.commit }), - ]); - await expect(registry.listCatalog()).resolves.toEqual([ - expect.objectContaining({ id: "p1-filesystem", configurationState: "ready", revision: created }), - ]); - - await expect(registry.publish({ - action: "delete", - id: "p1-filesystem", - baseCommit: created!.commit, - baseBlob: created!.blob, - })).rejects.toMatchObject({ code: "workspace_curator_owned" }); - await expect(registry.list()).resolves.toEqual([ - expect.objectContaining({ id: "p1-filesystem" }), - ]); + await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" }); + expect(existsSync(join(remote.root, "registry", "state", "active.json"))).toBe(false); }); test("bootstraps a checkout and activates a validated immutable snapshot", async () => { const remote = await fixture(); @@ -436,45 +375,6 @@ test("concurrent first lists lazily bootstrap a clean registry once safely", asy expect(existsSync(join(root, "state", "active.json"))).toBe(true); }); -test("publishes a filesystem descriptor only when its Evidence tree exists in the pulled base", async () => { - const remote = await fixture(withFilesystemEvidence(validYaml)); - const root = join(remote.root, "registry"); - const registry = new WorkspaceRegistry(config(root, remote.remote)); - await registry.bootstrap(); - const evidencePath = "research/evidence"; - const initialTree = await gitOutput(remote.root, [ - "--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`, - ]); - - const created = await registry.publish({ - action: "create", - workspace: filesystemWorkspace("research"), - baseCommit: remote.initialCommit, - }); - - expect(created?.commit).not.toBe(remote.initialCommit); - await expect(runFile("git", [ - "--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:research/workspace.yaml`, - ], { cwd: remote.root })).resolves.toBeDefined(); - await expect(runFile("git", [ - "--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:${evidencePath}/guide.md`, - ], { cwd: remote.root })).resolves.toBeDefined(); - expect(await gitOutput(remote.root, [ - "--git-dir", remote.remote, "rev-parse", `${created!.commit}:${evidencePath}`, - ])).toBe(initialTree); - - const remoteHeadBeforeMissing = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]); - await expect(registry.publish({ - action: "create", - workspace: filesystemWorkspace("missing-tree"), - baseCommit: created!.commit, - })).rejects.toMatchObject({ code: "workspace_invalid" }); - expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe( - remoteHeadBeforeMissing, - ); - expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" }); -}); - test.each(["missing", "blob"])( "rejects a remote filesystem descriptor with a %s Evidence root and keeps the active snapshot", async (invalidKind) => { @@ -549,27 +449,6 @@ test("creates an immutable descriptor revision for a content-only Evidence commi ], { cwd: remote.root })).resolves.toBeDefined(); }); -test("rejects a stale API update after a content-only Evidence commit", async () => { - const remote = await fixture(withFilesystemEvidence(validYaml)); - const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); - await registry.bootstrap(); - const initial = await registry.read("psd-clinical"); - const guide = join(remote.source, "psd-clinical", "evidence", "guide.md"); - writeFileSync(guide, "curator content\n"); - await git(remote.source, ["add", "psd-clinical/evidence/guide.md"]); - await git(remote.source, ["commit", "-m", "Curator Evidence update"]); - await git(remote.source, ["push", "origin", "main"]); - const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); - - await expect(registry.publish({ - action: "update", - workspace: filesystemWorkspace("psd-clinical"), - baseCommit: initial.revision.commit, - baseBlob: initial.revision.blob, - })).rejects.toMatchObject({ code: "workspace_curator_owned" }); - expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit); -}); - test("keeps content-only historical descriptor revisions distinguishable by commit", async () => { const remote = await fixture(withFilesystemEvidence(validYaml)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); @@ -593,72 +472,6 @@ test("keeps content-only historical descriptor revisions distinguishable by comm expect(oldPinned.workspace).toEqual(newPinned.workspace); }); -test("publishes one bootstrap descriptor with the configured Git author identity and refuses curator-owned mutation", async () => { - const remote = await fixture(); - const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, { - gitAuthorName: "Configured Workspace Publisher", - gitAuthorEmail: "publisher@example.invalid", - })); - await registry.bootstrap(); - const createdWorkspace = workspaceWith("research"); - - const created = await registry.publish({ - action: "create", - workspace: createdWorkspace, - baseCommit: remote.initialCommit, - }); - - expect(created).toMatchObject({ id: "research", commit: expect.stringMatching(/^[0-9a-f]{40}$/) }); - expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "log", "-1", "--format=%an <%ae>"])).toBe( - "Configured Workspace Publisher ", - ); - await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspace-docs/research/README.md"], { - cwd: remote.root, - })).resolves.toBeDefined(); - - const before = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]); - await expect(registry.publish({ - action: "update", - workspace: workspaceWith("research", { name: "Research", description: "Updated workspace description" }), - baseCommit: created!.commit, - baseBlob: created!.blob, - })).rejects.toMatchObject({ code: "workspace_curator_owned" }); - await expect(registry.publish({ - action: "delete", - id: "research", - baseCommit: created!.commit, - baseBlob: created!.blob, - })).rejects.toMatchObject({ code: "workspace_curator_owned" }); - expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(before); - await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:research/workspace.yaml"], { - cwd: remote.root, - })).resolves.toBeDefined(); -}); -test("rejects curator-owned update after a curator push and leaves the active snapshot intact", async () => { - const remote = await fixture(); - const root = join(remote.root, "registry"); - const registry = new WorkspaceRegistry(config(root, remote.remote)); - await registry.bootstrap(); - const initial = await registry.read("psd-clinical"); - writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace( - "schema: datawarehouse", "schema: analytics", - )); - await git(remote.source, ["add", "psd-clinical/workspace.yaml"]); - await git(remote.source, ["commit", "-m", "Change dwh schema"]); - await git(remote.source, ["push", "origin", "main"]); - const actualCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); - - await registry.pull(); - await expect(registry.publish({ - action: "update", - workspace: workspaceWith("psd-clinical", { description: "Local stale change" }), - baseCommit: initial.revision.commit, - baseBlob: initial.revision.blob, - })).rejects.toMatchObject({ code: "workspace_curator_owned" }); - await expect(registry.read("psd-clinical")).resolves.toMatchObject({ - revision: { commit: actualCommit }, - }); -}); test.each([ ["adds", validYaml, withDwhRestDiagnostic(validYaml)], ["removes", withDwhRestDiagnostic(validYaml), validYaml], @@ -675,56 +488,7 @@ test.each([ await registry.pull(); const updated = await registry.read("psd-clinical"); expect(updated.revision.commit).not.toBe(initial.revision.commit); - await expect(registry.publish({ - action: "update", - workspace: workspaceWith("psd-clinical", { description: "Local stale change" }), - baseCommit: initial.revision.commit, - baseBlob: initial.revision.blob, - })).rejects.toMatchObject({ code: "workspace_curator_owned" }); }); -test("restores a clean checkout after a failed commit and retries publication", async () => { - const remote = await fixture(); - const root = join(remote.root, "registry"); - const registry = new WorkspaceRegistry(config(root, remote.remote)); - await registry.bootstrap(); - const objects = join(root, "repo", ".git", "objects"); - chmodSync(objects, 0o500); - const request = { - action: "create" as const, - workspace: workspaceWith("research"), - baseCommit: remote.initialCommit, - }; - - try { - await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_unavailable" }); - } finally { - chmodSync(objects, 0o700); - } - expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" }); - await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit }); - await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" }); -}); - -test("resets an ahead checkout after a rejected push and retries publication", async () => { - const remote = await fixture(); - const root = join(remote.root, "registry"); - const registry = new WorkspaceRegistry(config(root, remote.remote)); - await registry.bootstrap(); - const hook = join(remote.remote, "hooks", "pre-receive"); - writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); - const request = { - action: "create" as const, - workspace: workspaceWith("research"), - baseCommit: remote.initialCommit, - }; - - await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_push_rejected" }); - expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" }); - rmSync(hook); - await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit }); - await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" }); -}); - test.each([ ["v1", legacyV1Yaml()], ["v2", legacyV2Yaml()], @@ -752,16 +516,6 @@ test("writes only state-free revisions and never exposes revision state", async const read = await registry.read("psd-clinical"); expect(listed[0]).not.toHaveProperty("state"); expect(read.revision).not.toHaveProperty("state"); - - const published = await registry.publish({ - action: "create", - workspace: workspaceWith("research"), - baseCommit: remote.initialCommit, - }); - const updated = persistedState(root, published!.commit); - expect(updated.active.revisions[0]).not.toHaveProperty("state"); - expect(updated.manifest.revisions[0]).not.toHaveProperty("state"); - expect(published).not.toHaveProperty("state"); }); test("accepts historical operational state without leaking it or rewriting the immutable snapshot", async () => { @@ -851,7 +605,7 @@ test("normalizes operational state in retained historical snapshots without rewr "name: Policlinico San Donato", "name: Current workspace", )); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ - { id: "psd-clinical", name: "Current workspace" }, { id: "research", name: "Research" }, + { id: "psd-clinical", name: "Current workspace" }, ])); await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Update active workspace"]); @@ -966,7 +720,7 @@ test("retains a historical snapshot while a resumable manifest still references "name: Policlinico San Donato", "name: Updated Policlinico San Donato", )); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ - { id: "psd-clinical", name: "Updated Policlinico San Donato" }, { id: "research", name: "Research" }, + { id: "psd-clinical", name: "Updated Policlinico San Donato" }, ])); await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Update workspace"]); @@ -994,7 +748,7 @@ test("a session revision lease survives stale retention scans until its manifest "name: Policlinico San Donato", "name: Concurrent revision", )); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ - { id: "psd-clinical", name: "Concurrent revision" }, { id: "research", name: "Research" }, + { id: "psd-clinical", name: "Concurrent revision" }, ])); await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Publish while session is starting"]); @@ -1024,7 +778,7 @@ test("lists operational descriptors retained after their workspace was removed f "id: psd-clinical", "id: archive-only", ).replace("collection: psd-clinical", "collection: archive-only")); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ - { id: "psd-clinical", name: "Policlinico San Donato" }, { id: "research", name: "Research" }, + { id: "psd-clinical", name: "Policlinico San Donato" }, { id: "archive-only", name: "Policlinico San Donato" }, ])); await git(remote.source, ["add", "-A"]); @@ -1033,6 +787,9 @@ test("lists operational descriptors retained after their workspace was removed f await registry.pull(); rmSync(join(remote.source, "psd-clinical", "workspace.yaml")); + writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ + { id: "archive-only", name: "Policlinico San Donato" }, + ])); await git(remote.source, ["add", "-u"]); await git(remote.source, ["commit", "-m", "Remove original workspace"]); await git(remote.source, ["push", "origin", "main"]); @@ -1249,18 +1006,6 @@ test("never copies an installation secret canary into Git, generated artifacts, for (const name of readdirSync(snapshotDirectory)) { expect(readFileSync(join(snapshotDirectory, name), "utf8")).not.toContain(canary); } - let thrown: unknown; - try { - await registry.publish({ - action: "create", - workspace: filesystemWorkspace("missing-secret-canary-tree"), - baseCommit: status.head!, - }); - } catch (error) { - thrown = error; - } - expect(thrown).toMatchObject({ code: "workspace_invalid" }); - expect(String(thrown)).not.toContain(canary); } finally { if (previous === undefined) delete process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE; else process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = previous; diff --git a/backend/test/workspaces-git-repository.test.ts b/backend/test/workspaces-git-repository.test.ts index e4776dd4..696ae52f 100644 --- a/backend/test/workspaces-git-repository.test.ts +++ b/backend/test/workspaces-git-repository.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +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"; @@ -92,6 +92,16 @@ function config(root: string, remoteUrl: string): WorkspaceRegistryConfig { }; } +test("does not expose repository mutation or publication operations", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + + expect(repository).not.toHaveProperty("createRegistryFile"); + expect(repository).not.toHaveProperty("writeRegistryFile"); + expect(repository).not.toHaveProperty("removeRegistryFile"); + expect(repository).not.toHaveProperty("commitAndPush"); +}); + 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)); @@ -300,19 +310,3 @@ test("parallel contenders recover a stale lock file without overlapping critical expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); expect(maximum).toBe(1); }); - - -test("creates a descriptor only when the exact curator path is absent", async () => { - const fixture = await temporaryRemote(); - const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); - await repository.bootstrap(); - const descriptorPath = "new-workspace/workspace.yaml"; - const descriptor = "curator descriptor\n"; - await expect(repository.createRegistryFile(descriptorPath, descriptor)).resolves.toBeUndefined(); - expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor); - await expect(repository.createRegistryFile(descriptorPath, "overwrite\n")) - .rejects.toMatchObject({ code: "workspace_curator_owned" }); - expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor); - await expect(repository.createRegistryFile("workspace-docs/workspace.yaml", descriptor)) - .rejects.toMatchObject({ code: "workspace_invalid" }); -});