From 7ce25894a21543992dafe56a88d19590e09349ce Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 11 Aug 2026 16:19:10 +0200 Subject: [PATCH] feat: reconcile generated docs on explicit registry pull --- backend/src/workspaces/git-repository.ts | 39 +++++++++++++++++ backend/src/workspaces/registry.ts | 56 +++++++++++++++++++++++- backend/test/routes-workspaces.test.ts | 9 +++- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts index 161adcca..f2ef6abb 100644 --- a/backend/src/workspaces/git-repository.ts +++ b/backend/src/workspaces/git-repository.ts @@ -178,6 +178,45 @@ export class GitWorkspaceRepository { return (await this.git(["rev-parse", `${revision}:${path}`])).trim(); } + /** Read-only object type at an exact revision, or undefined when absent. */ + async gitObjectType(revision: string, path: string): Promise { + if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); + } + if (!/^[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); + } + return await this.gitOptional(["cat-file", "-t", `${revision}:${path}`]); + } + + /** Read a generated-doc blob at an exact revision, or undefined when absent. */ + async readObjectOrAbsent(revision: string, path: string): Promise { + if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); + } + if (!/^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); + } + const output = await this.gitOptional(["show", `${revision}:${path}`]); + return output === undefined ? undefined : output; + } + + /** List committed generated-doc paths at an exact revision. */ + async workspaceDocsPaths(revision: string): Promise { + if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); + } + const output = await this.git(["ls-tree", "-r", "--name-only", revision, "--", "workspace-docs"]); + if (output.trim() === "") return []; + const paths = output.trim().split("\n"); + for (const path of paths) { + if (!/^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid docs path"); + } + } + return paths; + } + /** Assert that a canonical Evidence root is a Git tree at an exact commit. */ async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision) diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index d9e05fc7..49950419 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -136,14 +136,66 @@ export class WorkspaceRegistry { return await this.lock.run(async () => { try { const status = await this.repository.pull(); - await this.activate(status.head!); - return status; + const head = await this.reconcileGeneratedDocs(status.head!); + await this.activate(head); + return head === status.head ? status : { ...status, head }; } 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 path.startsWith("workspace-docs/"))).toBe(true); expect(list.statusCode).toBe(200); const summary = list.json().find(({ id }: { id: string }) => id === "research-clinical"); expect(summary.configurationState).toBe("ready"); - expect(summary.revision.commit).toBe(remoteCommit); + expect(summary.revision.commit).toBe(pulledHead); expect(read.statusCode).toBe(200); expect(read.json().workspace).toEqual(remotelyEdited); });