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);
}
}
+9 -8
View File
@@ -143,6 +143,7 @@ function appFor(registry: RegistryFake, diagnose = vi.fn(async () => ({ activata
}), {
thtRunner: {} as any,
workspaceRegistry: registry as WorkspaceRegistry,
workspaceAuthorService: { publish: vi.fn(async () => ({ id: workspace.workspace.id, commit: revision.commit, blob: revision.blob })) } as any,
workspaceDiagnoser: diagnose,
} as any);
}
@@ -397,15 +398,15 @@ test("maps a stale registry commit to HTTP 409 without conflict payloads", async
});
test("publishes accepted CRUD through the addressed request and returns its terminal revision", async () => {
const publishAddressed = vi.fn(async (request: any) => ({
plan: { targetCommit: revision.commit, targetManifestSha256: "a".repeat(64), targetWorkspaces: [{ workspaceId: request.mutation.workspace.workspace.id, revision: revision.commit, descriptorBlob: revision.blob, manifestSha256: "b".repeat(64) }] },
const publishAddressed = vi.fn(async (_request: any) => ({
plan: { targetCommit: revision.commit, targetManifestSha256: "a".repeat(64), targetWorkspaces: [{ workspaceId: workspace.workspace.id, revision: revision.commit, descriptorBlob: revision.blob, manifestSha256: "b".repeat(64) }] },
}));
const registry = registryFake({ publishAddressed });
const app = appFor(registry);
const response = await app.inject({ method: "POST", url: "/workspaces/publish", payload: { action: "create", workspace, baseCommit: revision.commit } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ revision: { id: workspace.workspace.id, commit: revision.commit, blob: revision.blob, snapshotPath: revision.snapshotPath } });
expect(publishAddressed).toHaveBeenCalledWith(expect.objectContaining({ mode: "create", operation: "registry_pull", mutation: expect.objectContaining({ action: "create", baseCommit: revision.commit }) }));
expect(publishAddressed).toHaveBeenCalledWith(expect.objectContaining({ mode: "create", operation: "registry_pull", expectedBaseCommit: revision.commit }));
});
test("exports generated public artifacts without secret values", async () => {
@@ -664,7 +665,7 @@ test("real publish create/update, pull, list, and read preserve a complete Evide
test("real route reports a safe field for an Evidence-only concurrent edit", async () => {
const fixture = await createRealRouteFixture(httpEvidenceWorkspace);
await fixture.registry.bootstrap();
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
const base = await fixture.registry.read("psd-clinical");
const remote = withEvidence(
{ ...httpEvidenceWorkspace.evidence!.source },
@@ -700,7 +701,7 @@ test.each([
["cross-workspace", "workspace-content/research/evidence"],
])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => {
const fixture = await createRealRouteFixture();
await fixture.registry.bootstrap();
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
const base = await fixture.registry.read("psd-clinical");
const invalid = structuredClone(filesystemEvidenceWorkspace) as any;
invalid.evidence.source.uri = uri;
@@ -732,7 +733,7 @@ test.each([
},
])("real publish rejects $label without echoing it or changing HEAD", async ({ source }) => {
const fixture = await createRealRouteFixture();
await fixture.registry.bootstrap();
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
const base = await fixture.registry.read("psd-clinical");
const invalid = structuredClone(base.workspace) as any;
invalid.evidence = { source };
@@ -753,7 +754,7 @@ test.each([
test("real publish and pull fail safely when the contextual Evidence Git tree is missing", async () => {
const fixture = await createRealRouteFixture();
await fixture.registry.bootstrap();
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
const current = await fixture.registry.read("psd-clinical");
const missing = validateWorkspaceDescriptor({
...workspace,
@@ -791,7 +792,7 @@ test("real export and import preserve stable public Evidence artifacts without E
const secretDirectory = join(fixture.root, "fixture-secrets");
mkdirSync(secretDirectory);
writeFileSync(join(secretDirectory, "credential"), SECRET_CANARY);
await fixture.registry.bootstrap();
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
const firstResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });
const secondResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });