feat: split workspace authoring from addressed activation
This commit is contained in:
+4
-1
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user