feat: reconcile generated docs on explicit registry pull

This commit is contained in:
2026-08-11 16:19:10 +02:00
parent f0a19a89eb
commit 7ce25894a2
3 changed files with 100 additions and 4 deletions
+39
View File
@@ -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<string | undefined> {
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<string | undefined> {
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<string[]> {
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<void> {
if (!/^[0-9a-f]{40}$/.test(revision)
+54 -2
View File
@@ -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<string> {
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<string, { envExample: string; markdown: string }>();
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<string>();
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<Array<WorkspaceCatalogEntry & {
configurationState: "ready" | "configuration_required";
revision?: WorkspaceRevision;
+7 -2
View File
@@ -646,11 +646,16 @@ test("real bootstrap create, curator push/pull, list, and read preserve a comple
expect(status.statusCode).toBe(200);
expect(create.statusCode).toBe(200);
expect(pull.statusCode).toBe(200);
expect(pull.json().head).toBe(remoteCommit);
// Explicit pull may produce a deterministic docs-only follow-up commit on top of the curator
// commit; the active descriptor must always be the curator's bytes.
const pulledHead = pull.json().head;
const pulledDiff = pulledHead === remoteCommit ? [] : (await realGit(join(fixture.registryRoot, "repo"), ["diff", "--name-only", `${remoteCommit}..${pulledHead}`])).split(/\s+/).filter(Boolean);
expect(pulledHead).toMatch(/^[0-9a-f]{40}$/);
expect(pulledDiff.every((path) => 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);
});