refactor: make workspace repository strictly read only
This commit is contained in:
@@ -17,7 +17,6 @@ import {
|
||||
parseWorkspaceYaml,
|
||||
serializeWorkspaceYaml,
|
||||
validateOperationalWorkspace,
|
||||
type CanonicalWorkspace,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./schema.js";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
@@ -40,25 +39,6 @@ export interface SessionRevisionLease {
|
||||
abort(): Promise<void>;
|
||||
}
|
||||
|
||||
export type PublishWorkspaceRequest =
|
||||
| { action: "create"; workspace: CanonicalWorkspace; baseCommit: string }
|
||||
| { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string }
|
||||
| { action: "delete"; id: string; baseCommit: string; baseBlob: string };
|
||||
|
||||
export class WorkspaceConflictError extends WorkspaceRegistryError {
|
||||
constructor(
|
||||
readonly fields: string[],
|
||||
readonly expected: { commit: string; blob?: string },
|
||||
readonly actual: { commit: string; blob?: string },
|
||||
readonly base?: CanonicalWorkspace,
|
||||
readonly local?: CanonicalWorkspace,
|
||||
readonly remote?: CanonicalWorkspace,
|
||||
) {
|
||||
super("workspace_conflict", "Workspace revision conflicts with the active registry");
|
||||
this.name = "WorkspaceConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveState {
|
||||
head: string;
|
||||
revisions: WorkspaceRevision[];
|
||||
@@ -139,69 +119,17 @@ export class WorkspaceRegistry {
|
||||
return await this.lock.run(async () => {
|
||||
try {
|
||||
const status = await this.repository.pull();
|
||||
const head = await this.reconcileGeneratedDocs(status.head!);
|
||||
await this.activate(head);
|
||||
return head === status.head ? status : { ...status, head };
|
||||
await this.activate(status.head!);
|
||||
return status;
|
||||
} 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;
|
||||
configurationState: "ready";
|
||||
revision: WorkspaceRevision;
|
||||
}>> {
|
||||
const active = await this.tryActiveState();
|
||||
if (!active) {
|
||||
@@ -211,11 +139,8 @@ export class WorkspaceRegistry {
|
||||
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) }
|
||||
: {}),
|
||||
configurationState: "ready" as const,
|
||||
revision: active.revisions.find((revision) => revision.id === entry.id)!,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -427,120 +352,6 @@ export class WorkspaceRegistry {
|
||||
return leases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish canonical YAML and derived public documentation as one optimistic Git revision.
|
||||
* The browser never provides paths or generated artifacts; those are derived server-side.
|
||||
*/
|
||||
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.workspace.workspace.id;
|
||||
const existing = current.revisions.find((revision) => revision.id === id);
|
||||
if (existing) {
|
||||
throw new WorkspaceRegistryError(
|
||||
"workspace_curator_owned",
|
||||
"Workspace descriptor is curator-owned and must be changed through Git",
|
||||
);
|
||||
}
|
||||
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);
|
||||
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],
|
||||
`Publish workspace ${id}`,
|
||||
);
|
||||
await this.activate(next.head!);
|
||||
return (await this.activeState()).revisions.find((revision) => revision.id === id);
|
||||
});
|
||||
}
|
||||
|
||||
private documentationPaths(id: string): { contract: string; readme: string } {
|
||||
workspacePath(id);
|
||||
const directory = `workspace-docs/${id}`;
|
||||
return { contract: `${directory}/contract.env.example`, readme: `${directory}/README.md` };
|
||||
}
|
||||
|
||||
private async conflictFor(
|
||||
request: PublishWorkspaceRequest,
|
||||
currentCommit: string,
|
||||
existing: WorkspaceRevision | undefined,
|
||||
local: CanonicalWorkspace | undefined,
|
||||
): Promise<WorkspaceConflictError> {
|
||||
const id = request.action === "delete" ? request.id : request.workspace.workspace.id;
|
||||
const base = await this.readSnapshotCanonical(request.baseCommit, id);
|
||||
let remote: CanonicalWorkspace | undefined;
|
||||
if (existing) {
|
||||
remote = (await this.read(id)).workspace;
|
||||
}
|
||||
return new WorkspaceConflictError(
|
||||
this.changedFields(base, remote),
|
||||
{ commit: request.baseCommit, ...(request.action === "create" ? {} : { blob: request.baseBlob }) },
|
||||
{ commit: currentCommit, ...(existing ? { blob: existing.blob } : {}) },
|
||||
base,
|
||||
local,
|
||||
remote,
|
||||
);
|
||||
}
|
||||
|
||||
private async readSnapshotCanonical(commit: string, id: string): Promise<CanonicalWorkspace | undefined> {
|
||||
try {
|
||||
const source = await readFile(this.snapshotPath(commit, id), "utf8");
|
||||
return parseWorkspaceYaml(source);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private changedFields(
|
||||
base: unknown,
|
||||
remote: unknown,
|
||||
prefix = "",
|
||||
): string[] {
|
||||
if (base === undefined || remote === undefined) {
|
||||
return base === remote ? [] : [prefix || "workspace.id"];
|
||||
}
|
||||
if (Array.isArray(base) || Array.isArray(remote) || typeof base !== "object" || typeof remote !== "object") {
|
||||
return JSON.stringify(base) === JSON.stringify(remote) ? [] : [prefix];
|
||||
}
|
||||
const baseObject = base as Record<string, unknown>;
|
||||
const remoteObject = remote as Record<string, unknown>;
|
||||
const keys = new Set([...Object.keys(baseObject), ...Object.keys(remoteObject)]);
|
||||
return [...keys].flatMap((key) => this.changedFields(
|
||||
baseObject[key],
|
||||
remoteObject[key],
|
||||
prefix ? `${prefix}.${key}` : key,
|
||||
));
|
||||
}
|
||||
|
||||
private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise<void> {
|
||||
if (workspace.evidence?.source.type !== "filesystem") return;
|
||||
// P6 owns recursive containment. Here we deliberately validate only the declared root object.
|
||||
@@ -564,6 +375,15 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
const files = await this.repository.workspacePaths();
|
||||
const descriptorIds = new Set(files.map((path) => path.slice(0, -"/workspace.yaml".length)));
|
||||
for (const id of catalogById.keys()) {
|
||||
if (!descriptorIds.has(id)) {
|
||||
throw new WorkspaceRegistryError(
|
||||
"workspace_invalid",
|
||||
"Every catalog workspace must have a published descriptor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshots: Array<{
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user