fix: replace addressed registry lifecycle with capability owner
This commit is contained in:
+168
-167
@@ -17,16 +17,9 @@ import {
|
||||
type WorkspaceDescriptor,
|
||||
} from "./schema.js";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
import {
|
||||
RegistryAddressedPublicationStore,
|
||||
addressedRunId,
|
||||
canonicalBootstrapRequestDigest,
|
||||
type RegistryAddressedSnapshotV1,
|
||||
type RegistryEnsureBootstrapAddressedResultV1,
|
||||
type RegistryAddressedRequestV1,
|
||||
type RegistryAddressedPublicationResultV1,
|
||||
} from "./registry-publication.js";
|
||||
|
||||
import type { VerifiedWorkspaceLockRootLeaseFactory, Revision40, CanonicalWorkspaceId } from "./workspace-lock-root-lease.js";
|
||||
import { runUnderOrderedWorkspaceWriterLocks, type OrderedWorkspaceWriterCapabilitySet } from "./preprocessing-state.js";
|
||||
import { RegistryAddressedPublicationStore, addressedRunId, canonicalBootstrapRequestDigest, registryDigest, CapabilityAwareRegistryPublicationLifecycleOwner, type CapabilityAwareRegistryPublicationParticipant, type CapabilityAwareRegistryPublicationSynchronizer, type RegistryAddressedPlanV1, 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 {
|
||||
@@ -112,131 +105,178 @@ function workspaceError(error: unknown): WorkspaceRegistryError {
|
||||
}
|
||||
|
||||
/** 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(private readonly config: WorkspaceRegistryConfig) {
|
||||
this.repository = new GitWorkspaceRepository(config);
|
||||
constructor(input: WorkspaceRegistryDependencies) {
|
||||
if (!input.repository || !input.installationIdentity || !input.repositoryIdentity || !input.remoteIdentity) throw new Error("WorkspaceRegistry requires bound repository identities");
|
||||
this.repository = input.repository;
|
||||
this.lock = new WorkspaceRepositoryLock(this.repository.locksPath);
|
||||
this.rootLeaseFactory = input.rootLeaseFactory;
|
||||
this.lifecycleOwner = input.lifecycleOwner;
|
||||
this.participants = input.participants;
|
||||
this.synchronizers = input.synchronizers;
|
||||
this.installationIdentity = input.installationIdentity;
|
||||
this.repositoryIdentity = input.repositoryIdentity;
|
||||
this.remoteIdentity = input.remoteIdentity;
|
||||
}
|
||||
|
||||
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 acquired before active-state inspection. */
|
||||
async ensureBootstrapAddressed(request?: any): Promise<any> {
|
||||
/** Automatic addressed recovery. The repository lock is held for selection and execution. */
|
||||
recoveryIdentity(): RegistryBootstrapRecoveryIdentityV1 { return this.installationIdentity; }
|
||||
|
||||
async ensureBootstrapAddressed(identity: RegistryBootstrapRecoveryIdentityV1): Promise<RegistryEnsureBootstrapAddressedResultV1> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
const active = await this.tryActiveState();
|
||||
const req: any = request ?? this.defaultAddressedRequest(active?.head ?? null);
|
||||
if (active) return { kind: "already_active", snapshot: this.addressedSnapshot(active, req) };
|
||||
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 () => {
|
||||
const store = new RegistryAddressedPublicationStore(this.repository.root);
|
||||
const jobs = await store.scan();
|
||||
const matching = jobs.filter((job: any) => job.operation === "registry_bootstrap" && job.requestSha256 === (req.requestSha256 ?? req.requestDigest) && job.installationIdentitySha256 === (req.installationIdentitySha256 ?? req.installation?.digest) && job.repositoryIdentitySha256 === (req.repositoryIdentitySha256 ?? req.repository?.digest) && job.remoteRefIdentitySha256 === (req.remoteRefIdentitySha256 ?? req.remote?.digest));
|
||||
const nonterminal = jobs.filter((job: any) => job.phase !== "terminal_durable");
|
||||
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: any = matching.find((job: any) => job.phase !== "terminal_durable");
|
||||
if (!state) {
|
||||
const runId = addressedRunId();
|
||||
const createRequest: any = { mode: "create", operation: "registry_bootstrap", runId, requestSha256: (req.requestSha256 ?? req.requestDigest), installationIdentitySha256: (req.installationIdentitySha256 ?? req.installation?.digest), repositoryIdentitySha256: (req.repositoryIdentitySha256 ?? req.repository?.digest), expectedBaseCommit: null, remoteRefIdentitySha256: (req.remoteRefIdentitySha256 ?? req.remote?.digest) };
|
||||
state = await store.claim(createRequest, runId);
|
||||
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 result = await this.executeAddressed(store, state, req);
|
||||
const snapshot = (await this.activeState()) as any;
|
||||
return { kind: "bootstrap_terminal", result, snapshot: this.addressedSnapshot(snapshot, req) };
|
||||
const identity: RegistryBootstrapRecoveryIdentityV1 = {
|
||||
operation: "registry_bootstrap", requestSha256: request.requestSha256,
|
||||
installationIdentitySha256: request.installationIdentitySha256,
|
||||
repositoryIdentitySha256: request.repositoryIdentitySha256,
|
||||
remoteRefIdentitySha256: request.remoteRefIdentitySha256,
|
||||
};
|
||||
return this.executeAddressedLocked(store, state, identity);
|
||||
});
|
||||
}
|
||||
|
||||
async publishAddressed(request: any): Promise<any> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
const before = await this.tryActiveState();
|
||||
let req: any = request ?? {};
|
||||
if (!req.requestDigest && !req.requestSha256) {
|
||||
const base = this.defaultAddressedRequest(before?.head ?? null);
|
||||
req = { ...base, kind: "publish", requestDigest: canonicalBootstrapRequestDigest({ ...base, kind: "publish" }), target: req.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!);
|
||||
}
|
||||
const store = new RegistryAddressedPublicationStore(this.repository.root);
|
||||
const runId = (req.runId ?? addressedRunId()) as any;
|
||||
const addressed: any = (req.operation ? req : { mode: "create", operation: "registry_pull", runId, requestSha256: req.requestSha256 ?? req.requestDigest, installationIdentitySha256: req.installationIdentitySha256 ?? req.installation?.digest, repositoryIdentitySha256: req.repositoryIdentitySha256 ?? req.repository?.digest, expectedBaseCommit: req.expectedBaseCommit ?? before?.head, remoteRefIdentitySha256: req.remoteRefIdentitySha256 ?? req.remote?.digest });
|
||||
let state: any;
|
||||
try { state = await store.read(addressed.runId); } catch { state = await store.claim(addressed, runId); }
|
||||
const result = await this.executeAddressed(store, state, req);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async executeAddressed(store: RegistryAddressedPublicationStore, state: any, request: any): Promise<any> {
|
||||
if (state.phase === "terminal_durable") return state.result ?? { operation: state.operation, runId: state.runId, jobArtifactPath: state.jobArtifactPath, phase: "terminal_durable", publication: "unchanged" };
|
||||
const status = state.operation === "registry_bootstrap" ? await this.repository.bootstrap() : await this.repository.pull();
|
||||
const target = status.head!;
|
||||
await this.activate(target);
|
||||
const active = await this.activeState();
|
||||
const workspaces = active.revisions.map(revision => ({ workspaceId: revision.id, revision: revision.commit, descriptorBlob: revision.blob, manifestSha256: digest(JSON.stringify(revision)) }));
|
||||
const changed = workspaces.map(x => x.workspaceId).sort();
|
||||
const plan: any = { schemaVersion: 1, operation: state.operation, installationIdentitySha256: state.installationIdentitySha256, repositoryIdentitySha256: state.repositoryIdentitySha256, remoteRefIdentitySha256: state.remoteRefIdentitySha256, jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: target, immutableTargetRef: `refs/thoth/addressed-runs/${state.runId}/target`, fetchedTargetCommit: target, targetCommit: target, targetManifestSha256: digest(JSON.stringify(active)), targetWorkspaces: workspaces, changedWorkspaceIds: changed, changedSetSha256: digest(JSON.stringify(changed)), changedSetRule: state.operation === "registry_bootstrap" ? "all_target_workspace_ids" : "symmetric_base_target_workspace_difference", baseCommit: state.operation === "registry_bootstrap" ? null : state.baseCommit, baseManifestSha256: state.operation === "registry_bootstrap" ? null : state.baseManifestSha256, baseWorkspaces: [] };
|
||||
let current = state;
|
||||
for (const phase of ["target_advertised", "target_fetched", "planned", "participants_prepared", "publication_intent_durable", "target_published"] as const) current = await store.transition(state.runId, phase, (phase === "target_advertised" ? { advertisedTargetCommit: target, immutableTargetRef: plan.immutableTargetRef } : phase === "target_fetched" ? { fetchedTargetCommit: target } : phase === "planned" ? { targetCommit: target, targetManifestSha256: plan.targetManifestSha256, targetWorkspaces: workspaces, changedWorkspaceIds: changed, changedSetSha256: plan.changedSetSha256, planSha256: digest(plan), changedSetRule: plan.changedSetRule } : {}) as any);
|
||||
const result: any = { operation: state.operation, runId: state.runId, jobArtifactPath: state.jobArtifactPath, plan, planSha256: digest(plan), phase: "terminal_durable", publication: "target" };
|
||||
await store.transition(state.runId, "terminal_durable", { terminalResultSha256: digest(result) as any, publishedActiveStateSha256: digest(JSON.stringify(active)) as any });
|
||||
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(targetWorkspaces), 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 });
|
||||
}
|
||||
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;
|
||||
}}));
|
||||
return result;
|
||||
}
|
||||
|
||||
private defaultAddressedRequest(head: string | null): any {
|
||||
const installation = { installationId: this.config.installationId, digest: digest(this.config.installationId) };
|
||||
const repository = { remote: this.config.remoteUrl ?? "", branch: this.config.branch, head: head ?? "", digest: digest(`${this.config.remoteUrl ?? ""}:${this.config.branch}:${head ?? ""}`) };
|
||||
const remote = { remote: this.config.remoteUrl ?? "", head: head ?? "", digest: repository.digest };
|
||||
const base = { kind: "bootstrap" as const, installation, repository, remote, workspaceIds: [] as string[] };
|
||||
return { ...base, requestDigest: canonicalBootstrapRequestDigest(base) };
|
||||
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, request: any): RegistryAddressedSnapshotV1 {
|
||||
|
||||
private addressedSnapshot(state: ActiveState, requestDigest: string): RegistryActiveSnapshotV1 {
|
||||
const ids = state.revisions.map(revision => revision.id).sort();
|
||||
return { schemaVersion: 1, commit: state.head as any, manifestSha256: digest(JSON.stringify(state)) as any, workspaces: ids.map(id => { const revision = state.revisions.find(x => x.id === id)!; return { workspaceId: id as any, revision: revision.commit as any, descriptorBlob: revision.blob as any, manifestSha256: digest(JSON.stringify(revision)) as any }; }), requestDigest: request.requestDigest ?? request.requestSha256 };
|
||||
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 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[]> {
|
||||
const active = await this.tryActiveState();
|
||||
if (active) return active.revisions;
|
||||
// A clean installation has no active snapshot until the first registry operation. Keep
|
||||
// this lazy so health/startup remain available when Git is temporarily unreachable, while
|
||||
// still refusing corrupted existing state (tryActiveState throws instead of returning none).
|
||||
await this.bootstrap();
|
||||
return (await this.activeState()).revisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* List every intact retained snapshot, current snapshots first. Session discovery and
|
||||
* retention use this rather than only the active revision so removing a workspace from
|
||||
* Git cannot strand a resumable session that still pins one of its older descriptors.
|
||||
*/
|
||||
async listRetainedSnapshots(): Promise<WorkspaceRevision[]> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
@@ -434,57 +474,6 @@ export class WorkspaceRegistry {
|
||||
* 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
|
||||
)) {
|
||||
const contentOnlyStale = request.action !== "create"
|
||||
&& request.baseCommit !== status.head
|
||||
&& existing?.blob === request.baseBlob;
|
||||
if (contentOnlyStale) {
|
||||
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 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}`;
|
||||
@@ -556,9 +545,9 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private async activate(commit: string): Promise<void> {
|
||||
private async materialize(commit: string): Promise<void> {
|
||||
const safeHead = safeCommit(commit);
|
||||
const files = await this.repository.workspacePaths();
|
||||
const files = await this.repository.workspacePathsAt(safeHead);
|
||||
|
||||
const snapshots: Array<{
|
||||
id: string;
|
||||
@@ -570,7 +559,7 @@ export class WorkspaceRegistry {
|
||||
try {
|
||||
for (const path of files) {
|
||||
const id = path.slice("workspaces/".length, -".yaml".length);
|
||||
const source = await this.repository.readWorkspace(path);
|
||||
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");
|
||||
@@ -588,7 +577,7 @@ export class WorkspaceRegistry {
|
||||
id,
|
||||
source: serializeWorkspaceYaml(workspace),
|
||||
workspace,
|
||||
blob: await this.repository.blob(path),
|
||||
blob: await this.repository.blobAt(safeHead, path),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -631,7 +620,19 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeActiveState({ head: safeHead, revisions });
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -640,7 +641,7 @@ export class WorkspaceRegistry {
|
||||
const active = await this.tryActiveState();
|
||||
if (!active) throw safeError;
|
||||
return {
|
||||
branch: this.config.branch,
|
||||
branch: this.repository.config.branch,
|
||||
head: active.head,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
|
||||
Reference in New Issue
Block a user