refactor: make workspace repository strictly read only

This commit is contained in:
2026-08-14 16:20:08 +02:00
parent 3a50c447c3
commit 42e02f8b1c
4 changed files with 38 additions and 570 deletions
+2 -93
View File
@@ -1,7 +1,7 @@
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { lstatSync, mkdirSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, dirname, isAbsolute, join } from "node:path";
import { mkdir } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { promisify, TextDecoder } from "node:util";
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
@@ -387,55 +387,6 @@ export class GitWorkspaceRepository {
}
}
/** Write only a validated API-owned artifact below the checked-out repository. */
async writeRegistryFile(path: string, source: string): Promise<void> {
this.assertRegistryArtifactPath(path);
const target = join(this.repoPath, path);
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
await writeFile(target, source, { encoding: "utf8", mode: 0o600 });
}
/** Create a descriptor only when no filesystem entry exists at its exact path. */
async createRegistryFile(path: string, source: string): Promise<void> {
this.assertRegistryArtifactPath(path);
if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor path is invalid");
}
const target = join(this.repoPath, path);
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
try {
await writeFile(target, source, { encoding: "utf8", mode: 0o600, flag: "wx" });
} catch {
throw new WorkspaceRegistryError("workspace_curator_owned", "Workspace descriptor is curator-owned");
}
}
async removeRegistryFile(path: string): Promise<void> {
this.assertRegistryArtifactPath(path);
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());
await this.git(["push", "origin", `HEAD:${this.config.branch}`]);
return await this.status();
} catch (error) {
// A failed commit leaves staged/working changes; a failed push leaves an ahead commit.
// Restore the last fetched remote revision so the next refresh or explicit retry starts clean.
await this.restoreFailedPublication();
throw error;
}
}
private async clone(): Promise<void> {
try {
await execFileAsync("git", [
@@ -448,17 +399,6 @@ export class GitWorkspaceRepository {
}
}
private isRegistryArtifactPath(path: string): boolean {
return /^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)
|| /^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path);
}
private assertRegistryArtifactPath(path: string): void {
if (!this.isRegistryArtifactPath(path)) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
}
}
private async refresh(): Promise<void> {
if ((await this.git(["status", "--porcelain"])).trim() !== "") {
throw new WorkspaceRegistryError("workspace_stale", "Workspace checkout has local changes");
@@ -481,37 +421,6 @@ export class GitWorkspaceRepository {
}
}
private publicationIdentity(): NodeJS.ProcessEnv {
return {
GIT_AUTHOR_NAME: this.config.gitAuthorName,
GIT_AUTHOR_EMAIL: this.config.gitAuthorEmail,
GIT_COMMITTER_NAME: this.config.gitAuthorName,
GIT_COMMITTER_EMAIL: this.config.gitAuthorEmail,
};
}
private async restoreFailedPublication(): Promise<void> {
try {
await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]);
// 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.
}
}
private async git(
args: string[],
env: NodeJS.ProcessEnv = {},
+15 -195
View File
@@ -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;