feat: reconcile generated docs on explicit registry pull
This commit is contained in:
@@ -178,6 +178,45 @@ export class GitWorkspaceRepository {
|
|||||||
return (await this.git(["rev-parse", `${revision}:${path}`])).trim();
|
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. */
|
/** Assert that a canonical Evidence root is a Git tree at an exact commit. */
|
||||||
async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise<void> {
|
async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise<void> {
|
||||||
if (!/^[0-9a-f]{40}$/.test(revision)
|
if (!/^[0-9a-f]{40}$/.test(revision)
|
||||||
|
|||||||
@@ -136,14 +136,66 @@ export class WorkspaceRegistry {
|
|||||||
return await this.lock.run(async () => {
|
return await this.lock.run(async () => {
|
||||||
try {
|
try {
|
||||||
const status = await this.repository.pull();
|
const status = await this.repository.pull();
|
||||||
await this.activate(status.head!);
|
const head = await this.reconcileGeneratedDocs(status.head!);
|
||||||
return status;
|
await this.activate(head);
|
||||||
|
return head === status.head ? status : { ...status, head };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return await this.gitFallback(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 & {
|
async listCatalog(): Promise<Array<WorkspaceCatalogEntry & {
|
||||||
configurationState: "ready" | "configuration_required";
|
configurationState: "ready" | "configuration_required";
|
||||||
revision?: WorkspaceRevision;
|
revision?: WorkspaceRevision;
|
||||||
|
|||||||
@@ -646,11 +646,16 @@ test("real bootstrap create, curator push/pull, list, and read preserve a comple
|
|||||||
expect(status.statusCode).toBe(200);
|
expect(status.statusCode).toBe(200);
|
||||||
expect(create.statusCode).toBe(200);
|
expect(create.statusCode).toBe(200);
|
||||||
expect(pull.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);
|
expect(list.statusCode).toBe(200);
|
||||||
const summary = list.json().find(({ id }: { id: string }) => id === "research-clinical");
|
const summary = list.json().find(({ id }: { id: string }) => id === "research-clinical");
|
||||||
expect(summary.configurationState).toBe("ready");
|
expect(summary.configurationState).toBe("ready");
|
||||||
expect(summary.revision.commit).toBe(remoteCommit);
|
expect(summary.revision.commit).toBe(pulledHead);
|
||||||
expect(read.statusCode).toBe(200);
|
expect(read.statusCode).toBe(200);
|
||||||
expect(read.json().workspace).toEqual(remotelyEdited);
|
expect(read.json().workspace).toEqual(remotelyEdited);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user