feat: split workspace authoring from addressed activation

This commit is contained in:
2026-08-11 20:42:12 +02:00
parent 01c67d4c70
commit 1cc0b50446
4 changed files with 128 additions and 10 deletions
+4 -1
View File
@@ -21,6 +21,7 @@ import { ReadinessManager } from "./runtime/readiness-manager.js";
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
import { WorkspaceRegistry } from "./workspaces/registry.js";
import { GitWorkspaceRepository } from "./workspaces/git-repository.js";
import { WorkspaceAuthorGitService } from "./workspaces/author-git-service.js";
import { WorkspaceFsAtV1 } from "./workspaces/workspace-fs-at.js";
import { VerifiedWorkspaceLockRootLeaseFactory } from "./workspaces/workspace-lock-root-lease.js";
import { CapabilityAwareRegistryPublicationLifecycleOwner, canonicalBootstrapRequestDigest } from "./workspaces/registry-publication.js";
@@ -39,6 +40,7 @@ export interface BuildAppDeps {
readiness?: ReadinessManager;
hub?: SseHub;
workspaceRegistry?: WorkspaceRegistry;
workspaceAuthorService?: WorkspaceAuthorGitService;
workspaceDiagnoser?: WorkspaceDiagnoser;
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
maintenanceBarrier?: MaintenanceBarrier;
@@ -94,6 +96,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
const hub = deps?.hub ?? new SseHub();
const workspaceRegistry = deps?.workspaceRegistry ?? createWorkspaceRegistry(config);
const workspaceAuthorService = deps?.workspaceAuthorService ?? new WorkspaceAuthorGitService(new GitWorkspaceRepository(config.workspaceRegistry));
const workspaceDiagnoser = deps?.workspaceDiagnoser
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
internalQdrantUrl: config.internalQdrantUrl,
@@ -181,7 +184,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
});
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser });
workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser, authorService: workspaceAuthorService });
settingsRoutes(app, { cfg: config, listModels, getSettings });
piManagementRoutes(app, { config, service: piManagement });
+9 -1
View File
@@ -6,6 +6,7 @@ import yauzl from "yauzl";
import yazl from "yazl";
import { z } from "zod";
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
import type { WorkspaceAuthorGitService } from "../workspaces/author-git-service.js";
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
import { addressedRunId } from "../workspaces/registry-publication.js";
import {
@@ -38,6 +39,7 @@ interface WorkspaceRoutesDeps {
registry: WorkspaceRegistry;
config: WorkspaceRegistryConfig;
diagnose: WorkspaceDiagnoser;
authorService: WorkspaceAuthorGitService;
}
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
@@ -372,6 +374,9 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
try {
const requestValue = publishRequest(request.body);
const identity = recoveryIdentity();
// Revalidate the active addressed snapshot before authoring. This pins the
// accepted base while the author service stages and pushes its commit.
await deps.registry.ensureBootstrapAddressed(identity);
const id = requestValue.action === "delete" ? requestValue.id : requestValue.workspace.workspace.id;
const addressed = {
mode: "create" as const, operation: "registry_pull" as const, runId: addressedRunId(),
@@ -381,7 +386,10 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
expectedBaseCommit: requestValue.baseCommit as Revision40,
};
const result = await deps.registry.publishAddressed(addressed, { authoring: requestValue });
// Authoring is intentionally split from activation: this service creates and pushes
// the remote commit only. The addressed registry is the sole pointer owner.
await deps.authorService.publish(requestValue);
const result = await deps.registry.publishAddressed(addressed);
return { revision: revisionFromPublication(result, id) };
} catch (error) {
return errorReply(reply, error);
@@ -0,0 +1,106 @@
import { WorkspaceConflictError, type PublishWorkspaceRequest } from "./registry.js";
import { GitWorkspaceRepository, WorkspaceRegistryError, WorkspaceRepositoryLock } from "./git-repository.js";
import { parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateOperationalWorkspace, type CanonicalWorkspace, type WorkspaceDescriptor } from "./schema.js";
function changedFields(left: unknown, right: unknown, path = ""): string[] {
if (JSON.stringify(left) === JSON.stringify(right)) return [];
if (!left || !right || typeof left !== "object" || typeof right !== "object" || Array.isArray(left) || Array.isArray(right)) return [path || "workspace"];
const keys = new Set([...Object.keys(left as object), ...Object.keys(right as object)]);
return [...keys].sort().flatMap(key => changedFields((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key], path ? `${path}.${key}` : key));
}
export interface WorkspaceAuthorGitResult {
readonly id: string;
readonly commit: string;
readonly blob: string;
}
/**
* The authoring side of workspace publication. This service owns only the author
* checkout and the remote commit; activation is deliberately left to the addressed
* registry owner. In particular, this class never writes state/active.json or a
* snapshot pointer.
*/
export class WorkspaceAuthorGitService {
private readonly lock: WorkspaceRepositoryLock;
constructor(private readonly repository: GitWorkspaceRepository) {
this.lock = new WorkspaceRepositoryLock(repository.locksPath);
}
async publish(request: PublishWorkspaceRequest): Promise<WorkspaceAuthorGitResult> {
await this.repository.ensureLayout();
return this.lock.run(async () => {
const status = await this.repository.pull();
const id = request.action === "delete" ? request.id : request.workspace.workspace.id;
const path = `workspaces/${id}.yaml`;
const current = await this.currentDescriptor(status.head!, path);
const currentBlob = current ? await this.repository.blob(path) : undefined;
if (request.baseCommit !== status.head) {
if (request.action !== "create" && currentBlob === request.baseBlob) {
throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
}
const base = await this.currentDescriptor(request.baseCommit, path);
throw this.conflict(request, status.head!, current, currentBlob, base);
}
if (request.action === "create" && current) throw this.conflict(request, status.head!, current, currentBlob, current);
if (request.action !== "create" && !current) throw this.conflict(request, status.head!, current, currentBlob, current);
if (request.action !== "create") {
if (currentBlob !== request.baseBlob) throw this.conflict(request, status.head!, current, currentBlob, current);
}
if (request.action !== "delete") await this.assertEvidence(request.workspace, status.head!);
const docs = {
contract: `workspace-docs/${id}/contract.env.example`,
readme: `workspace-docs/${id}/README.md`,
};
if (request.action === "delete") {
await this.repository.removeRegistryFile(path);
await this.repository.removeRegistryFile(docs.contract);
await this.repository.removeRegistryFile(docs.readme);
} else {
await this.repository.writeRegistryFile(path, serializeWorkspaceYaml(request.workspace));
const rendered = renderWorkspaceDocs(request.workspace);
await this.repository.writeRegistryFile(docs.contract, rendered.envExample);
await this.repository.writeRegistryFile(docs.readme, rendered.markdown);
}
const published = await this.repository.commitAndPush(
[path, docs.contract, docs.readme],
request.action === "delete" ? `Delete workspace ${id}` : `Publish workspace ${id}`,
);
const commit = published.head;
if (!commit) throw new WorkspaceRegistryError("git_unavailable", "Workspace Git service is unavailable");
if (request.action === "delete") return { id, commit, blob: request.baseBlob };
return { id, commit, blob: await this.repository.blob(path) };
});
}
private async currentDescriptor(commit: string, path: string): Promise<WorkspaceDescriptor | undefined> {
try {
return parseWorkspaceYaml(await this.repository.readWorkspaceAt(commit, path));
} catch (error) {
if (error instanceof WorkspaceRegistryError && error.code === "git_unavailable") return undefined;
throw error;
}
}
private async assertEvidence(workspace: CanonicalWorkspace, commit: string): Promise<void> {
if (workspace.evidence?.source.type !== "filesystem") return;
const uri = workspace.evidence.source.uri;
if (!/^workspace-content\/[a-z][a-z0-9-]{2,62}\/evidence$/.test(uri)) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence root is invalid");
}
await this.repository.assertTreeAtRevision(commit, uri);
}
private conflict(request: PublishWorkspaceRequest, commit: string, remote: WorkspaceDescriptor | undefined, blob: string | undefined, base: WorkspaceDescriptor | undefined): WorkspaceConflictError {
const local = request.action === "delete" ? undefined : request.workspace;
const expected = { commit: request.baseCommit, ...(request.action === "create" ? {} : { blob: request.baseBlob }) };
const actual = { commit, ...(blob ? { blob } : {}) };
const remoteChanges = changedFields(base, remote);
const fields = remoteChanges;
return new WorkspaceConflictError(fields, expected, actual, base, local, remote);
}
}