fix: route workspace CRUD through addressed publication

This commit is contained in:
2026-08-11 18:30:06 +02:00
parent 4a0ec61da3
commit 29a2e507a9
5 changed files with 120 additions and 35 deletions
+50 -8
View File
@@ -20,7 +20,7 @@ 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 RegistryAddressedRequestV1, type RegistryAddressedResultV1, type RegistryBootstrapRecoveryIdentityV1, type RegistryEnsureBootstrapAddressedResultV1, type RegistryActiveSnapshotV1, type RegistryWorkspaceManifestIdentityV1, type RegistryAddressedPublicationStateV1 } from "./registry-publication.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 {
@@ -157,24 +157,28 @@ export class WorkspaceRegistry {
/** Automatic addressed recovery. The repository lock is held for selection and execution. */
recoveryIdentity(): RegistryBootstrapRecoveryIdentityV1 { return this.installationIdentity; }
async bootstrap(): Promise<GitStatus> {
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); } });
}
async pull(): Promise<GitStatus> {
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); } });
}
async list(): Promise<WorkspaceRevision[]> {
private 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> {
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(async () => {
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;
@@ -190,7 +194,6 @@ export class WorkspaceRegistry {
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> {
@@ -226,6 +229,10 @@ export class WorkspaceRegistry {
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); }
@@ -248,6 +255,41 @@ export class WorkspaceRegistry {
});
}
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!);