942 lines
51 KiB
TypeScript
942 lines
51 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { lstatSync, mkdirSync } from "node:fs";
|
|
import { mkdir, open as openFile, readdir, 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,
|
|
validateOperationalWorkspace,
|
|
type CanonicalWorkspace,
|
|
type WorkspaceDescriptor,
|
|
} from "./schema.js";
|
|
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
|
import { VerifiedWorkspaceLockRootLeaseFactory, type Revision40, type CanonicalWorkspaceId } from "./workspace-lock-root-lease.js";
|
|
import { WorkspaceFsAtV1 } from "./workspace-fs-at.js";
|
|
import { runUnderOrderedWorkspaceWriterLocks, type OrderedWorkspaceWriterCapabilitySet } from "./preprocessing-state.js";
|
|
import { RegistryAddressedPublicationStore, addressedRunId, canonicalBootstrapRequestDigest, registryDigest, CapabilityAwareRegistryPublicationLifecycleOwner, type CapabilityAwareRegistryPublicationParticipant, type CapabilityAwareRegistryPublicationSynchronizer, type RegistryAddressedPlanV1, type RegistryPullAddressedPlanV1, type RegistryAddressedRequestV1, type RegistryAddressedResultV1, type RegistryBootstrapRecoveryIdentityV1, type RegistryEnsureBootstrapAddressedResultV1, type RegistryActiveSnapshotV1, type RegistryWorkspaceManifestIdentityV1, type RegistryAddressedPublicationStateV1 } from "./registry-publication.js";
|
|
export type { GitStatus } from "./git-repository.js";
|
|
|
|
export interface WorkspaceRevision {
|
|
id: string;
|
|
commit: string;
|
|
blob: string;
|
|
snapshotPath: string;
|
|
}
|
|
|
|
export interface SessionRevisionLease {
|
|
workspace: WorkspaceDescriptor;
|
|
revision: WorkspaceRevision;
|
|
/** Mark the manifest durable; retention removes the lease only after observing that manifest. */
|
|
markPersisted(): Promise<void>;
|
|
/** Remove a lease for a session that failed before its manifest was durable. */
|
|
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[];
|
|
}
|
|
|
|
interface SnapshotManifest extends ActiveState {
|
|
files: Record<string, string>;
|
|
}
|
|
|
|
interface RevisionLeaseRecord {
|
|
version: 1;
|
|
token: string;
|
|
workspaceId: string;
|
|
commit: string;
|
|
state: "creating" | "persisted";
|
|
}
|
|
|
|
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 safeBlob(blob: string): string {
|
|
if (!/^[0-9a-f]{40}$/.test(blob)) {
|
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot blob is invalid");
|
|
}
|
|
return blob;
|
|
}
|
|
|
|
function digest(contents: unknown): string {
|
|
return createHash("sha256").update(typeof contents === "string" || Buffer.isBuffer(contents) ? contents : JSON.stringify(contents)).digest("hex");
|
|
}
|
|
|
|
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 interface WorkspaceRegistryDependencies {
|
|
readonly rootLeaseFactory: VerifiedWorkspaceLockRootLeaseFactory;
|
|
readonly lifecycleOwner: CapabilityAwareRegistryPublicationLifecycleOwner;
|
|
readonly participants: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[];
|
|
readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[];
|
|
readonly repository?: GitWorkspaceRepository;
|
|
readonly installationIdentity?: RegistryBootstrapRecoveryIdentityV1;
|
|
readonly repositoryIdentity?: { readonly digest: string; readonly remote: string; readonly branch: string; readonly head: string };
|
|
readonly remoteIdentity?: { readonly digest: string; readonly remote: string; readonly head: string };
|
|
}
|
|
|
|
export class WorkspaceRegistry {
|
|
private readonly repository: GitWorkspaceRepository;
|
|
private readonly lock: WorkspaceRepositoryLock;
|
|
private readonly rootLeaseFactory: VerifiedWorkspaceLockRootLeaseFactory;
|
|
private readonly lifecycleOwner: CapabilityAwareRegistryPublicationLifecycleOwner;
|
|
private readonly participants: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[];
|
|
private readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[];
|
|
private readonly installationIdentity: RegistryBootstrapRecoveryIdentityV1;
|
|
private readonly repositoryIdentity: WorkspaceRegistryDependencies["repositoryIdentity"];
|
|
private readonly remoteIdentity: WorkspaceRegistryDependencies["remoteIdentity"];
|
|
|
|
constructor(input: WorkspaceRegistryDependencies | WorkspaceRegistryConfig) {
|
|
const raw = input as WorkspaceRegistryDependencies & WorkspaceRegistryConfig;
|
|
const config = "root" in raw && "branch" in raw ? raw : (raw as WorkspaceRegistryDependencies & { config?: WorkspaceRegistryConfig }).config!;
|
|
this.repository = raw.repository ?? new GitWorkspaceRepository(config);
|
|
this.lock = new WorkspaceRepositoryLock(this.repository.locksPath);
|
|
const sessionsRoot = join(this.repository.root, "sessions");
|
|
mkdirSync(sessionsRoot, { recursive: true, mode: 0o700 });
|
|
this.rootLeaseFactory = raw.rootLeaseFactory ?? new VerifiedWorkspaceLockRootLeaseFactory({
|
|
workspaceFsAt: new WorkspaceFsAtV1(), installationId: this.repository.config.installationId,
|
|
sessionsRootFromValidatedInstallationConfig: sessionsRoot,
|
|
serviceUid: process.getuid?.() ?? 0, provisionedWorkspaceMode: 0o700,
|
|
});
|
|
this.lifecycleOwner = raw.lifecycleOwner ?? new CapabilityAwareRegistryPublicationLifecycleOwner();
|
|
this.participants = raw.participants ?? [];
|
|
this.synchronizers = raw.synchronizers ?? [];
|
|
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
const repo = raw.repositoryIdentity ?? { remote: this.repository.config.remoteUrl ?? "", branch: this.repository.config.branch, head: "", digest: hash(`${this.repository.config.remoteUrl ?? ""}:${this.repository.config.branch}`) };
|
|
const remote = raw.remoteIdentity ?? { remote: repo.remote, head: "", digest: repo.digest };
|
|
this.repositoryIdentity = repo; this.remoteIdentity = remote;
|
|
this.installationIdentity = raw.installationIdentity ?? { operation: "registry_bootstrap", requestSha256: hash(this.repository.config.installationId) as never, installationIdentitySha256: hash(this.repository.config.installationId) as never, repositoryIdentitySha256: repo.digest as never, remoteRefIdentitySha256: remote.digest as never };
|
|
}
|
|
|
|
snapshotPath(commit: string, id: string): string {
|
|
return join(this.repository.snapshotsPath, safeCommit(commit), `${workspacePath(id).slice("workspaces/".length)}`);
|
|
}
|
|
|
|
/** Automatic addressed recovery. The repository lock is held for selection and execution. */
|
|
recoveryIdentity(): RegistryBootstrapRecoveryIdentityV1 { return this.installationIdentity; }
|
|
|
|
private async bootstrap(): Promise<GitStatus> {
|
|
await this.repository.ensureLayout();
|
|
return this.lock.run(async () => { try { const status = await this.repository.bootstrap(); await this.materialize(status.head!); await this.publishMaterialized(status.head!); return status; } catch (error) { return this.gitFallback(error); } });
|
|
}
|
|
private async pull(): Promise<GitStatus> {
|
|
await this.repository.ensureLayout();
|
|
return this.lock.run(async () => { try { const status = await this.repository.pull(); await this.materialize(status.head!); await this.publishMaterialized(status.head!); return status; } catch (error) { return this.gitFallback(error); } });
|
|
}
|
|
private async list(): Promise<WorkspaceRevision[]> {
|
|
const active = await this.tryActiveState();
|
|
if (active) return active.revisions;
|
|
await this.bootstrap();
|
|
return (await this.activeState()).revisions;
|
|
}
|
|
private async activate(commit: string): Promise<void> { await this.materialize(commit); await this.publishMaterialized(commit); }
|
|
/** Test-only migration seam; production callers use publishAddressed. */
|
|
private async publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> { return this.publishWorkspace(request); }
|
|
private async publishWorkspace(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> {
|
|
await this.repository.ensureLayout();
|
|
return this.lock.run(() => this.publishWorkspaceLocked(request));
|
|
}
|
|
private async publishWorkspaceLocked(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> {
|
|
const status = await this.repository.pull(); await this.materialize(status.head!); await this.publishMaterialized(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)) {
|
|
if (request.action !== "create" && request.baseCommit !== status.head && existing?.blob === request.baseBlob) throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
|
|
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 !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local);
|
|
if (request.action !== "delete") await this.assertEvidenceContext(request.workspace, status.head!);
|
|
const yamlPath = workspacePath(id); const docs = this.documentationPaths(id);
|
|
if (request.action === "delete") { await this.repository.removeRegistryFile(yamlPath); await this.repository.removeRegistryFile(docs.contract); await this.repository.removeRegistryFile(docs.readme); }
|
|
else { const source = serializeWorkspaceYaml(request.workspace); const rendered = renderWorkspaceDocs(request.workspace); await this.repository.writeRegistryFile(yamlPath, source); await this.repository.writeRegistryFile(docs.contract, rendered.envExample); await this.repository.writeRegistryFile(docs.readme, rendered.markdown); }
|
|
const next = await this.repository.commitAndPush([yamlPath, docs.contract, docs.readme], request.action === "delete" ? `Delete workspace ${id}` : `Publish workspace ${id}`);
|
|
await this.materialize(next.head!); await this.publishMaterialized(next.head!); return (await this.activeState()).revisions.find(revision => revision.id === id);
|
|
}
|
|
|
|
async ensureBootstrapAddressed(identity: RegistryBootstrapRecoveryIdentityV1): Promise<RegistryEnsureBootstrapAddressedResultV1> {
|
|
await this.repository.ensureLayout();
|
|
return this.lock.run(async () => this.ensureBootstrapAddressedLocked(identity));
|
|
}
|
|
|
|
private async ensureBootstrapAddressedLocked(identity: RegistryBootstrapRecoveryIdentityV1): Promise<RegistryEnsureBootstrapAddressedResultV1> {
|
|
let active: ActiveState | undefined;
|
|
try { active = await this.tryActiveState(); }
|
|
catch { throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt"); }
|
|
if (active) return { kind: "already_active", snapshot: this.addressedSnapshot(active, identity.requestSha256) };
|
|
const store = new RegistryAddressedPublicationStore(this.repository.root);
|
|
let jobs: readonly RegistryAddressedPublicationStateV1[];
|
|
try { jobs = await store.scan(); } catch { throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt"); }
|
|
const nonterminal = jobs.filter(job => job.phase !== "terminal_durable");
|
|
const matching = nonterminal.filter(job => job.operation === identity.operation && job.requestSha256 === identity.requestSha256 && job.installationIdentitySha256 === identity.installationIdentitySha256 && job.repositoryIdentitySha256 === identity.repositoryIdentitySha256 && job.remoteRefIdentitySha256 === identity.remoteRefIdentitySha256);
|
|
if (nonterminal.length > 1 || (nonterminal.length === 1 && matching.length !== 1)) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
|
let state = matching[0];
|
|
if (!state) {
|
|
const request: RegistryAddressedRequestV1 = {
|
|
mode: "create", operation: "registry_bootstrap", runId: addressedRunId(), requestSha256: identity.requestSha256,
|
|
installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256,
|
|
expectedBaseCommit: null, remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
|
};
|
|
state = await store.claim(request, request.runId);
|
|
}
|
|
const result = await this.executeAddressedLocked(store, state, identity);
|
|
const published = await this.activeState();
|
|
return { kind: "bootstrap_terminal", result: result as Extract<RegistryAddressedResultV1, { operation: "registry_bootstrap" }>, snapshot: this.addressedSnapshot(published, identity.requestSha256) };
|
|
}
|
|
|
|
async publishAddressed(request: RegistryAddressedRequestV1): Promise<RegistryAddressedResultV1> {
|
|
await this.repository.ensureLayout();
|
|
return this.lock.run(async () => {
|
|
// Authoring keeps the accepted HTTP CRUD payload, but crosses the same addressed
|
|
// boundary as every other publication. The mutation is deliberately handled while
|
|
// repository.lock is held; callers never receive the historical publish API.
|
|
if ("mutation" in request && request.mutation) return this.publishWorkspaceAddressed(request as Extract<RegistryAddressedRequestV1, { readonly mode: "create"; readonly operation: "registry_pull" }> & { readonly mutation: PublishWorkspaceRequest });
|
|
const store = new RegistryAddressedPublicationStore(this.repository.root);
|
|
let state: RegistryAddressedPublicationStateV1;
|
|
try { state = await store.read(request.runId); }
|
|
catch {
|
|
state = await store.claim(request, request.runId);
|
|
if (request.operation === "registry_pull" && request.mode === "create") {
|
|
const base = await this.snapshotState(request.expectedBaseCommit);
|
|
const baseWorkspaces = base.revisions.map(revision => this.manifestIdentity(revision));
|
|
state = await store.setBase(state.runId, registryDigest(base) as never, baseWorkspaces);
|
|
}
|
|
}
|
|
const identity: RegistryBootstrapRecoveryIdentityV1 = {
|
|
operation: "registry_bootstrap", requestSha256: request.requestSha256,
|
|
installationIdentitySha256: request.installationIdentitySha256,
|
|
repositoryIdentitySha256: request.repositoryIdentitySha256,
|
|
remoteRefIdentitySha256: request.remoteRefIdentitySha256,
|
|
};
|
|
if (state.operation !== request.operation || state.requestSha256 !== request.requestSha256 || state.installationIdentitySha256 !== request.installationIdentitySha256 || state.repositoryIdentitySha256 !== request.repositoryIdentitySha256 || state.remoteRefIdentitySha256 !== request.remoteRefIdentitySha256) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed request identity does not match the durable job");
|
|
return this.executeAddressedLocked(store, state, identity);
|
|
});
|
|
}
|
|
|
|
private async publishWorkspaceAddressed(request: Extract<RegistryAddressedRequestV1, { readonly mode: "create"; readonly operation: "registry_pull" }> & { readonly mutation: PublishWorkspaceRequest }): Promise<RegistryAddressedResultV1> {
|
|
const mutation = request.mutation;
|
|
await this.publishWorkspaceLocked(mutation);
|
|
const targetState = await this.activeState();
|
|
const targetWorkspaces = targetState.revisions.map(revision => this.manifestIdentity(revision));
|
|
const base = request.expectedBaseCommit ? await this.snapshotState(request.expectedBaseCommit) : undefined;
|
|
const baseWorkspaces = base?.revisions.map(revision => this.manifestIdentity(revision)) ?? [];
|
|
const baseIds = new Set(baseWorkspaces.map(item => item.workspaceId));
|
|
const targetIds = new Set(targetWorkspaces.map(item => item.workspaceId));
|
|
const changedWorkspaceIds = [...new Set([...baseIds, ...targetIds])].filter(id =>
|
|
!baseIds.has(id) || !targetIds.has(id)
|
|
|| registryDigest(baseWorkspaces.find(item => item.workspaceId === id)) !== registryDigest(targetWorkspaces.find(item => item.workspaceId === id)),
|
|
).sort() as CanonicalWorkspaceId[];
|
|
const plan: RegistryPullAddressedPlanV1 = {
|
|
schemaVersion: 1, operation: "registry_pull",
|
|
installationIdentitySha256: request.installationIdentitySha256,
|
|
repositoryIdentitySha256: request.repositoryIdentitySha256,
|
|
remoteRefIdentitySha256: request.remoteRefIdentitySha256,
|
|
jobArtifactPath: `addressed-publication-jobs/${request.runId}.json`,
|
|
advertisedTargetCommit: targetState.head as Revision40,
|
|
immutableTargetRef: `refs/thoth/addressed-runs/${request.runId}/target`,
|
|
fetchedTargetCommit: targetState.head as Revision40,
|
|
targetCommit: targetState.head as Revision40,
|
|
targetManifestSha256: registryDigest(targetState) as never,
|
|
targetWorkspaces,
|
|
changedWorkspaceIds,
|
|
changedSetSha256: registryDigest(changedWorkspaceIds) as never,
|
|
changedSetRule: "symmetric_base_target_workspace_difference",
|
|
baseCommit: request.expectedBaseCommit ?? targetState.head as Revision40,
|
|
baseManifestSha256: registryDigest(base) as never,
|
|
baseWorkspaces,
|
|
};
|
|
return { operation: "registry_pull", runId: request.runId, jobArtifactPath: plan.jobArtifactPath, plan, planSha256: registryDigest(plan) as never, phase: "terminal_durable", publication: "target" };
|
|
}
|
|
|
|
private async executeAddressedLocked(store: RegistryAddressedPublicationStore, initial: RegistryAddressedPublicationStateV1, identity: RegistryBootstrapRecoveryIdentityV1): Promise<RegistryAddressedResultV1> {
|
|
let state = initial;
|
|
if (state.phase === "terminal_durable") return store.readTerminalResult(state.runId, state.terminalResultSha256!);
|
|
const operation = state.operation;
|
|
let target = state.advertisedTargetCommit ?? state.fetchedTargetCommit;
|
|
if (state.phase === "request_claimed") {
|
|
const status = operation === "registry_bootstrap" ? await this.repository.bootstrap() : await this.repository.pull();
|
|
target = safeCommit(status.head ?? "") as Revision40;
|
|
state = await store.transition(state.runId, "target_advertised", { advertisedTargetCommit: target, immutableTargetRef: `refs/thoth/addressed-runs/${state.runId}/target` });
|
|
} else if (!target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
|
if (state.phase === "target_advertised") {
|
|
const current = await this.repository.runRef(state.runId);
|
|
if (current !== undefined && current !== target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
|
if (current === undefined) {
|
|
await this.repository.fetchExact(target!);
|
|
await this.repository.ensureRunRef(state.runId, target!);
|
|
}
|
|
state = await store.transition(state.runId, "target_fetched", { fetchedTargetCommit: target });
|
|
}
|
|
target = state.fetchedTargetCommit ?? target;
|
|
if (!target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
|
if (state.phase === "target_fetched") {
|
|
await this.materialize(target);
|
|
const targetState = await this.snapshotState(target);
|
|
const base = operation === "registry_pull" ? await this.baseState(state.baseCommit) : undefined;
|
|
const targetWorkspaces = targetState.revisions.map(revision => this.manifestIdentity(revision));
|
|
const baseWorkspaces = base?.revisions.map(revision => this.manifestIdentity(revision)) ?? [];
|
|
const baseIds = new Set(baseWorkspaces.map(workspace => workspace.workspaceId));
|
|
const targetIds = new Set(targetWorkspaces.map(workspace => workspace.workspaceId));
|
|
const changedWorkspaceIds = [...new Set([...baseIds, ...targetIds])].filter(id => !baseIds.has(id) || !targetIds.has(id) || registryDigest(baseWorkspaces.find(x => x.workspaceId === id)) !== registryDigest(targetWorkspaces.find(x => x.workspaceId === id))).sort() as CanonicalWorkspaceId[];
|
|
const plan = operation === "registry_bootstrap" ? {
|
|
schemaVersion: 1, operation, installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256, remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
|
jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: state.advertisedTargetCommit!, immutableTargetRef: state.immutableTargetRef!, fetchedTargetCommit: target as Revision40, targetCommit: target as Revision40,
|
|
targetManifestSha256: registryDigest(targetState) as never, targetWorkspaces, changedWorkspaceIds: targetWorkspaces.map(x => x.workspaceId).sort() as CanonicalWorkspaceId[], changedSetSha256: registryDigest(targetWorkspaces.map(x => x.workspaceId).sort()) as never, changedSetRule: "all_target_workspace_ids" as const, baseCommit: null, baseManifestSha256: null, baseWorkspaces: [] as const,
|
|
} : {
|
|
schemaVersion: 1, operation, installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256, remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
|
jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: state.advertisedTargetCommit!, immutableTargetRef: state.immutableTargetRef!, fetchedTargetCommit: target as Revision40, targetCommit: target as Revision40,
|
|
targetManifestSha256: registryDigest(targetState), targetWorkspaces, changedWorkspaceIds, changedSetSha256: registryDigest(changedWorkspaceIds) as never, changedSetRule: "symmetric_base_target_workspace_difference" as const, baseCommit: state.baseCommit!, baseManifestSha256: registryDigest(base) as never, baseWorkspaces,
|
|
};
|
|
state = await store.transition(state.runId, "planned", { targetCommit: target as Revision40, targetManifestSha256: plan.targetManifestSha256 as never, targetWorkspaces: plan.targetWorkspaces, changedWorkspaceIds: plan.changedWorkspaceIds, changedSetSha256: plan.changedSetSha256 as never, planSha256: registryDigest(plan) as never, changedSetRule: plan.changedSetRule });
|
|
}
|
|
const plan = await this.planForState(state);
|
|
const leases = [];
|
|
for (const id of plan.changedWorkspaceIds) leases.push(await this.rootLeaseFactory.acquireOrProvision(this.rootLeaseFactory.canonicalInput(id)));
|
|
const result = await runUnderOrderedWorkspaceWriterLocks(leases, capabilities => this.lifecycleOwner.run({ plan, capabilities, participants: this.participants, synchronizers: this.synchronizers, action: async () => {
|
|
if (state.phase === "planned") {
|
|
state = await store.transition(state.runId, "participants_prepared", { participantsSha256: registryDigest(this.participants.map(participant => participant.participantId)) as never, synchronizersSha256: registryDigest(this.synchronizers.map(synchronizer => synchronizer.synchronizerId)) as never });
|
|
}
|
|
if (state.phase === "participants_prepared") {
|
|
state = await store.transition(state.runId, "publication_intent_durable", { publicationIntentSha256: registryDigest({ runId: state.runId, planSha256: state.planSha256 }) as never });
|
|
}
|
|
if (state.phase === "publication_intent_durable") {
|
|
await this.publishMaterialized(target!);
|
|
state = await store.transition(state.runId, "target_published", { publishedActiveStateSha256: registryDigest(await this.activeState()) as never });
|
|
}
|
|
return undefined;
|
|
}}));
|
|
const output = { operation: state.operation, runId: state.runId, jobArtifactPath: state.jobArtifactPath, plan, planSha256: registryDigest(plan), phase: "terminal_durable" as const, publication: "target" as const } as RegistryAddressedResultV1;
|
|
await store.storeTerminalResult(state.runId, output);
|
|
state = await store.transition(state.runId, "terminal_durable", { terminalResultSha256: registryDigest(output) as never });
|
|
return output;
|
|
}
|
|
|
|
private async planForState(state: RegistryAddressedPublicationStateV1): Promise<RegistryAddressedPlanV1> {
|
|
if (!state.targetCommit || !state.targetWorkspaces || !state.changedWorkspaceIds || !state.planSha256 || !state.changedSetSha256 || !state.targetManifestSha256 || !state.advertisedTargetCommit || !state.immutableTargetRef || !state.fetchedTargetCommit) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
|
const common = { schemaVersion: 1 as const, installationIdentitySha256: state.installationIdentitySha256, repositoryIdentitySha256: state.repositoryIdentitySha256, remoteRefIdentitySha256: state.remoteRefIdentitySha256, jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: state.advertisedTargetCommit, immutableTargetRef: state.immutableTargetRef, fetchedTargetCommit: state.fetchedTargetCommit, targetCommit: state.targetCommit, targetManifestSha256: state.targetManifestSha256, targetWorkspaces: state.targetWorkspaces, changedWorkspaceIds: state.changedWorkspaceIds, changedSetSha256: state.changedSetSha256 };
|
|
const plan: RegistryAddressedPlanV1 = state.operation === "registry_bootstrap" ? { ...common, operation: "registry_bootstrap", changedSetRule: "all_target_workspace_ids", baseCommit: null, baseManifestSha256: null, baseWorkspaces: [] } : { ...common, operation: "registry_pull", changedSetRule: "symmetric_base_target_workspace_difference", baseCommit: state.baseCommit!, baseManifestSha256: state.baseManifestSha256!, baseWorkspaces: state.baseWorkspaces };
|
|
if (registryDigest(plan) !== state.planSha256) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
|
return plan;
|
|
}
|
|
|
|
private addressedSnapshot(state: ActiveState, requestDigest: string): RegistryActiveSnapshotV1 {
|
|
const ids = state.revisions.map(revision => revision.id).sort();
|
|
return { schemaVersion: 1, commit: state.head as RegistryActiveSnapshotV1["commit"], manifestSha256: registryDigest(state) as RegistryActiveSnapshotV1["manifestSha256"], workspaces: ids.map(id => { const revision = state.revisions.find(candidate => candidate.id === id)!; return this.manifestIdentity(revision); }) };
|
|
}
|
|
|
|
async listRetainedSnapshots(): Promise<WorkspaceRevision[]> {
|
|
await this.repository.ensureLayout();
|
|
return await this.lock.run(async () => {
|
|
try {
|
|
const active = await this.activeState();
|
|
const revisions = [...active.revisions];
|
|
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[0-9a-f]{40}$/.test(entry.name)) continue;
|
|
if (entry.name === active.head) continue;
|
|
const state = await this.snapshotState(entry.name);
|
|
revisions.push(...state.revisions);
|
|
}
|
|
return revisions;
|
|
} catch (error) {
|
|
throw workspaceError(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
async read(id: string): Promise<{ workspace: WorkspaceDescriptor; 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the active revision and create its cross-process retention lease under the same
|
|
* repository lock. The lease bridges the interval before `session_manifest.yaml` is durable.
|
|
*/
|
|
async acquireSessionRevision(id: string): Promise<SessionRevisionLease> {
|
|
await this.repository.ensureLayout();
|
|
return await this.lock.run(async () => {
|
|
const state = await this.activeState();
|
|
const revision = state.revisions.find((candidate) => candidate.id === id);
|
|
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
|
let workspace: WorkspaceDescriptor;
|
|
try {
|
|
workspace = validateOperationalWorkspace(
|
|
parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8")),
|
|
);
|
|
} catch (error) {
|
|
throw workspaceError(error);
|
|
}
|
|
|
|
const token = randomUUID();
|
|
const record: RevisionLeaseRecord = {
|
|
version: 1,
|
|
token,
|
|
workspaceId: id,
|
|
commit: revision.commit,
|
|
state: "creating",
|
|
};
|
|
const path = await this.writeRevisionLease(record, true);
|
|
let localState: RevisionLeaseRecord["state"] | "aborted" = "creating";
|
|
|
|
return {
|
|
workspace,
|
|
revision,
|
|
markPersisted: async () => {
|
|
if (localState === "persisted") return;
|
|
if (localState === "aborted") throw new WorkspaceRegistryError(
|
|
"workspace_invalid", "Workspace revision lease is unavailable",
|
|
);
|
|
await this.lock.run(async () => {
|
|
await this.replaceRevisionLease(path, { ...record, state: "persisted" });
|
|
});
|
|
localState = "persisted";
|
|
},
|
|
abort: async () => {
|
|
if (localState !== "creating") return;
|
|
await this.lock.run(async () => { await rm(path, { force: true }); });
|
|
localState = "aborted";
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Read a retained immutable snapshot for a session pinned to a historical commit. */
|
|
async readPinned(id: string, commit: string): Promise<{ workspace: WorkspaceDescriptor; workspaceConfigPath: string }> {
|
|
const snapshotPath = this.snapshotPath(safeCommit(commit), id);
|
|
try {
|
|
const source = await readFile(snapshotPath, "utf8");
|
|
return {
|
|
workspace: validateOperationalWorkspace(parseWorkspaceYaml(source)),
|
|
workspaceConfigPath: snapshotPath,
|
|
};
|
|
} catch (error) {
|
|
throw workspaceError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Garbage-collect obsolete immutable snapshots without breaking cold Resume.
|
|
* Callers must supply revisions collected from an administrator-visible complete session list;
|
|
* a partial, per-user list could otherwise remove another user's resumable workspace pin.
|
|
*/
|
|
async reconcileSnapshotRetention(referencedCommits: readonly string[]): Promise<void> {
|
|
const manifestReferences = new Set(referencedCommits.map(safeCommit));
|
|
const retained = new Set(manifestReferences);
|
|
await this.repository.ensureLayout();
|
|
await this.lock.run(async () => {
|
|
const leases = await this.revisionLeases();
|
|
for (const { record } of leases) retained.add(record.commit);
|
|
retained.add((await this.activeState()).head);
|
|
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
// Leave staging and unexpected entries untouched: this cleanup only owns finalized,
|
|
// commit-addressed snapshot directories.
|
|
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[0-9a-f]{40}$/.test(entry.name)) continue;
|
|
if (retained.has(entry.name)) continue;
|
|
const path = join(this.repository.snapshotsPath, entry.name);
|
|
const current = lstatSync(path);
|
|
if (!current.isDirectory() || current.isSymbolicLink()) continue;
|
|
await rm(path, { recursive: true, force: true });
|
|
}
|
|
// A persisted lease is handed off only when this exact authoritative scan has observed a
|
|
// manifest pin for its commit. A stale scan therefore keeps the lease and cannot prune it.
|
|
for (const { path, record } of leases) {
|
|
if (record.state === "persisted" && manifestReferences.has(record.commit)) {
|
|
await rm(path, { force: true });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private revisionLeaseDirectory(): string {
|
|
return join(this.repository.statePath, "revision-leases");
|
|
}
|
|
|
|
private async writeRevisionLease(record: RevisionLeaseRecord, exclusive: boolean): Promise<string> {
|
|
const directory = this.revisionLeaseDirectory();
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
const path = join(directory, `${record.token}.json`);
|
|
await writeFile(path, JSON.stringify(record), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
flush: true,
|
|
...(exclusive ? { flag: "wx" } : {}),
|
|
});
|
|
return path;
|
|
}
|
|
|
|
private async replaceRevisionLease(path: string, record: RevisionLeaseRecord): Promise<void> {
|
|
const staging = `${path}.staging-${randomUUID()}`;
|
|
try {
|
|
await writeFile(staging, JSON.stringify(record), {
|
|
encoding: "utf8", mode: 0o600, flag: "wx", flush: true,
|
|
});
|
|
await rename(staging, path);
|
|
} catch (error) {
|
|
await rm(staging, { force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async revisionLeases(): Promise<Array<{ path: string; record: RevisionLeaseRecord }>> {
|
|
const directory = this.revisionLeaseDirectory();
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const leases: Array<{ path: string; record: RevisionLeaseRecord }> = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || entry.isSymbolicLink() || !/^[0-9a-f-]{36}\.json$/.test(entry.name)) {
|
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
|
}
|
|
const path = join(directory, entry.name);
|
|
let record: RevisionLeaseRecord;
|
|
try {
|
|
record = JSON.parse(await readFile(path, "utf8")) as RevisionLeaseRecord;
|
|
} catch {
|
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
|
}
|
|
if (
|
|
record.version !== 1
|
|
|| `${record.token}.json` !== entry.name
|
|
|| !/^[0-9a-f-]{36}$/.test(record.token)
|
|
|| !/^[a-z][a-z0-9-]{2,62}$/.test(record.workspaceId)
|
|
|| !/^[0-9a-f]{40}$/.test(record.commit)
|
|
|| (record.state !== "creating" && record.state !== "persisted")
|
|
) {
|
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
|
}
|
|
leases.push({ path, record });
|
|
}
|
|
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.
|
|
*/
|
|
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.
|
|
await this.repository.assertTreeAtRevision(revision, workspace.evidence.source.uri);
|
|
}
|
|
|
|
private async assertSnapshotEvidenceContexts(state: ActiveState): Promise<void> {
|
|
for (const revision of state.revisions) {
|
|
const workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8"));
|
|
await this.assertEvidenceContext(workspace, revision.commit);
|
|
}
|
|
}
|
|
|
|
private async materialize(commit: string): Promise<void> {
|
|
const safeHead = safeCommit(commit);
|
|
const files = await this.repository.workspacePathsAt(safeHead);
|
|
|
|
const snapshots: Array<{
|
|
id: string;
|
|
source: string;
|
|
workspace: WorkspaceDescriptor;
|
|
blob: string;
|
|
}> = [];
|
|
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.readWorkspaceAt(safeHead, path);
|
|
const workspace = parseWorkspaceYaml(source);
|
|
if (workspace.workspace.id !== id) {
|
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
|
}
|
|
await this.assertEvidenceContext(workspace, safeHead);
|
|
const collection = workspace.semantic_index.vector_store.collection;
|
|
const owner = collectionOwners.get(collection);
|
|
if (owner !== undefined) {
|
|
throw new Error(`duplicate qdrant collection ownership: ${collection} (${owner}, ${id})`);
|
|
}
|
|
collectionOwners.set(collection, id);
|
|
buildInstallationContract(workspace);
|
|
renderWorkspaceDocs(workspace);
|
|
snapshots.push({
|
|
id,
|
|
source: serializeWorkspaceYaml(workspace),
|
|
workspace,
|
|
blob: await this.repository.blobAt(safeHead, path),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
throw workspaceError(error);
|
|
}
|
|
|
|
const snapshotDirectory = join(this.repository.snapshotsPath, safeHead);
|
|
const revisions = snapshots.map((snapshot) => ({
|
|
id: snapshot.id,
|
|
commit: safeHead,
|
|
blob: snapshot.blob,
|
|
snapshotPath: this.snapshotPath(safeHead, snapshot.id),
|
|
}));
|
|
if (this.pathExists(snapshotDirectory)) {
|
|
await this.assertSnapshotIntegrity({ head: safeHead, revisions });
|
|
} else {
|
|
const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`);
|
|
await mkdir(staging, { mode: 0o700 });
|
|
try {
|
|
const files: Record<string, string> = {};
|
|
for (const snapshot of snapshots) {
|
|
const yamlName = `${snapshot.id}.yaml`;
|
|
const envName = `${snapshot.id}.env.example`;
|
|
const docsName = `${snapshot.id}.md`;
|
|
await writeFile(join(staging, yamlName), snapshot.source, { encoding: "utf8", mode: 0o400 });
|
|
files[yamlName] = digest(snapshot.source);
|
|
const docs = renderWorkspaceDocs(snapshot.workspace);
|
|
await writeFile(join(staging, envName), docs.envExample, { encoding: "utf8", mode: 0o400 });
|
|
await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 });
|
|
files[envName] = digest(docs.envExample);
|
|
files[docsName] = digest(docs.markdown);
|
|
}
|
|
await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, files }), {
|
|
encoding: "utf8", mode: 0o400,
|
|
});
|
|
await rename(staging, snapshotDirectory);
|
|
} catch (error) {
|
|
await rm(staging, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private async publishMaterialized(commit: string): Promise<void> {
|
|
const state = await this.snapshotState(commit);
|
|
await this.writeActiveState({ head: state.head, revisions: state.revisions });
|
|
}
|
|
|
|
private async baseState(commit: Revision40 | null): Promise<ActiveState | undefined> {
|
|
return commit ? this.snapshotState(commit) : undefined;
|
|
}
|
|
|
|
private manifestIdentity(revision: WorkspaceRevision): RegistryWorkspaceManifestIdentityV1 {
|
|
return { workspaceId: revision.id as RegistryWorkspaceManifestIdentityV1["workspaceId"], revision: revision.commit as RegistryWorkspaceManifestIdentityV1["revision"], descriptorBlob: revision.blob as RegistryWorkspaceManifestIdentityV1["descriptorBlob"], manifestSha256: registryDigest(revision) as RegistryWorkspaceManifestIdentityV1["manifestSha256"] };
|
|
}
|
|
|
|
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.repository.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 = this.decodeActiveState(JSON.parse(await readFile(file, "utf8")));
|
|
await this.assertSnapshotIntegrity(state);
|
|
return state;
|
|
} catch (error) {
|
|
if (this.pathIsMissing(file)) return undefined;
|
|
if (error instanceof WorkspaceRegistryError) throw error;
|
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace active snapshot is invalid");
|
|
}
|
|
}
|
|
|
|
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 });
|
|
const handle = await openFile(staging, "r");
|
|
try { await handle.sync(); } finally { await handle.close(); }
|
|
await rename(staging, target);
|
|
const parent = await openFile(this.repository.statePath, "r");
|
|
try { await parent.sync(); } finally { await parent.close(); }
|
|
}
|
|
|
|
private decodeActiveState(value: unknown): ActiveState {
|
|
const state = this.strictObject(value, ["head", "revisions"]);
|
|
return this.decodeStateRevisions(state.head, state.revisions);
|
|
}
|
|
|
|
private decodeSnapshotManifest(value: unknown): SnapshotManifest {
|
|
const manifest = this.strictObject(value, ["head", "revisions", "files"]);
|
|
const state = this.decodeStateRevisions(manifest.head, manifest.revisions);
|
|
if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) {
|
|
throw new Error("bad manifest files");
|
|
}
|
|
const entries = Object.entries(manifest.files as Record<string, unknown>);
|
|
if (entries.some(([, contentsDigest]) => typeof contentsDigest !== "string")) {
|
|
throw new Error("bad manifest files");
|
|
}
|
|
return { ...state, files: Object.fromEntries(entries) as Record<string, string> };
|
|
}
|
|
|
|
private decodeStateRevisions(headValue: unknown, revisionsValue: unknown): ActiveState {
|
|
if (typeof headValue !== "string" || !Array.isArray(revisionsValue)) throw new Error("bad state");
|
|
const head = safeCommit(headValue);
|
|
const ids = new Set<string>();
|
|
const revisions = revisionsValue.map((value) => {
|
|
const revision = this.decodeRevision(value, head);
|
|
if (ids.has(revision.id)) throw new Error("duplicate revision");
|
|
ids.add(revision.id);
|
|
return revision;
|
|
});
|
|
return { head, revisions };
|
|
}
|
|
|
|
private decodeRevision(value: unknown, head: string): WorkspaceRevision {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad revision");
|
|
const revision = value as Record<string, unknown>;
|
|
const keys = Object.keys(revision);
|
|
const required = ["id", "commit", "blob", "snapshotPath"];
|
|
const hasHistoricalState = Object.prototype.hasOwnProperty.call(revision, "state");
|
|
if (
|
|
keys.length !== required.length + (hasHistoricalState ? 1 : 0)
|
|
|| !required.every((key) => Object.prototype.hasOwnProperty.call(revision, key))
|
|
|| (hasHistoricalState && revision.state !== "operational")
|
|
) {
|
|
throw new Error("bad revision");
|
|
}
|
|
if (
|
|
typeof revision.id !== "string"
|
|
|| typeof revision.commit !== "string"
|
|
|| typeof revision.blob !== "string"
|
|
|| typeof revision.snapshotPath !== "string"
|
|
) {
|
|
throw new Error("bad revision");
|
|
}
|
|
const id = revision.id;
|
|
const commit = safeCommit(revision.commit);
|
|
const blob = safeBlob(revision.blob);
|
|
const snapshotPath = revision.snapshotPath;
|
|
workspacePath(id);
|
|
if (
|
|
commit !== head
|
|
|| !isAbsolute(snapshotPath)
|
|
|| snapshotPath !== this.snapshotPath(commit, id)
|
|
) {
|
|
throw new Error("bad revision");
|
|
}
|
|
// Always reconstruct a fresh public revision. The sole accepted historical state field is
|
|
// compatibility input and must never cross the registry boundary.
|
|
return { id, commit, blob, snapshotPath };
|
|
}
|
|
|
|
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>;
|
|
const keys = Object.keys(record);
|
|
if (
|
|
keys.length !== expectedKeys.length
|
|
|| !expectedKeys.every((key) => Object.prototype.hasOwnProperty.call(record, key))
|
|
) {
|
|
throw new Error("bad state");
|
|
}
|
|
return record;
|
|
}
|
|
|
|
private async readSnapshotManifest(head: string): Promise<unknown> {
|
|
const path = join(this.repository.snapshotsPath, head, "snapshot.json");
|
|
return JSON.parse(await readFile(path, "utf8"));
|
|
}
|
|
|
|
private async snapshotState(head: string): Promise<ActiveState> {
|
|
const state = this.decodeSnapshotManifest(await this.readSnapshotManifest(safeCommit(head)));
|
|
await this.assertSnapshotIntegrity(state);
|
|
return state;
|
|
}
|
|
|
|
private async assertSnapshotIntegrity(state: ActiveState): Promise<void> {
|
|
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");
|
|
}
|
|
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state));
|
|
await this.assertSnapshotEvidenceContexts(state);
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceRegistryError) throw error;
|
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
|
|
}
|
|
}
|
|
|
|
private expectedSnapshotFiles(state: ActiveState): string[] {
|
|
// P1 snapshots only descriptors and derived public docs. P6 owns revision-pinned
|
|
// workspace-content materialization and its recursive containment checks.
|
|
return state.revisions.flatMap((revision) => [
|
|
`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`,
|
|
]);
|
|
}
|
|
|
|
private async assertManifestFiles(
|
|
directory: string,
|
|
files: Record<string, string>,
|
|
expected: string[],
|
|
): Promise<void> {
|
|
if (!files || typeof files !== "object" || Object.keys(files).length !== expected.length || !expected.every((name) => (
|
|
/^[0-9a-f]{64}$/.test(files[name] ?? "")
|
|
))) throw new Error("manifest files are invalid");
|
|
for (const name of expected) {
|
|
const path = join(directory, name);
|
|
const entry = lstatSync(path);
|
|
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("snapshot file is invalid");
|
|
const contents = await readFile(path);
|
|
if (digest(contents) !== files[name]) throw new Error("snapshot file does not match manifest");
|
|
if (name.endsWith(".yaml")) {
|
|
const workspace = parseWorkspaceYaml(contents.toString("utf8"));
|
|
if (workspace.workspace.id !== name.slice(0, -".yaml".length)) {
|
|
throw new Error("snapshot workspace is invalid");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private sameRevisions(left: WorkspaceRevision[], right: WorkspaceRevision[]): boolean {
|
|
return left.length === right.length && left.every((revision, index) => {
|
|
const candidate = right[index];
|
|
return candidate !== undefined
|
|
&& candidate.id === revision.id && candidate.commit === revision.commit
|
|
&& candidate.blob === revision.blob && candidate.snapshotPath === revision.snapshotPath;
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
private pathIsMissing(path: string): boolean {
|
|
try {
|
|
lstatSync(path);
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
}
|