fix: close addressed publication recovery blockers
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
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 {
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
type WorkspaceDescriptor,
|
||||
} from "./schema.js";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
import type { VerifiedWorkspaceLockRootLeaseFactory, Revision40, CanonicalWorkspaceId } from "./workspace-lock-root-lease.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 RegistryAddressedRequestV1, type RegistryAddressedResultV1, type RegistryBootstrapRecoveryIdentityV1, type RegistryEnsureBootstrapAddressedResultV1, type RegistryActiveSnapshotV1, type RegistryWorkspaceManifestIdentityV1, type RegistryAddressedPublicationStateV1 } from "./registry-publication.js";
|
||||
export type { GitStatus } from "./git-repository.js";
|
||||
@@ -127,17 +128,26 @@ export class WorkspaceRegistry {
|
||||
private readonly repositoryIdentity: WorkspaceRegistryDependencies["repositoryIdentity"];
|
||||
private readonly remoteIdentity: WorkspaceRegistryDependencies["remoteIdentity"];
|
||||
|
||||
constructor(input: WorkspaceRegistryDependencies) {
|
||||
if (!input.repository || !input.installationIdentity || !input.repositoryIdentity || !input.remoteIdentity) throw new Error("WorkspaceRegistry requires bound repository identities");
|
||||
this.repository = input.repository;
|
||||
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);
|
||||
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;
|
||||
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 {
|
||||
@@ -147,6 +157,42 @@ export class WorkspaceRegistry {
|
||||
/** Automatic addressed recovery. The repository lock is held for selection and execution. */
|
||||
recoveryIdentity(): RegistryBootstrapRecoveryIdentityV1 { return this.installationIdentity; }
|
||||
|
||||
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); } });
|
||||
}
|
||||
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); } });
|
||||
}
|
||||
async list(): Promise<WorkspaceRevision[]> {
|
||||
const active = await this.tryActiveState();
|
||||
if (active) return active.revisions;
|
||||
await this.bootstrap();
|
||||
return (await this.activeState()).revisions;
|
||||
}
|
||||
async activate(commit: string): Promise<void> { await this.materialize(commit); await this.publishMaterialized(commit); }
|
||||
async publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> {
|
||||
await this.repository.ensureLayout();
|
||||
return this.lock.run(async () => {
|
||||
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));
|
||||
@@ -197,6 +243,7 @@ export class WorkspaceRegistry {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -238,7 +285,7 @@ export class WorkspaceRegistry {
|
||||
} : {
|
||||
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,
|
||||
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 });
|
||||
}
|
||||
@@ -256,12 +303,12 @@ export class WorkspaceRegistry {
|
||||
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 undefined;
|
||||
}}));
|
||||
return result;
|
||||
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> {
|
||||
@@ -673,7 +720,11 @@ export class WorkspaceRegistry {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user