feat: activate workspaces from the root catalog and bootstrap-only publication

This commit is contained in:
2026-08-11 14:49:46 +02:00
parent 86af45acb4
commit 25ec236f1f
10 changed files with 358 additions and 242 deletions
+27 -6
View File
@@ -129,19 +129,25 @@ export class GitWorkspaceRepository {
};
}
async workspacePaths(): Promise<string[]> {
async workspaceDirectories(): Promise<string[]> {
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<string[]> {
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<GitStatus> {
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<void> {
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.
+104 -44
View File
@@ -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<GitStatus> {
@@ -142,6 +144,26 @@ export class WorkspaceRegistry {
});
}
async listCatalog(): Promise<Array<WorkspaceCatalogEntry & {
configurationState: "ready" | "configuration_required";
revision?: WorkspaceRevision;
}>> {
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<WorkspaceRevision[]> {
const active = await this.tryActiveState();
if (active) return active.revisions;
@@ -357,48 +379,49 @@ export class WorkspaceRegistry {
async publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> {
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<void> {
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<string, string>();
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<GitStatus> {
@@ -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<string, string> };
return { ...state, ...(catalog ? { catalog } : {}), files: Object.fromEntries(entries) as Record<string, string> };
}
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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state");
const record = value as Record<string, unknown>;
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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state");
const record = value as Record<string, unknown>;
@@ -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);