diff --git a/backend/src/app.ts b/backend/src/app.ts index f948453b..d8b8905b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 }); diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index 104591f8..3aa71746 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -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); diff --git a/backend/src/workspaces/author-git-service.ts b/backend/src/workspaces/author-git-service.ts new file mode 100644 index 00000000..9f97050f --- /dev/null +++ b/backend/src/workspaces/author-git-service.ts @@ -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)[key], (right as Record)[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 { + 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 { + 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 { + 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); + } +} diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index b21af38d..c5feff26 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -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" });