feat: expose workspace registry API
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { lstatSync, mkdirSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { basename, isAbsolute, join } from "node:path";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
|
||||
@@ -154,6 +154,30 @@ export class GitWorkspaceRepository {
|
||||
return (await this.git(["rev-parse", `HEAD:${path}`])).trim();
|
||||
}
|
||||
|
||||
/** Write only a validated registry 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 });
|
||||
}
|
||||
|
||||
async removeRegistryFile(path: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
await rm(join(this.repoPath, path), { force: true });
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
await this.git(["add", "--", ...paths]);
|
||||
await this.git(["commit", "-m", message]);
|
||||
await this.git(["push", "origin", `HEAD:${this.config.branch}`]);
|
||||
return await this.status();
|
||||
}
|
||||
|
||||
private async clone(): Promise<void> {
|
||||
try {
|
||||
await execFileAsync("git", [
|
||||
@@ -166,6 +190,17 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private isRegistryArtifactPath(path: string): boolean {
|
||||
return /^workspaces\/[a-z][a-z0-9-]{2,62}\.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");
|
||||
|
||||
@@ -33,6 +33,18 @@ export type PublishWorkspaceRequest =
|
||||
| { 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 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,9 +151,100 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Publication is deliberately deferred until Task 6 adds validated route-level concurrency controls. */
|
||||
async publish(_request: PublishWorkspaceRequest): Promise<WorkspaceRevision> {
|
||||
throw new WorkspaceRegistryError("workspace_stale", "Workspace publication is unavailable");
|
||||
/**
|
||||
* 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 () => {
|
||||
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 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
|
||||
)) {
|
||||
throw await this.conflictFor(request, existing, local);
|
||||
}
|
||||
if (request.action === "create" && existing) throw await this.conflictFor(request, existing, local);
|
||||
if (request.action !== "create" && !existing) throw await this.conflictFor(request, existing, local);
|
||||
|
||||
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 next = await this.repository.commitAndPush(
|
||||
[yamlPath, docPaths.contract, docPaths.readme],
|
||||
request.action === "delete" ? `Delete workspace ${id}` : `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,
|
||||
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) {
|
||||
const read = await this.read(id);
|
||||
remote = isCanonicalWorkspace(read.workspace) ? read.workspace : undefined;
|
||||
}
|
||||
return new WorkspaceConflictError(this.changedFields(base, remote), base, local, remote);
|
||||
}
|
||||
|
||||
private async readSnapshotCanonical(commit: string, id: string): Promise<CanonicalWorkspace | undefined> {
|
||||
try {
|
||||
const source = await readFile(this.snapshotPath(commit, id), "utf8");
|
||||
const workspace = parseWorkspaceYaml(source);
|
||||
return isCanonicalWorkspace(workspace) ? workspace : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private changedFields(
|
||||
base: unknown,
|
||||
remote: unknown,
|
||||
prefix = "",
|
||||
): string[] {
|
||||
if (!base || !remote) return ["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 activate(commit: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user