feat: manage workspace Git checkout and snapshots
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
|
||||
import {
|
||||
GitWorkspaceRepository,
|
||||
WorkspaceRegistryError,
|
||||
WorkspaceRepositoryLock,
|
||||
type GitStatus,
|
||||
} from "./git-repository.js";
|
||||
import { parseWorkspaceYaml, serializeWorkspaceYaml, type CanonicalWorkspace } from "./schema.js";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
|
||||
export type { GitStatus } from "./git-repository.js";
|
||||
|
||||
export interface WorkspaceRevision {
|
||||
id: string;
|
||||
commit: string;
|
||||
blob: string;
|
||||
snapshotPath: string;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
interface ActiveState {
|
||||
head: string;
|
||||
revisions: WorkspaceRevision[];
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function safeCommit(commit: string): string {
|
||||
if (!/^[0-9a-f]{40}$/.test(commit)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid");
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
function workspaceError(error: unknown): WorkspaceRegistryError {
|
||||
if (error instanceof WorkspaceRegistryError) return error;
|
||||
return new WorkspaceRegistryError("workspace_invalid", "Workspace repository content is invalid");
|
||||
}
|
||||
|
||||
/** Immutable canonical workspace snapshots backed by the configured Git checkout. */
|
||||
export class WorkspaceRegistry {
|
||||
private readonly repository: GitWorkspaceRepository;
|
||||
private readonly lock: WorkspaceRepositoryLock;
|
||||
|
||||
constructor(private readonly config: WorkspaceRegistryConfig) {
|
||||
this.repository = new GitWorkspaceRepository(config);
|
||||
this.lock = new WorkspaceRepositoryLock(this.repository.locksPath);
|
||||
}
|
||||
|
||||
snapshotPath(commit: string, id: string): string {
|
||||
return join(this.repository.snapshotsPath, safeCommit(commit), `${workspacePath(id).slice("workspaces/".length)}`);
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<GitStatus> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
try {
|
||||
const status = await this.repository.bootstrap();
|
||||
await this.activate(status.head!);
|
||||
return status;
|
||||
} catch (error) {
|
||||
return await this.gitFallback(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async pull(): Promise<GitStatus> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
try {
|
||||
const status = await this.repository.pull();
|
||||
await this.activate(status.head!);
|
||||
return status;
|
||||
} catch (error) {
|
||||
return await this.gitFallback(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async list(): Promise<WorkspaceRevision[]> {
|
||||
return (await this.activeState()).revisions;
|
||||
}
|
||||
|
||||
async read(id: string): Promise<{ workspace: CanonicalWorkspace; revision: WorkspaceRevision }> {
|
||||
const state = await this.activeState();
|
||||
const revision = state.revisions.find((candidate) => candidate.id === id);
|
||||
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
try {
|
||||
const source = await readFile(revision.snapshotPath, "utf8");
|
||||
return { workspace: parseWorkspaceYaml(source), revision };
|
||||
} catch (error) {
|
||||
throw workspaceError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
|
||||
private async activate(commit: string): Promise<void> {
|
||||
const safeHead = safeCommit(commit);
|
||||
const files = await this.repository.workspacePaths();
|
||||
if (files.length === 0) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains no workspaces");
|
||||
}
|
||||
|
||||
const snapshots: Array<{ id: string; source: string; workspace: CanonicalWorkspace; blob: string }> = [];
|
||||
try {
|
||||
for (const path of files) {
|
||||
const id = path.slice("workspaces/".length, -".yaml".length);
|
||||
const source = await this.repository.readWorkspace(path);
|
||||
const workspace = parseWorkspaceYaml(source);
|
||||
if (workspace.workspace.id !== id) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
||||
}
|
||||
buildInstallationContract(workspace);
|
||||
renderWorkspaceDocs(workspace);
|
||||
snapshots.push({ id, source: serializeWorkspaceYaml(workspace), workspace, blob: await this.repository.blob(path) });
|
||||
}
|
||||
} catch (error) {
|
||||
throw workspaceError(error);
|
||||
}
|
||||
|
||||
const snapshotDirectory = join(this.repository.snapshotsPath, safeHead);
|
||||
if (!this.pathExists(snapshotDirectory)) {
|
||||
const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`);
|
||||
await mkdir(staging, { mode: 0o700 });
|
||||
try {
|
||||
const revisions: WorkspaceRevision[] = [];
|
||||
for (const snapshot of snapshots) {
|
||||
const path = join(staging, `${snapshot.id}.yaml`);
|
||||
const docs = renderWorkspaceDocs(snapshot.workspace);
|
||||
await writeFile(path, snapshot.source, { encoding: "utf8", mode: 0o400 });
|
||||
await writeFile(join(staging, `${snapshot.id}.env.example`), docs.envExample, { encoding: "utf8", mode: 0o400 });
|
||||
await writeFile(join(staging, `${snapshot.id}.md`), docs.markdown, { encoding: "utf8", mode: 0o400 });
|
||||
revisions.push({ id: snapshot.id, commit: safeHead, blob: snapshot.blob, snapshotPath: this.snapshotPath(safeHead, snapshot.id) });
|
||||
}
|
||||
await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions }), {
|
||||
encoding: "utf8", mode: 0o400,
|
||||
});
|
||||
await rename(staging, snapshotDirectory);
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const revisions = snapshots.map((snapshot) => ({
|
||||
id: snapshot.id,
|
||||
commit: safeHead,
|
||||
blob: snapshot.blob,
|
||||
snapshotPath: this.snapshotPath(safeHead, snapshot.id),
|
||||
}));
|
||||
await this.writeActiveState({ head: safeHead, revisions });
|
||||
}
|
||||
|
||||
private async gitFallback(error: unknown): Promise<GitStatus> {
|
||||
const safeError = workspaceError(error);
|
||||
if (safeError.code !== "git_unavailable" && safeError.code !== "git_auth_failed") throw safeError;
|
||||
const active = await this.tryActiveState();
|
||||
if (!active) throw safeError;
|
||||
return {
|
||||
branch: this.config.branch,
|
||||
head: active.head,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
degraded: true,
|
||||
lastError: safeError.code,
|
||||
};
|
||||
}
|
||||
|
||||
private async activeState(): Promise<ActiveState> {
|
||||
const active = await this.tryActiveState();
|
||||
if (!active) throw new WorkspaceRegistryError("workspace_invalid", "No active workspace snapshot is available");
|
||||
return active;
|
||||
}
|
||||
|
||||
private async tryActiveState(): Promise<ActiveState | undefined> {
|
||||
const file = join(this.repository.statePath, "active.json");
|
||||
try {
|
||||
const state = JSON.parse(await readFile(file, "utf8")) as ActiveState;
|
||||
safeCommit(state.head);
|
||||
if (!Array.isArray(state.revisions) || state.revisions.length === 0) throw new Error("bad state");
|
||||
for (const revision of state.revisions) {
|
||||
safeCommit(revision.commit);
|
||||
workspacePath(revision.id);
|
||||
if (!isAbsolute(revision.snapshotPath) || revision.snapshotPath !== this.snapshotPath(revision.commit, revision.id)) {
|
||||
throw new Error("bad snapshot path");
|
||||
}
|
||||
}
|
||||
return state;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeActiveState(state: ActiveState): Promise<void> {
|
||||
const target = join(this.repository.statePath, "active.json");
|
||||
const staging = join(this.repository.statePath, `.active-${randomUUID()}.json`);
|
||||
await writeFile(staging, JSON.stringify(state), { encoding: "utf8", mode: 0o600 });
|
||||
await rename(staging, target);
|
||||
}
|
||||
|
||||
private pathExists(path: string): boolean {
|
||||
try {
|
||||
const entry = lstatSync(path);
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot path is invalid");
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user