feat: activate workspaces from the root catalog and bootstrap-only publication
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user