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 { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
|
||||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||||
import { GitWorkspaceRepository } from "./workspaces/git-repository.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 { WorkspaceFsAtV1 } from "./workspaces/workspace-fs-at.js";
|
||||||
import { VerifiedWorkspaceLockRootLeaseFactory } from "./workspaces/workspace-lock-root-lease.js";
|
import { VerifiedWorkspaceLockRootLeaseFactory } from "./workspaces/workspace-lock-root-lease.js";
|
||||||
import { CapabilityAwareRegistryPublicationLifecycleOwner, canonicalBootstrapRequestDigest } from "./workspaces/registry-publication.js";
|
import { CapabilityAwareRegistryPublicationLifecycleOwner, canonicalBootstrapRequestDigest } from "./workspaces/registry-publication.js";
|
||||||
@@ -39,6 +40,7 @@ export interface BuildAppDeps {
|
|||||||
readiness?: ReadinessManager;
|
readiness?: ReadinessManager;
|
||||||
hub?: SseHub;
|
hub?: SseHub;
|
||||||
workspaceRegistry?: WorkspaceRegistry;
|
workspaceRegistry?: WorkspaceRegistry;
|
||||||
|
workspaceAuthorService?: WorkspaceAuthorGitService;
|
||||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||||
maintenanceBarrier?: MaintenanceBarrier;
|
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 mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
|
||||||
const hub = deps?.hub ?? new SseHub();
|
const hub = deps?.hub ?? new SseHub();
|
||||||
const workspaceRegistry = deps?.workspaceRegistry ?? createWorkspaceRegistry(config);
|
const workspaceRegistry = deps?.workspaceRegistry ?? createWorkspaceRegistry(config);
|
||||||
|
const workspaceAuthorService = deps?.workspaceAuthorService ?? new WorkspaceAuthorGitService(new GitWorkspaceRepository(config.workspaceRegistry));
|
||||||
const workspaceDiagnoser = deps?.workspaceDiagnoser
|
const workspaceDiagnoser = deps?.workspaceDiagnoser
|
||||||
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
|
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
|
||||||
internalQdrantUrl: config.internalQdrantUrl,
|
internalQdrantUrl: config.internalQdrantUrl,
|
||||||
@@ -181,7 +184,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
});
|
});
|
||||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
|
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
|
||||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
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 });
|
settingsRoutes(app, { cfg: config, listModels, getSettings });
|
||||||
piManagementRoutes(app, { config, service: piManagement });
|
piManagementRoutes(app, { config, service: piManagement });
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import yauzl from "yauzl";
|
|||||||
import yazl from "yazl";
|
import yazl from "yazl";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
|
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
|
||||||
|
import type { WorkspaceAuthorGitService } from "../workspaces/author-git-service.js";
|
||||||
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
||||||
import { addressedRunId } from "../workspaces/registry-publication.js";
|
import { addressedRunId } from "../workspaces/registry-publication.js";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,7 @@ interface WorkspaceRoutesDeps {
|
|||||||
registry: WorkspaceRegistry;
|
registry: WorkspaceRegistry;
|
||||||
config: WorkspaceRegistryConfig;
|
config: WorkspaceRegistryConfig;
|
||||||
diagnose: WorkspaceDiagnoser;
|
diagnose: WorkspaceDiagnoser;
|
||||||
|
authorService: WorkspaceAuthorGitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||||
@@ -372,6 +374,9 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
|||||||
try {
|
try {
|
||||||
const requestValue = publishRequest(request.body);
|
const requestValue = publishRequest(request.body);
|
||||||
const identity = recoveryIdentity();
|
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 id = requestValue.action === "delete" ? requestValue.id : requestValue.workspace.workspace.id;
|
||||||
const addressed = {
|
const addressed = {
|
||||||
mode: "create" as const, operation: "registry_pull" as const, runId: addressedRunId(),
|
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,
|
remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
||||||
expectedBaseCommit: requestValue.baseCommit as Revision40,
|
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) };
|
return { revision: revisionFromPublication(result, id) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorReply(reply, 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,
|
thtRunner: {} as any,
|
||||||
workspaceRegistry: registry as WorkspaceRegistry,
|
workspaceRegistry: registry as WorkspaceRegistry,
|
||||||
|
workspaceAuthorService: { publish: vi.fn(async () => ({ id: workspace.workspace.id, commit: revision.commit, blob: revision.blob })) } as any,
|
||||||
workspaceDiagnoser: diagnose,
|
workspaceDiagnoser: diagnose,
|
||||||
} as any);
|
} 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 () => {
|
test("publishes accepted CRUD through the addressed request and returns its terminal revision", async () => {
|
||||||
const publishAddressed = vi.fn(async (request: any) => ({
|
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) }] },
|
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 registry = registryFake({ publishAddressed });
|
||||||
const app = appFor(registry);
|
const app = appFor(registry);
|
||||||
const response = await app.inject({ method: "POST", url: "/workspaces/publish", payload: { action: "create", workspace, baseCommit: revision.commit } });
|
const response = await app.inject({ method: "POST", url: "/workspaces/publish", payload: { action: "create", workspace, baseCommit: revision.commit } });
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ revision: { id: workspace.workspace.id, commit: revision.commit, blob: revision.blob, snapshotPath: revision.snapshotPath } });
|
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 () => {
|
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 () => {
|
test("real route reports a safe field for an Evidence-only concurrent edit", async () => {
|
||||||
const fixture = await createRealRouteFixture(httpEvidenceWorkspace);
|
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 base = await fixture.registry.read("psd-clinical");
|
||||||
const remote = withEvidence(
|
const remote = withEvidence(
|
||||||
{ ...httpEvidenceWorkspace.evidence!.source },
|
{ ...httpEvidenceWorkspace.evidence!.source },
|
||||||
@@ -700,7 +701,7 @@ test.each([
|
|||||||
["cross-workspace", "workspace-content/research/evidence"],
|
["cross-workspace", "workspace-content/research/evidence"],
|
||||||
])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => {
|
])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => {
|
||||||
const fixture = await createRealRouteFixture();
|
const fixture = await createRealRouteFixture();
|
||||||
await fixture.registry.bootstrap();
|
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
|
||||||
const base = await fixture.registry.read("psd-clinical");
|
const base = await fixture.registry.read("psd-clinical");
|
||||||
const invalid = structuredClone(filesystemEvidenceWorkspace) as any;
|
const invalid = structuredClone(filesystemEvidenceWorkspace) as any;
|
||||||
invalid.evidence.source.uri = uri;
|
invalid.evidence.source.uri = uri;
|
||||||
@@ -732,7 +733,7 @@ test.each([
|
|||||||
},
|
},
|
||||||
])("real publish rejects $label without echoing it or changing HEAD", async ({ source }) => {
|
])("real publish rejects $label without echoing it or changing HEAD", async ({ source }) => {
|
||||||
const fixture = await createRealRouteFixture();
|
const fixture = await createRealRouteFixture();
|
||||||
await fixture.registry.bootstrap();
|
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
|
||||||
const base = await fixture.registry.read("psd-clinical");
|
const base = await fixture.registry.read("psd-clinical");
|
||||||
const invalid = structuredClone(base.workspace) as any;
|
const invalid = structuredClone(base.workspace) as any;
|
||||||
invalid.evidence = { source };
|
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 () => {
|
test("real publish and pull fail safely when the contextual Evidence Git tree is missing", async () => {
|
||||||
const fixture = await createRealRouteFixture();
|
const fixture = await createRealRouteFixture();
|
||||||
await fixture.registry.bootstrap();
|
await fixture.registry.ensureBootstrapAddressed(fixture.registry.recoveryIdentity());
|
||||||
const current = await fixture.registry.read("psd-clinical");
|
const current = await fixture.registry.read("psd-clinical");
|
||||||
const missing = validateWorkspaceDescriptor({
|
const missing = validateWorkspaceDescriptor({
|
||||||
...workspace,
|
...workspace,
|
||||||
@@ -791,7 +792,7 @@ test("real export and import preserve stable public Evidence artifacts without E
|
|||||||
const secretDirectory = join(fixture.root, "fixture-secrets");
|
const secretDirectory = join(fixture.root, "fixture-secrets");
|
||||||
mkdirSync(secretDirectory);
|
mkdirSync(secretDirectory);
|
||||||
writeFileSync(join(secretDirectory, "credential"), SECRET_CANARY);
|
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 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" });
|
const secondResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });
|
||||||
|
|||||||
Reference in New Issue
Block a user