diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index 8e778ddb..c3386d56 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -66,6 +66,7 @@ const SAFE_MESSAGES = { workspace_not_activatable: "Workspace cannot be activated on this installation.", workspace_stale: "Workspace revision is stale.", workspace_conflict: "Workspace changed in the registry.", + workspace_curator_owned: "Workspace descriptor is owned by the curator and must be changed through Git.", git_unavailable: "Workspace Git service is unavailable.", git_auth_failed: "Workspace Git authentication failed.", git_non_fast_forward: "Workspace Git branch has changed.", @@ -225,7 +226,7 @@ function workspaceErrorCode(error: unknown): keyof typeof SAFE_MESSAGES { } function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number { - if (code === "workspace_conflict" || code === "workspace_stale" || code === "git_non_fast_forward") return 409; + if (code === "workspace_conflict" || code === "workspace_curator_owned" || code === "workspace_stale" || code === "git_non_fast_forward") return 409; if (code === "git_unavailable" || code === "git_auth_failed" || code === "git_push_rejected") return 503; return 400; } @@ -297,20 +298,16 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) app.get("/workspaces", async (_request, reply) => { try { - const revisions = await deps.registry.list(); - return await Promise.all(revisions.map(async (revision) => { - const { workspace } = await deps.registry.read(revision.id); - return { - id: revision.id, - // Retain the metadata endpoint's selector fields while adding registry summary data. - name: revision.id, - file: `${revision.id}.yaml`, - displayName: workspace.workspace.name, - description: workspace.workspace.description, - language: workspace.workspace.language, - workspace, - revision, - }; + const records = await deps.registry.listCatalog(); + return records.map((record) => ({ + id: record.id, + // Retain the metadata endpoint's selector field while adding catalog metadata. + name: record.id, + file: `${record.id}/workspace.yaml`, + displayName: record.name, + description: record.description, + configurationState: record.configurationState, + ...(record.revision ? { revision: record.revision } : {}), })); } catch (error) { return errorReply(reply, error); diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts index d2de0057..161adcca 100644 --- a/backend/src/workspaces/git-repository.ts +++ b/backend/src/workspaces/git-repository.ts @@ -129,19 +129,25 @@ export class GitWorkspaceRepository { }; } - async workspacePaths(): Promise { + async workspaceDirectories(): Promise { const output = await this.git(["ls-tree", "-d", "--name-only", "HEAD"]); const directories = output.trim() === "" ? [] : output.trim().split("\n"); - const paths: string[] = []; for (const id of directories) { if (id === "workspace-docs") continue; if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path"); } + } + return directories.filter((id) => id !== "workspace-docs").sort(); + } + + async workspacePaths(): Promise { + const paths: string[] = []; + for (const id of await this.workspaceDirectories()) { const path = `${id}/workspace.yaml`; - const type = (await this.git(["cat-file", "-t", `HEAD:${path}`], {}, - "Workspace descriptor is invalid")).trim(); - if (type !== "blob") { + const type = await this.gitOptional(["cat-file", "-t", `HEAD:${path}`]); + if (type === undefined) continue; + if (type.trim() !== "blob") { throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is invalid"); } paths.push(path); @@ -216,11 +222,14 @@ export class GitWorkspaceRepository { 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()); @@ -291,7 +300,19 @@ export class GitWorkspaceRepository { private async restoreFailedPublication(): Promise { try { await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]); - await this.git(["clean", "-fd", "--", "workspace-docs"]); + // 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. diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 2eda9570..d9e05fc7 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -3,6 +3,7 @@ import { lstatSync } from "node:fs"; import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js"; +import { assertCatalogMatchesDescriptor, parseWorkspaceCatalogYaml, type WorkspaceCatalog, type WorkspaceCatalogEntry } from "./catalog.js"; import { GitWorkspaceRepository, WorkspaceRegistryError, @@ -58,6 +59,7 @@ export class WorkspaceConflictError extends WorkspaceRegistryError { interface ActiveState { head: string; revisions: WorkspaceRevision[]; + catalog?: WorkspaceCatalog; } interface SnapshotManifest extends ActiveState { @@ -76,7 +78,7 @@ 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`; + return `${id}/workspace.yaml`; } function safeCommit(commit: string): string { @@ -113,7 +115,7 @@ export class WorkspaceRegistry { } snapshotPath(commit: string, id: string): string { - return join(this.repository.snapshotsPath, safeCommit(commit), `${workspacePath(id).slice("workspaces/".length)}`); + return join(this.repository.snapshotsPath, safeCommit(commit), `${id}.yaml`); } async bootstrap(): Promise { @@ -142,6 +144,26 @@ export class WorkspaceRegistry { }); } + async listCatalog(): Promise> { + const active = await this.tryActiveState(); + if (!active) { + await this.bootstrap(); + return await this.listCatalog(); + } + 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) } + : {}), + })); + } + async list(): Promise { const active = await this.tryActiveState(); if (active) return active.revisions; @@ -357,48 +379,49 @@ export class WorkspaceRegistry { 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.action === "delete" ? request.id : request.workspace.workspace.id; + const id = request.workspace.workspace.id; const existing = current.revisions.find((revision) => revision.id === id); - const local = request.action === "delete" ? undefined : request.workspace; - - if (request.baseCommit !== status.head || ( - request.action !== "create" && existing?.blob !== request.baseBlob - )) { - const contentOnlyStale = request.action !== "create" - && request.baseCommit !== status.head - && existing?.blob === request.baseBlob; - if (contentOnlyStale) { - throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale"); - } - throw await this.conflictFor(request, status.head!, existing, local); + if (existing) { + throw new WorkspaceRegistryError( + "workspace_curator_owned", + "Workspace descriptor is curator-owned and must be changed through Git", + ); } - if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local); - if (request.action !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local); - if (request.action !== "delete") { - await this.assertEvidenceContext(request.workspace, status.head!); + 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); - if (request.action === "delete") { - await this.repository.removeRegistryFile(yamlPath); - await this.repository.removeRegistryFile(docPaths.contract); - await this.repository.removeRegistryFile(docPaths.readme); - } else { - const canonical = request.workspace; - const source = serializeWorkspaceYaml(canonical); - const docs = renderWorkspaceDocs(canonical); - await this.repository.writeRegistryFile(yamlPath, source); - await this.repository.writeRegistryFile(docPaths.contract, docs.envExample); - await this.repository.writeRegistryFile(docPaths.readme, docs.markdown); - } + 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], - request.action === "delete" ? `Delete workspace ${id}` : `Publish workspace ${id}`, + `Publish workspace ${id}`, ); await this.activate(next.head!); return (await this.activeState()).revisions.find((revision) => revision.id === id); @@ -478,6 +501,13 @@ export class WorkspaceRegistry { private async activate(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])); + for (const id of await this.repository.workspaceDirectories()) { + if (!catalogById.has(id)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace directory is not listed in the catalog"); + } + } const files = await this.repository.workspacePaths(); const snapshots: Array<{ @@ -489,12 +519,15 @@ export class WorkspaceRegistry { const collectionOwners = new Map(); try { for (const path of files) { - const id = path.slice("workspaces/".length, -".yaml".length); - const source = await this.repository.readWorkspace(path); + const id = path.slice(0, -"/workspace.yaml".length); + const entry = catalogById.get(id); + if (!entry) throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is not listed in the catalog"); + const source = await this.repository.readWorkspace(path, safeHead); const workspace = parseWorkspaceYaml(source); if (workspace.workspace.id !== id) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path"); } + assertCatalogMatchesDescriptor(entry, workspace); await this.assertEvidenceContext(workspace, safeHead); const collection = workspace.semantic_index.vector_store.collection; const owner = collectionOwners.get(collection); @@ -508,7 +541,7 @@ export class WorkspaceRegistry { id, source: serializeWorkspaceYaml(workspace), workspace, - blob: await this.repository.blob(path), + blob: await this.repository.blob(path, safeHead), }); } } catch (error) { @@ -523,7 +556,7 @@ export class WorkspaceRegistry { snapshotPath: this.snapshotPath(safeHead, snapshot.id), })); if (this.pathExists(snapshotDirectory)) { - await this.assertSnapshotIntegrity({ head: safeHead, revisions }); + await this.assertSnapshotIntegrity({ head: safeHead, revisions, catalog }); } else { const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`); await mkdir(staging, { mode: 0o700 }); @@ -541,7 +574,7 @@ export class WorkspaceRegistry { files[envName] = digest(docs.envExample); files[docsName] = digest(docs.markdown); } - await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, files }), { + await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, catalog, files }), { encoding: "utf8", mode: 0o400, }); await rename(staging, snapshotDirectory); @@ -551,7 +584,7 @@ export class WorkspaceRegistry { } } - await this.writeActiveState({ head: safeHead, revisions }); + await this.writeActiveState({ head: safeHead, revisions, catalog }); } private async gitFallback(error: unknown): Promise { @@ -596,13 +629,15 @@ export class WorkspaceRegistry { } private decodeActiveState(value: unknown): ActiveState { - const state = this.strictObject(value, ["head", "revisions"]); - return this.decodeStateRevisions(state.head, state.revisions); + const record = this.optionalKeyObject(value, ["head", "revisions"], ["catalog"]); + const state = this.decodeStateRevisions(record.head, record.revisions); + return record.catalog === undefined ? state : { ...state, catalog: this.decodeCatalog(record.catalog) }; } private decodeSnapshotManifest(value: unknown): SnapshotManifest { - const manifest = this.strictObject(value, ["head", "revisions", "files"]); + const manifest = this.optionalKeyObject(value, ["head", "revisions", "files"], ["catalog"]); const state = this.decodeStateRevisions(manifest.head, manifest.revisions); + const catalog = manifest.catalog === undefined ? undefined : this.decodeCatalog(manifest.catalog); if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) { throw new Error("bad manifest files"); } @@ -610,7 +645,7 @@ export class WorkspaceRegistry { if (entries.some(([, contentsDigest]) => typeof contentsDigest !== "string")) { throw new Error("bad manifest files"); } - return { ...state, files: Object.fromEntries(entries) as Record }; + return { ...state, ...(catalog ? { catalog } : {}), files: Object.fromEntries(entries) as Record }; } private decodeStateRevisions(headValue: unknown, revisionsValue: unknown): ActiveState { @@ -664,6 +699,30 @@ export class WorkspaceRegistry { return { id, commit, blob, snapshotPath }; } + private decodeCatalog(value: unknown): WorkspaceCatalog { + if (typeof value !== "object" || value === null) throw new Error("bad catalog"); + return parseWorkspaceCatalogYaml(JSON.stringify(value)); + } + + private optionalKeyObject( + value: unknown, + requiredKeys: readonly string[], + optionalKeys: readonly string[], + ): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state"); + const record = value as Record; + const allowed = new Set([...requiredKeys, ...optionalKeys]); + const keys = Object.keys(record); + if ( + keys.length !== requiredKeys.length + optionalKeys.length + || !requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(record, key)) + || !keys.every((key) => allowed.has(key)) + ) { + throw new Error("bad state"); + } + return record; + } + private strictObject(value: unknown, expectedKeys: readonly string[]): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state"); const record = value as Record; @@ -692,8 +751,9 @@ export class WorkspaceRegistry { const directory = join(this.repository.snapshotsPath, state.head); try { const manifest = this.decodeSnapshotManifest(await this.readSnapshotManifest(state.head)); - if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) { - throw new Error("manifest revisions do not match active state"); + if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions) + || JSON.stringify(manifest.catalog ?? null) !== JSON.stringify(state.catalog ?? null)) { + throw new Error("manifest state does not match active state"); } await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state)); await this.assertSnapshotEvidenceContexts(state); diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 645a2d18..712b6f7f 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -127,6 +127,9 @@ function registryFake(overrides: Partial = {}): RegistryFake { branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false, })), list: vi.fn(async () => [revision]), + listCatalog: vi.fn(async () => [{ + id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision, + }]), read: vi.fn(async () => ({ workspace, revision })), publish: vi.fn(async () => revision), ...overrides, @@ -232,7 +235,7 @@ test("lists compatible workspace summaries and reads a validated workspace", asy expect(list.statusCode).toBe(200); expect(list.json()).toEqual([expect.objectContaining({ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "Policlinico San Donato", + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical/workspace.yaml", displayName: "Policlinico San Donato", configurationState: "ready", })]); expect(detail.statusCode).toBe(200); expect(detail.json()).toMatchObject({ workspace, revision }); @@ -455,7 +458,7 @@ function withEvidence( } const filesystemEvidenceWorkspace = withEvidence({ - type: "filesystem", uri: "workspace-content/psd-clinical/evidence", + type: "filesystem", uri: "psd-clinical/evidence", }); const httpEvidenceWorkspace = withEvidence({ type: "http", @@ -480,12 +483,21 @@ async function createRealRouteFixture( await realGit(author, ["init", "--initial-branch=main"]); await realGit(author, ["config", "user.name", "Workspace Route Test"]); await realGit(author, ["config", "user.email", "workspace-route@example.invalid"]); - mkdirSync(join(author, "workspaces")); - writeFileSync(join(author, "workspaces", "psd-clinical.yaml"), serializeWorkspaceYaml(initialWorkspace)); + const catalogName = initialWorkspace.workspace.name; + const catalogDescription = initialWorkspace.workspace.description; + writeFileSync(join(author, "thoth-workspaces.yaml"), [ + "schema_version: 1", + "workspaces:", + ` - id: psd-clinical\n name: ${catalogName}${catalogDescription ? `\n description: ${catalogDescription}` : ""}`, + ` - id: research-clinical\n name: Research Clinical${catalogDescription ? `\n description: ${catalogDescription}` : ""}`, + ` - id: missing-evidence\n name: Missing Evidence${catalogDescription ? `\n description: ${catalogDescription}` : ""}`, + ].join("\n") + "\n"); + mkdirSync(join(author, "psd-clinical"), { recursive: true }); + writeFileSync(join(author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(initialWorkspace)); if (initialWorkspace.evidence?.source.type === "filesystem") { - mkdirSync(join(author, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); + mkdirSync(join(author, "psd-clinical", "evidence"), { recursive: true }); writeFileSync( - join(author, "workspace-content", "psd-clinical", "evidence", "guide.md"), + join(author, "psd-clinical", "evidence", "guide.md"), EVIDENCE_FILE_BYTES, ); } @@ -545,7 +557,7 @@ afterEach(() => { test.each([ { - source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, + source: { type: "filesystem", uri: "psd-clinical/evidence" }, expectedVariables: [], }, { @@ -582,7 +594,7 @@ test.each([ .map(({ name }: { name: string }) => name)).toEqual(expectedVariables); }); -test("real publish create/update, pull, list, and read preserve a complete Evidence descriptor", async () => { +test("real bootstrap create, curator push/pull, list, and read preserve a complete Evidence descriptor", async () => { const fixture = await createRealRouteFixture(httpEvidenceWorkspace); const status = await fixture.app.inject({ method: "GET", url: "/workspace-registry/status" }); const created = validateWorkspaceDescriptor({ @@ -597,57 +609,52 @@ test("real publish create/update, pull, list, and read preserve a complete Evide method: "POST", url: "/workspaces/publish", payload: { action: "create", workspace: created, baseCommit: status.json().head }, }); + expect(create.statusCode).toBe(200); const createdRevision = create.json().revision as WorkspaceRevision; - const updated = validateWorkspaceDescriptor({ + + await realGit(fixture.author, ["pull", "--ff-only", "origin", "main"]); + const remotelyEdited = validateWorkspaceDescriptor({ ...created, evidence: { ...created.evidence, - policy: { max_chunk_chars: 8_192, retain_published_generations: 7 }, - }, - }); - - const update = await fixture.app.inject({ - method: "POST", url: "/workspaces/publish", - payload: { - action: "update", workspace: updated, - baseCommit: createdRevision.commit, baseBlob: createdRevision.blob, - }, - }); - expect(update.statusCode).toBe(200); - await realGit(fixture.author, ["pull", "--ff-only", "origin", "main"]); - const remotelyEdited = validateWorkspaceDescriptor({ - ...updated, - evidence: { - ...updated.evidence, policy: { max_chunk_chars: 9_001, retain_published_generations: 9 }, }, }); writeFileSync( - join(fixture.author, "workspaces", "research-clinical.yaml"), + join(fixture.author, "research-clinical", "workspace.yaml"), serializeWorkspaceYaml(remotelyEdited), ); - await realGit(fixture.author, ["add", "workspaces/research-clinical.yaml"]); + await realGit(fixture.author, ["add", "research-clinical/workspace.yaml"]); await realGit(fixture.author, ["commit", "-m", "Remote Evidence-only descriptor edit"]); await realGit(fixture.author, ["push", "origin", "main"]); const remoteCommit = await realGit(fixture.author, ["rev-parse", "HEAD"]); + const update = await fixture.app.inject({ + method: "POST", url: "/workspaces/publish", + payload: { + action: "update", workspace: remotelyEdited, + baseCommit: createdRevision.commit, baseBlob: createdRevision.blob, + }, + }); + expect(update.statusCode).toBe(409); + expect(update.json()).toMatchObject({ code: "workspace_curator_owned" }); + const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" }); const list = await fixture.app.inject({ method: "GET", url: "/workspaces" }); const read = await fixture.app.inject({ method: "GET", url: "/workspaces/research-clinical" }); expect(status.statusCode).toBe(200); expect(create.statusCode).toBe(200); - expect(update.statusCode).toBe(200); expect(pull.statusCode).toBe(200); expect(pull.json().head).toBe(remoteCommit); expect(list.statusCode).toBe(200); - expect(list.json().find(({ id }: { id: string }) => id === "research-clinical").workspace) - .toEqual(remotelyEdited); + const summary = list.json().find(({ id }: { id: string }) => id === "research-clinical"); + expect(summary.configurationState).toBe("ready"); + expect(summary.revision.commit).toBe(remoteCommit); expect(read.statusCode).toBe(200); expect(read.json().workspace).toEqual(remotelyEdited); }); - -test("real route reports a safe field for an Evidence-only concurrent edit", async () => { +test("real route refuses curator-owned updates with a safe 409 after an Evidence-only concurrent edit", async () => { const fixture = await createRealRouteFixture(httpEvidenceWorkspace); await fixture.registry.bootstrap(); const base = await fixture.registry.read("psd-clinical"); @@ -655,8 +662,8 @@ test("real route reports a safe field for an Evidence-only concurrent edit", asy { ...httpEvidenceWorkspace.evidence!.source }, { max_chunk_chars: 9_000, retain_published_generations: 3 }, ); - writeFileSync(join(fixture.author, "workspaces", "psd-clinical.yaml"), serializeWorkspaceYaml(remote)); - await realGit(fixture.author, ["add", "workspaces/psd-clinical.yaml"]); + writeFileSync(join(fixture.author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(remote)); + await realGit(fixture.author, ["add", "psd-clinical/workspace.yaml"]); await realGit(fixture.author, ["commit", "-m", "Change Evidence policy only"]); await realGit(fixture.author, ["push", "origin", "main"]); const local = withEvidence( @@ -673,16 +680,13 @@ test("real route reports a safe field for an Evidence-only concurrent edit", asy }); expect(response.statusCode).toBe(409); - expect(response.json()).toMatchObject({ - code: "workspace_conflict", fields: ["evidence.policy.max_chunk_chars"], - }); + expect(response.json()).toMatchObject({ code: "workspace_curator_owned" }); expect(response.body).not.toContain(SECRET_CANARY); }); - test.each([ ["absolute", "/tmp/CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], - ["traversal", "workspace-content/psd-clinical/../CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], - ["cross-workspace", "workspace-content/research/evidence"], + ["traversal", "psd-clinical/../CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], + ["cross-workspace", "research/evidence"], ])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => { const fixture = await createRealRouteFixture(); await fixture.registry.bootstrap(); @@ -747,7 +751,7 @@ test("real publish and pull fail safely when the contextual Evidence Git tree is ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "missing-evidence" }, }, - evidence: { source: { type: "filesystem", uri: "workspace-content/missing-evidence/evidence" } }, + evidence: { source: { type: "filesystem", uri: "missing-evidence/evidence" } }, }); const publish = await fixture.app.inject({ method: "POST", url: "/workspaces/publish", @@ -755,10 +759,11 @@ test("real publish and pull fail safely when the contextual Evidence Git tree is }); expect(publish.statusCode).toBe(400); expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); + expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) .toBe(fixture.initialCommit); - rmSync(join(fixture.author, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); + rmSync(join(fixture.author, "psd-clinical", "evidence"), { recursive: true }); await realGit(fixture.author, ["add", "-A"]); await realGit(fixture.author, ["commit", "-m", "Remove Evidence tree"]); await realGit(fixture.author, ["push", "origin", "main"]); diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 68ce1bb9..5d702c4a 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -42,7 +42,7 @@ function withFilesystemEvidence(source: string, id = "psd-clinical"): string { return source.concat(`evidence: source: type: filesystem - uri: workspace-content/${id}/evidence + uri: ${id}/evidence `); } @@ -177,6 +177,14 @@ async function gitOutput(cwd: string, args: string[]): Promise { return stdout.trim(); } +function catalogYaml(entries: Array<{ id: string; name: string; description?: string }>): string { + return `schema_version: 1 +workspaces: +${entries.map((entry) => ` - id: ${entry.id} + name: ${entry.name}${entry.description ? `\n description: ${entry.description}` : ""}`).join("\n")} +`; +} + async function fixture(workspaceSource = validYaml): Promise<{ root: string; remote: string; source: string; initialCommit: string; }> { @@ -189,16 +197,22 @@ async function fixture(workspaceSource = validYaml): Promise<{ 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"), workspaceSource); + const workspace = workspaceSource.includes("schema_version: 3") + ? parseWorkspaceYaml(workspaceSource) : undefined; + 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, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); - mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true }); - writeFileSync(join(source, "workspace-content", "psd-clinical", "evidence", "guide.md"), "guide v1\n"); - writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "research guide\n"); - await git(source, ["add", "workspaces", "workspace-content"]); + 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", "workspaces/psd-clinical.yaml"]); + await git(source, ["add", "thoth-workspaces.yaml", "psd-clinical/workspace.yaml"]); } await git(source, ["commit", "-m", "Initial workspace"]); await git(source, ["remote", "add", "origin", remote]); @@ -219,10 +233,11 @@ async function contentOnlyFixture(): Promise<{ 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"]); - const evidence = join(source, "workspace-content", "p1-filesystem", "evidence"); + const evidence = join(source, "p1-filesystem", "evidence"); mkdirSync(evidence, { recursive: true }); writeFileSync(join(evidence, "guide.md"), "curated content\n"); - await git(source, ["add", "workspace-content"]); + writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml([{ id: "p1-filesystem", name: "p1-filesystem" }])); + await git(source, ["add", "-A"]); await git(source, ["commit", "-m", "Bootstrap curated content"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); @@ -242,11 +257,16 @@ async function multiWorkspaceFixture(workspaces: Record): Promis 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")); + const entries = []; for (const [id, workspaceSource] of Object.entries(workspaces)) { - writeFileSync(join(source, "workspaces", `${id}.yaml`), workspaceSource); + const workspace = workspaceSource.includes("schema_version: 3") ? parseWorkspaceYaml(workspaceSource) : undefined; + entries.push({ id, name: workspace.workspace.name, ...(workspace.workspace.description ? { description: workspace.workspace.description } : {}) }); + mkdirSync(join(source, id), { recursive: true }); + writeFileSync(join(source, id, "workspace.yaml"), workspaceSource); + if (workspaceSource.includes("type: filesystem")) mkdirSync(join(source, id, "evidence"), { recursive: true }); } - await git(source, ["add", "workspaces"]); + writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml(entries)); + await git(source, ["add", "-A"]); await git(source, ["commit", "-m", "Initial workspaces"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); @@ -306,8 +326,8 @@ async function checkoutStatus(checkout: string): Promise<{ porcelain: string; di } async function pushInvalidWorkspace(source: string): Promise { - writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), "workspace: invalid\n"); - await git(source, ["add", "workspaces/psd-clinical.yaml"]); + writeFileSync(join(source, "psd-clinical", "workspace.yaml"), "workspace: invalid\n"); + await git(source, ["add", "psd-clinical/workspace.yaml"]); await git(source, ["commit", "-m", "Invalid workspace"]); await git(source, ["push", "origin", "main"]); } @@ -353,12 +373,15 @@ function persistedState(root: string, commit: string): { active: any; manifest: }; } -test("allows first API publication and delete-last from a content-only registry base", async () => { +test("allows bootstrap creation from a catalog-only base and refuses later curator-owned writes", 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", @@ -366,16 +389,23 @@ test("allows first API publication and delete-last from a content-only registry 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, - })).resolves.toBeUndefined(); - await expect(registry.list()).resolves.toEqual([]); + })).rejects.toMatchObject({ code: "workspace_curator_owned" }); + await expect(registry.list()).resolves.toEqual([ + expect.objectContaining({ id: "p1-filesystem" }), + ]); }); - 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)); @@ -411,7 +441,7 @@ test("publishes a filesystem descriptor only when its Evidence tree exists in th const root = join(remote.root, "registry"); const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); - const evidencePath = "workspace-content/research/evidence"; + const evidencePath = "research/evidence"; const initialTree = await gitOutput(remote.root, [ "--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`, ]); @@ -424,7 +454,7 @@ test("publishes a filesystem descriptor only when its Evidence tree exists in th expect(created?.commit).not.toBe(remote.initialCommit); await expect(runFile("git", [ - "--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:workspaces/research.yaml`, + "--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`, @@ -451,10 +481,10 @@ test.each(["missing", "blob"])( const remote = await fixture(withFilesystemEvidence(validYaml)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); await registry.bootstrap(); - const evidenceRoot = join(remote.source, "workspace-content", "psd-clinical", "evidence"); + const evidenceRoot = join(remote.source, "psd-clinical", "evidence"); rmSync(evidenceRoot, { recursive: true, force: true }); if (invalidKind === "blob") writeFileSync(evidenceRoot, "not a tree\n"); - await git(remote.source, ["add", "-A", "workspace-content/psd-clinical/evidence"]); + await git(remote.source, ["add", "-A", "psd-clinical/evidence"]); await git(remote.source, ["commit", "-m", `Make Evidence root ${invalidKind}`]); await git(remote.source, ["push", "origin", "main"]); const invalidCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); @@ -471,10 +501,10 @@ test("activation validates filesystem Evidence against its exact safeHead rather const remote = await fixture(withFilesystemEvidence(validYaml)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); await registry.bootstrap(); - rmSync(join(remote.source, "workspace-content", "psd-clinical", "evidence"), { + rmSync(join(remote.source, "psd-clinical", "evidence"), { recursive: true, force: true, }); - await git(remote.source, ["add", "-A", "workspace-content/psd-clinical/evidence"]); + await git(remote.source, ["add", "-A", "psd-clinical/evidence"]); await git(remote.source, ["commit", "-m", "Remove current Evidence root"]); await git(remote.source, ["push", "origin", "main"]); const invalidHead = await gitOutput(remote.source, ["rev-parse", "HEAD"]); @@ -496,7 +526,7 @@ test("creates an immutable descriptor revision for a content-only Evidence commi const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); const initial = await registry.read("psd-clinical"); - const evidencePath = "workspace-content/psd-clinical/evidence"; + const evidencePath = "psd-clinical/evidence"; const initialTree = await gitOutput(remote.source, ["rev-parse", `${remote.initialCommit}:${evidencePath}`]); writeFileSync(join(remote.source, evidencePath, "guide.md"), "guide v2\n"); await git(remote.source, ["add", `${evidencePath}/guide.md`]); @@ -524,9 +554,9 @@ test("rejects a stale API update after a content-only Evidence commit", async () 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, "workspace-content", "psd-clinical", "evidence", "guide.md"); + const guide = join(remote.source, "psd-clinical", "evidence", "guide.md"); writeFileSync(guide, "curator content\n"); - await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); + 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"]); @@ -536,7 +566,7 @@ test("rejects a stale API update after a content-only Evidence commit", async () workspace: filesystemWorkspace("psd-clinical"), baseCommit: initial.revision.commit, baseBlob: initial.revision.blob, - })).rejects.toMatchObject({ code: "workspace_stale" }); + })).rejects.toMatchObject({ code: "workspace_curator_owned" }); expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit); }); @@ -545,10 +575,10 @@ test("keeps content-only historical descriptor revisions distinguishable by comm const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); await registry.bootstrap(); writeFileSync( - join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md"), + join(remote.source, "psd-clinical", "evidence", "guide.md"), "historical content\n", ); - await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); + await git(remote.source, ["add", "psd-clinical/evidence/guide.md"]); await git(remote.source, ["commit", "-m", "Retained Evidence update"]); await git(remote.source, ["push", "origin", "main"]); const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); @@ -563,14 +593,14 @@ test("keeps content-only historical descriptor revisions distinguishable by comm expect(oldPinned.workspace).toEqual(newPinned.workspace); }); -test("publishes create, update, and delete with the configured Git author identity", async () => { +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-registry", { name: "Research registry" }); + const createdWorkspace = workspaceWith("research"); const created = await registry.publish({ action: "create", @@ -578,89 +608,80 @@ test("publishes create, update, and delete with the configured Git author identi baseCommit: remote.initialCommit, }); - expect(created).toMatchObject({ id: "research-registry", commit: expect.stringMatching(/^[0-9a-f]{40}$/) }); + 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-registry/README.md"], { + await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspace-docs/research/README.md"], { cwd: remote.root, })).resolves.toBeDefined(); - const updated = await registry.publish({ + const before = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]); + await expect(registry.publish({ action: "update", - workspace: workspaceWith("research-registry", { description: "Updated workspace description" }), + workspace: workspaceWith("research", { name: "Research", description: "Updated workspace description" }), baseCommit: created!.commit, baseBlob: created!.blob, - }); - - expect(updated).toMatchObject({ id: "research-registry" }); - expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "show", "HEAD:workspaces/research-registry.yaml"])).toContain( - "description: Updated workspace description", - ); - + })).rejects.toMatchObject({ code: "workspace_curator_owned" }); await expect(registry.publish({ action: "delete", - id: "research-registry", - baseCommit: updated!.commit, - baseBlob: updated!.blob, - })).resolves.toBeUndefined(); - await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspaces/research-registry.yaml"], { + 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, - })).rejects.toBeDefined(); + })).resolves.toBeDefined(); }); - -test("reports stale publish conflicts with expected and actual revisions", async () => { +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, "workspaces", "psd-clinical.yaml"), validYaml.replace( + writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace( "schema: datawarehouse", "schema: analytics", )); - await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); + 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"]); - const actualBlob = await gitOutput(remote.source, ["rev-parse", "HEAD:workspaces/psd-clinical.yaml"]); + 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_conflict", - fields: ["dwh.schema"], - expected: { commit: initial.revision.commit, blob: initial.revision.blob }, - actual: { commit: actualCommit, blob: actualBlob }, + })).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], -])("reports an optional diagnostics branch when the registry %s it", async (_operation, baseSource, remoteSource) => { +])("pulls a curator change that %s a diagnostics branch without API rewrite", async (_operation, baseSource, remoteSource) => { const remote = await fixture(baseSource); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); await registry.bootstrap(); const initial = await registry.read("psd-clinical"); - writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), remoteSource); - await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); + writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), remoteSource); + await git(remote.source, ["add", "psd-clinical/workspace.yaml"]); await git(remote.source, ["commit", "-m", `Registry ${_operation} diagnostic branch`]); await git(remote.source, ["push", "origin", "main"]); + 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_conflict", - fields: ["dwh.supported_transports", "diagnostics"], - }); + })).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"); @@ -670,7 +691,7 @@ test("restores a clean checkout after a failed commit and retries publication", chmodSync(objects, 0o500); const request = { action: "create" as const, - workspace: workspaceWith("commit-recovery"), + workspace: workspaceWith("research"), baseCommit: remote.initialCommit, }; @@ -681,7 +702,7 @@ test("restores a clean checkout after a failed commit and retries publication", } 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: "commit-recovery" }); + await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" }); }); test("resets an ahead checkout after a rejected push and retries publication", async () => { @@ -693,7 +714,7 @@ test("resets an ahead checkout after a rejected push and retries publication", a writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); const request = { action: "create" as const, - workspace: workspaceWith("push-recovery"), + workspace: workspaceWith("research"), baseCommit: remote.initialCommit, }; @@ -701,7 +722,7 @@ test("resets an ahead checkout after a rejected push and retries publication", a 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: "push-recovery" }); + await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" }); }); test.each([ @@ -722,8 +743,8 @@ test("writes only state-free revisions and never exposes revision state", async await registry.bootstrap(); const initial = persistedState(root, remote.initialCommit); - expect(Object.keys(initial.active).sort()).toEqual(["head", "revisions"]); - expect(Object.keys(initial.manifest).sort()).toEqual(["files", "head", "revisions"]); + expect(Object.keys(initial.active).sort()).toEqual(["catalog", "head", "revisions"]); + expect(Object.keys(initial.manifest).sort()).toEqual(["catalog", "files", "head", "revisions"]); expect(Object.keys(initial.active.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]); expect(Object.keys(initial.manifest.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]); @@ -733,10 +754,9 @@ test("writes only state-free revisions and never exposes revision state", async expect(read.revision).not.toHaveProperty("state"); const published = await registry.publish({ - action: "update", - workspace: workspaceWith("psd-clinical", { name: "State-free revision" }), + action: "create", + workspace: workspaceWith("research"), baseCommit: remote.initialCommit, - baseBlob: listed[0]!.blob, }); const updated = persistedState(root, published!.commit); expect(updated.active.revisions[0]).not.toHaveProperty("state"); @@ -827,10 +847,13 @@ test("normalizes operational state in retained historical snapshots without rewr const root = join(remote.root, "registry"); const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); - writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( + writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace( "name: Policlinico San Donato", "name: Current workspace", )); - await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); + writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ + { id: "psd-clinical", name: "Current workspace" }, { id: "research", name: "Research" }, + ])); + await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Update active workspace"]); await git(remote.source, ["push", "origin", "main"]); await registry.pull(); @@ -907,17 +930,17 @@ test("rejects duplicate schema v3 collection ownership and keeps the previous ac await registry.bootstrap(); writeFileSync( - join(remote.source, "workspaces", "research-clinical.yaml"), + join(remote.source, "research-clinical", "workspace.yaml"), v3Yaml .replace("id: psd-clinical", "id: research-clinical") .replace("name: Policlinico San Donato", "name: Research Clinical") .replace("collection: psd-clinical", "collection: shared"), ); writeFileSync( - join(remote.source, "workspaces", "psd-clinical.yaml"), + join(remote.source, "psd-clinical", "workspace.yaml"), v3Yaml.replace("collection: psd-clinical", "collection: shared"), ); - await git(remote.source, ["add", "workspaces"]); + await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Duplicate collection ownership"]); await git(remote.source, ["push", "origin", "main"]); @@ -939,10 +962,13 @@ test("retains a historical snapshot while a resumable manifest still references const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); - writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( + writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace( "name: Policlinico San Donato", "name: Updated Policlinico San Donato", )); - await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); + writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ + { id: "psd-clinical", name: "Updated Policlinico San Donato" }, { id: "research", name: "Research" }, + ])); + await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Update workspace"]); await git(remote.source, ["push", "origin", "main"]); const currentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); @@ -964,10 +990,13 @@ test("a session revision lease survives stale retention scans until its manifest await registry.bootstrap(); const lease = await registry.acquireSessionRevision("psd-clinical"); - writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( + writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace( "name: Policlinico San Donato", "name: Concurrent revision", )); - await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); + writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ + { id: "psd-clinical", name: "Concurrent revision" }, { id: "research", name: "Research" }, + ])); + await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Publish while session is starting"]); await git(remote.source, ["push", "origin", "main"]); await registry.pull(); @@ -990,15 +1019,20 @@ test("lists operational descriptors retained after their workspace was removed f const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); - writeFileSync(join(remote.source, "workspaces", "archive-only.yaml"), validYaml.replace( + mkdirSync(join(remote.source, "archive-only"), { recursive: true }); + writeFileSync(join(remote.source, "archive-only", "workspace.yaml"), validYaml.replace( "id: psd-clinical", "id: archive-only", ).replace("collection: psd-clinical", "collection: archive-only")); - await git(remote.source, ["add", "workspaces/archive-only.yaml"]); + writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([ + { id: "psd-clinical", name: "Policlinico San Donato" }, { id: "research", name: "Research" }, + { id: "archive-only", name: "Policlinico San Donato" }, + ])); + await git(remote.source, ["add", "-A"]); await git(remote.source, ["commit", "-m", "Add retained workspace"]); await git(remote.source, ["push", "origin", "main"]); await registry.pull(); - rmSync(join(remote.source, "workspaces", "psd-clinical.yaml")); + rmSync(join(remote.source, "psd-clinical", "workspace.yaml")); await git(remote.source, ["add", "-u"]); await git(remote.source, ["commit", "-m", "Remove original workspace"]); await git(remote.source, ["push", "origin", "main"]); @@ -1050,12 +1084,12 @@ test("rejects a locally-ahead checkout instead of activating local-only content" const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); const checkout = join(root, "repo"); - writeFileSync(join(checkout, "workspaces", "psd-clinical.yaml"), validYaml.replace( + writeFileSync(join(checkout, "psd-clinical", "workspace.yaml"), validYaml.replace( "name: Policlinico San Donato", "name: Local only workspace", )); await git(checkout, ["config", "user.name", "Workspace Registry Test"]); await git(checkout, ["config", "user.email", "workspace-registry@example.invalid"]); - await git(checkout, ["add", "workspaces/psd-clinical.yaml"]); + await git(checkout, ["add", "psd-clinical/workspace.yaml"]); await git(checkout, ["commit", "-m", "Local-only workspace"]); await expect(registry.pull()).rejects.toMatchObject({ code: "git_non_fast_forward" }); @@ -1143,10 +1177,10 @@ test("snapshots canonical Evidence artifacts at the active commit without copyin expect(status.head).toBe(remote.initialCommit); const committedDescriptor = parseWorkspaceYaml(await gitOutput(remote.source, [ - "show", `${remote.initialCommit}:workspaces/psd-clinical.yaml`, + "show", `${remote.initialCommit}:psd-clinical/workspace.yaml`, ])) as CanonicalWorkspace; const committedBlob = await gitOutput(remote.source, [ - "rev-parse", `${remote.initialCommit}:workspaces/psd-clinical.yaml`, + "rev-parse", `${remote.initialCommit}:psd-clinical/workspace.yaml`, ]); expect(active.revision.blob).toBe(committedBlob); expect(expectedFiles["psd-clinical.yaml"]).toBe(serializeWorkspaceYaml(committedDescriptor)); @@ -1164,7 +1198,7 @@ test("snapshots canonical Evidence artifacts at the active commit without copyin } expect(JSON.stringify(manifest)).not.toContain("workspace-content/"); expect(readdirSync(snapshotDirectory).some((name) => name === "workspace-content")).toBe(false); - expect(readFileSync(join(remote.source, "workspace-content/psd-clinical/evidence/guide.md"), "utf8")) + expect(readFileSync(join(remote.source, "psd-clinical/evidence/guide.md"), "utf8")) .toBe("guide v1\n"); }); diff --git a/backend/test/workspace-runtime-handoff.test.ts b/backend/test/workspace-runtime-handoff.test.ts index c252250d..dd9490ad 100644 --- a/backend/test/workspace-runtime-handoff.test.ts +++ b/backend/test/workspace-runtime-handoff.test.ts @@ -46,7 +46,7 @@ llm_policy: const filesystemWorkspace = `${canonicalWorkspace}evidence: source: type: filesystem - uri: workspace-content/psd-clinical/evidence + uri: psd-clinical/evidence `; function evidenceWorkspace(source: string, policy = ""): string { @@ -77,9 +77,10 @@ async function fixture(workspaceSource = filesystemWorkspace) { await git(source, ["init", "--initial-branch=main"]); await git(source, ["config", "user.name", "Runtime Handoff Test"]); await git(source, ["config", "user.email", "runtime-handoff@example.invalid"]); - mkdirSync(join(source, "workspaces")); - writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource); - const evidenceRoot = join(source, "workspace-content", "psd-clinical", "evidence"); + writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: psd-clinical, name: Runtime handoff}]\n"); + mkdirSync(join(source, "psd-clinical"), { recursive: true }); + writeFileSync(join(source, "psd-clinical", "workspace.yaml"), workspaceSource); + const evidenceRoot = join(source, "psd-clinical", "evidence"); mkdirSync(evidenceRoot, { recursive: true }); writeFileSync(join(evidenceRoot, "guide.md"), "# Immutable revision evidence\n"); await git(source, ["add", "."]); @@ -174,7 +175,6 @@ test("separate runtime leases hand off byte-identical revision Evidence configs f.registryConfig.root, "snapshots", f.revision.commit, - "workspace-content", "psd-clinical", "evidence", ); @@ -228,10 +228,10 @@ test("real Evidence-content-only commit changes runtime identity and root with i const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); const descriptorBefore = readFileSync(f.revision.snapshotPath, "utf8"); writeFileSync( - join(f.source, "workspace-content", "psd-clinical", "evidence", "guide.md"), + join(f.source, "psd-clinical", "evidence", "guide.md"), "# Content-only revision two\n", ); - await git(f.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); + await git(f.source, ["add", "psd-clinical/evidence/guide.md"]); await git(f.source, ["commit", "-m", "Update Evidence content only"]); await git(f.source, ["push", "origin", "main"]); await f.registry.pull(); @@ -250,7 +250,6 @@ test("real Evidence-content-only commit changes runtime identity and root with i f.registryConfig.root, "snapshots", current.commit, - "workspace-content", "psd-clinical", "evidence", )); diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts index bb1fce1b..6fc85ec3 100644 --- a/backend/test/workspace-runtime-renderer.test.ts +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -193,7 +193,7 @@ function evidenceRender( test("renders filesystem Evidence below the immutable revision content root with default policy", () => { const yaml = evidenceRender({ type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", }); const rendered = parse(yaml); @@ -201,7 +201,7 @@ test("renders filesystem Evidence below the immutable revision content root with expect(rendered.evidence).toEqual({ sources: [{ type: "filesystem", - root: `/srv/registry/snapshots/${evidenceRevision}/workspace-content/psd-clinical/evidence`, + root: `/srv/registry/snapshots/${evidenceRevision}/psd-clinical/evidence`, patterns: ["**/*.md"], max_bytes: 10_485_760, }], @@ -400,7 +400,7 @@ test.each([ test("is byte deterministic and revision-bound for descriptor-identical content-only commits", () => { const source = { type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", }; const first = evidenceRender(source); expect(evidenceRender(source)).toBe(first); @@ -424,7 +424,7 @@ test("is byte deterministic and revision-bound for descriptor-identical content- expect(next).not.toBe(first); expect(nextParsed.runtime_identity.workspace_revision).toBe(nextRevision); expect(nextParsed.evidence.sources[0].root).toBe( - `/srv/registry/snapshots/${nextRevision}/workspace-content/psd-clinical/evidence`, + `/srv/registry/snapshots/${nextRevision}/psd-clinical/evidence`, ); expect(nextParsed.evidence.sources[0].root).not.toBe(firstParsed.evidence.sources[0].root); }); diff --git a/backend/test/workspaces-bindings.test.ts b/backend/test/workspaces-bindings.test.ts index 0d665560..5b9209e8 100644 --- a/backend/test/workspaces-bindings.test.ts +++ b/backend/test/workspaces-bindings.test.ts @@ -164,7 +164,7 @@ evidence: const evidenceVariable = (suffix: string) => `THT_WS_PSD_CLINICAL_EVIDENCE_${suffix}`; test.each([ - { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, + { type: "filesystem", uri: "psd-clinical/evidence" }, { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "none" }, { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "ambient" }, ])("does not resolve Evidence variables for $type modes without file credentials", (source) => { diff --git a/backend/test/workspaces-contracts.test.ts b/backend/test/workspaces-contracts.test.ts index fffc4639..261a5a90 100644 --- a/backend/test/workspaces-contracts.test.ts +++ b/backend/test/workspaces-contracts.test.ts @@ -103,7 +103,7 @@ test.each([ }); test.each([ - { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, + { type: "filesystem", uri: "psd-clinical/evidence" }, { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "none" }, { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "ambient" }, ])("omits Evidence installation variables for $type modes without file credentials", (source) => { @@ -134,7 +134,7 @@ llm_policy: { allowed: [zai/glm-5.2] } const evidenceSources = [ { label: "filesystem", - source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, + source: { type: "filesystem", uri: "psd-clinical/evidence" }, variables: [], }, { @@ -207,11 +207,11 @@ test("documents the S3 session token file as optional", () => { test("documents same-revision filesystem ownership without claiming P1 materialization", () => { const descriptor = parseWorkspaceYaml( - `${renderWorkspaceWithoutEvidence()}evidence:\n source: { type: filesystem, uri: workspace-content/psd-clinical/evidence }\n`, + `${renderWorkspaceWithoutEvidence()}evidence:\n source: { type: filesystem, uri: psd-clinical/evidence }\n`, ); const docs = renderWorkspaceDocs(descriptor).markdown; - expect(docs).toContain("`workspace-content/psd-clinical/evidence`"); + expect(docs).toContain("`psd-clinical/evidence`"); expect(docs).toMatch(/same Git revision/i); expect(docs).toMatch(/P6.*materializ/i); expect(docs).toMatch(/containment.*symlink/i); diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index 5db97411..6dff5f87 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -297,7 +297,7 @@ const validEvidenceSources = [ name: "filesystem with explicit values", source: { type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", patterns: ["documents/**/*.pdf", "notes/*.md"], max_bytes: 12_000_000, }, @@ -374,14 +374,14 @@ test.each(validEvidenceSources)("accepts evidence source: $name", ({ source }) = test("applies filesystem and policy defaults to the canonical descriptor", () => { const parsed = validateWorkspaceDescriptor(withEvidence({ type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", })); expect(parsed).toMatchObject({ evidence: { source: { type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", patterns: ["**/*.md"], max_bytes: 10 * 1024 * 1024, }, @@ -397,7 +397,7 @@ test("keeps evidence optional on schema v3", () => { test("serializes defaulted evidence canonically and parses it without loss", () => { const canonical = validateWorkspaceDescriptor(withEvidence({ type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", })); if (canonical.workspace.schema_version !== 3) throw new Error("expected schema v3"); @@ -405,16 +405,16 @@ test("serializes defaulted evidence canonically and parses it without loss", () }); const invalidFilesystemPaths = [ - "/workspace-content/psd-clinical/evidence", - "workspace-content/../psd-clinical/evidence", - "workspace-content/./psd-clinical/evidence", - "workspace-content//psd-clinical/evidence", - "workspace-content/psd-clinical/evidence/..", - "workspace-content\\psd-clinical\\evidence", - "workspace-content/psd-clinical/evidence\u0000", - "workspace-content/other-workspace/evidence", - "workspace-content/psd-clinical", - "workspace-content/psd-clinical/evidence/nested", + "/psd-clinical/evidence", + "../psd-clinical/evidence", + "./psd-clinical/evidence", + "/psd-clinical/evidence", + "psd-clinical/evidence/..", + "\\psd-clinical\\evidence", + "psd-clinical/evidence\u0000", + "other-workspace/evidence", + "psd-clinical", + "psd-clinical/evidence/nested", ]; test.each(invalidFilesystemPaths)("rejects unsafe or noncanonical filesystem URI %#", (uri) => { @@ -426,27 +426,27 @@ const invalidPatterns = ["", "/absolute", "../escape", ".", "folder/./file", "fo test.each(invalidPatterns)("rejects unsafe evidence glob %#", (pattern) => { expectSafeEvidenceError(withEvidence({ type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", patterns: [pattern], }), /evidence.*source.*patterns/i); }); test("rejects empty and duplicate filesystem patterns", () => { - const source = { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }; + const source = { type: "filesystem", uri: "psd-clinical/evidence" }; expectSafeEvidenceError(withEvidence({ ...source, patterns: [] }), /patterns/i); expectSafeEvidenceError(withEvidence({ ...source, patterns: ["**/*.pdf", "**/*.pdf"] }), /patterns/i); }); test.each(["ftp", "git", "unknown"])("rejects unsupported evidence discriminator %s", (type) => { - expectSafeEvidenceError(withEvidence({ type, uri: "workspace-content/psd-clinical/evidence" }), /evidence.*source.*type/i); + expectSafeEvidenceError(withEvidence({ type, uri: "psd-clinical/evidence" }), /evidence.*source.*type/i); }); test("rejects unknown evidence keys", () => { expectSafeEvidenceError({ ...withEvidence({ type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", }), evidence: { - source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence", mystery: true }, + source: { type: "filesystem", uri: "psd-clinical/evidence", mystery: true }, policy: explicitPolicy, mystery: true, } }, /unrecognized|mystery/i); @@ -460,7 +460,7 @@ test.each(credentialFields)("rejects credential-shaped evidence field %s without const canary = `CANARY-${field}-DO-NOT-LEAK`; expectSafeEvidenceError(withEvidence({ type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", + uri: "psd-clinical/evidence", [field]: canary, }), /evidence.*source/i, canary); }); @@ -545,6 +545,6 @@ test.each([ ["retain_published_generations", Number.MAX_SAFE_INTEGER + 1], ] as const)("rejects invalid evidence policy bound %s=%s", (field, value) => { expectSafeEvidenceError(withEvidence({ - type: "filesystem", uri: "workspace-content/psd-clinical/evidence", + type: "filesystem", uri: "psd-clinical/evidence", }, { ...explicitPolicy, [field]: value }), new RegExp(field, "i")); });