feat: activate workspaces from the root catalog and bootstrap-only publication

This commit is contained in:
2026-08-11 14:49:46 +02:00
parent 86af45acb4
commit 25ec236f1f
10 changed files with 358 additions and 242 deletions
+12 -15
View File
@@ -66,6 +66,7 @@ const SAFE_MESSAGES = {
workspace_not_activatable: "Workspace cannot be activated on this installation.", workspace_not_activatable: "Workspace cannot be activated on this installation.",
workspace_stale: "Workspace revision is stale.", workspace_stale: "Workspace revision is stale.",
workspace_conflict: "Workspace changed in the registry.", workspace_conflict: "Workspace changed in the registry.",
workspace_curator_owned: "Workspace descriptor is owned by the curator and must be changed through Git.",
git_unavailable: "Workspace Git service is unavailable.", git_unavailable: "Workspace Git service is unavailable.",
git_auth_failed: "Workspace Git authentication failed.", git_auth_failed: "Workspace Git authentication failed.",
git_non_fast_forward: "Workspace Git branch has changed.", git_non_fast_forward: "Workspace Git branch has changed.",
@@ -225,7 +226,7 @@ function workspaceErrorCode(error: unknown): keyof typeof SAFE_MESSAGES {
} }
function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number { function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number {
if (code === "workspace_conflict" || code === "workspace_stale" || code === "git_non_fast_forward") return 409; if (code === "workspace_conflict" || code === "workspace_curator_owned" || code === "workspace_stale" || code === "git_non_fast_forward") return 409;
if (code === "git_unavailable" || code === "git_auth_failed" || code === "git_push_rejected") return 503; if (code === "git_unavailable" || code === "git_auth_failed" || code === "git_push_rejected") return 503;
return 400; return 400;
} }
@@ -297,20 +298,16 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
app.get("/workspaces", async (_request, reply) => { app.get("/workspaces", async (_request, reply) => {
try { try {
const revisions = await deps.registry.list(); const records = await deps.registry.listCatalog();
return await Promise.all(revisions.map(async (revision) => { return records.map((record) => ({
const { workspace } = await deps.registry.read(revision.id); id: record.id,
return { // Retain the metadata endpoint's selector field while adding catalog metadata.
id: revision.id, name: record.id,
// Retain the metadata endpoint's selector fields while adding registry summary data. file: `${record.id}/workspace.yaml`,
name: revision.id, displayName: record.name,
file: `${revision.id}.yaml`, description: record.description,
displayName: workspace.workspace.name, configurationState: record.configurationState,
description: workspace.workspace.description, ...(record.revision ? { revision: record.revision } : {}),
language: workspace.workspace.language,
workspace,
revision,
};
})); }));
} catch (error) { } catch (error) {
return errorReply(reply, error); return errorReply(reply, error);
+27 -6
View File
@@ -129,19 +129,25 @@ export class GitWorkspaceRepository {
}; };
} }
async workspacePaths(): Promise<string[]> { async workspaceDirectories(): Promise<string[]> {
const output = await this.git(["ls-tree", "-d", "--name-only", "HEAD"]); const output = await this.git(["ls-tree", "-d", "--name-only", "HEAD"]);
const directories = output.trim() === "" ? [] : output.trim().split("\n"); const directories = output.trim() === "" ? [] : output.trim().split("\n");
const paths: string[] = [];
for (const id of directories) { for (const id of directories) {
if (id === "workspace-docs") continue; if (id === "workspace-docs") continue;
if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path"); throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path");
} }
}
return directories.filter((id) => id !== "workspace-docs").sort();
}
async workspacePaths(): Promise<string[]> {
const paths: string[] = [];
for (const id of await this.workspaceDirectories()) {
const path = `${id}/workspace.yaml`; const path = `${id}/workspace.yaml`;
const type = (await this.git(["cat-file", "-t", `HEAD:${path}`], {}, const type = await this.gitOptional(["cat-file", "-t", `HEAD:${path}`]);
"Workspace descriptor is invalid")).trim(); if (type === undefined) continue;
if (type !== "blob") { if (type.trim() !== "blob") {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is invalid"); throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is invalid");
} }
paths.push(path); paths.push(path);
@@ -216,11 +222,14 @@ export class GitWorkspaceRepository {
await rm(join(this.repoPath, path), { force: true }); await rm(join(this.repoPath, path), { force: true });
} }
private pendingPublicationPaths: string[] = [];
/** Commit and push a fixed set of validated artifact paths without exposing Git output. */ /** Commit and push a fixed set of validated artifact paths without exposing Git output. */
async commitAndPush(paths: readonly string[], message: string): Promise<GitStatus> { async commitAndPush(paths: readonly string[], message: string): Promise<GitStatus> {
if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) { if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
} }
this.pendingPublicationPaths = [...paths];
try { try {
await this.git(["add", "--", ...paths]); await this.git(["add", "--", ...paths]);
await this.git(["commit", "-m", message], this.publicationIdentity()); await this.git(["commit", "-m", message], this.publicationIdentity());
@@ -291,7 +300,19 @@ export class GitWorkspaceRepository {
private async restoreFailedPublication(): Promise<void> { private async restoreFailedPublication(): Promise<void> {
try { try {
await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]); await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]);
await this.git(["clean", "-fd", "--", "workspace-docs"]); // Remove only the exact untracked files this publication created, never curated content.
const untracked = this.pendingPublicationPaths.filter((path) => {
try {
lstatSync(join(this.repoPath, path));
return true;
} catch {
return false;
}
});
if (untracked.length > 0) {
await this.git(["clean", "-fd", "--", ...untracked]);
}
this.pendingPublicationPaths = [];
} catch { } catch {
// Keep the original sanitized publish failure. A future refresh will surface any recovery // Keep the original sanitized publish failure. A future refresh will surface any recovery
// problem without leaking the Git failure details through the API. // problem without leaking the Git failure details through the API.
+104 -44
View File
@@ -3,6 +3,7 @@ import { lstatSync } from "node:fs";
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path"; import { isAbsolute, join } from "node:path";
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js"; import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
import { assertCatalogMatchesDescriptor, parseWorkspaceCatalogYaml, type WorkspaceCatalog, type WorkspaceCatalogEntry } from "./catalog.js";
import { import {
GitWorkspaceRepository, GitWorkspaceRepository,
WorkspaceRegistryError, WorkspaceRegistryError,
@@ -58,6 +59,7 @@ export class WorkspaceConflictError extends WorkspaceRegistryError {
interface ActiveState { interface ActiveState {
head: string; head: string;
revisions: WorkspaceRevision[]; revisions: WorkspaceRevision[];
catalog?: WorkspaceCatalog;
} }
interface SnapshotManifest extends ActiveState { interface SnapshotManifest extends ActiveState {
@@ -76,7 +78,7 @@ function workspacePath(id: string): string {
if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID is invalid"); throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID is invalid");
} }
return `workspaces/${id}.yaml`; return `${id}/workspace.yaml`;
} }
function safeCommit(commit: string): string { function safeCommit(commit: string): string {
@@ -113,7 +115,7 @@ export class WorkspaceRegistry {
} }
snapshotPath(commit: string, id: string): string { snapshotPath(commit: string, id: string): string {
return join(this.repository.snapshotsPath, safeCommit(commit), `${workspacePath(id).slice("workspaces/".length)}`); return join(this.repository.snapshotsPath, safeCommit(commit), `${id}.yaml`);
} }
async bootstrap(): Promise<GitStatus> { async bootstrap(): Promise<GitStatus> {
@@ -142,6 +144,26 @@ export class WorkspaceRegistry {
}); });
} }
async listCatalog(): Promise<Array<WorkspaceCatalogEntry & {
configurationState: "ready" | "configuration_required";
revision?: WorkspaceRevision;
}>> {
const active = await this.tryActiveState();
if (!active) {
await this.bootstrap();
return await this.listCatalog();
}
const catalog = active.catalog ?? { schema_version: 1 as const, workspaces: [] };
return catalog.workspaces.map((entry) => ({
...entry,
configurationState: active.revisions.some((revision) => revision.id === entry.id)
? "ready" as const : "configuration_required" as const,
...(active.revisions.find((revision) => revision.id === entry.id)
? { revision: active.revisions.find((revision) => revision.id === entry.id) }
: {}),
}));
}
async list(): Promise<WorkspaceRevision[]> { async list(): Promise<WorkspaceRevision[]> {
const active = await this.tryActiveState(); const active = await this.tryActiveState();
if (active) return active.revisions; if (active) return active.revisions;
@@ -357,48 +379,49 @@ export class WorkspaceRegistry {
async publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> { async publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> {
await this.repository.ensureLayout(); await this.repository.ensureLayout();
return await this.lock.run(async () => { return await this.lock.run(async () => {
if (request.action !== "create") {
throw new WorkspaceRegistryError(
"workspace_curator_owned",
"Workspace descriptors are curator-owned and must be changed through Git",
);
}
const status = await this.repository.pull(); const status = await this.repository.pull();
await this.activate(status.head!); await this.activate(status.head!);
const current = await this.activeState(); const current = await this.activeState();
const id = request.action === "delete" ? request.id : request.workspace.workspace.id; const id = request.workspace.workspace.id;
const existing = current.revisions.find((revision) => revision.id === id); const existing = current.revisions.find((revision) => revision.id === id);
const local = request.action === "delete" ? undefined : request.workspace; if (existing) {
throw new WorkspaceRegistryError(
if (request.baseCommit !== status.head || ( "workspace_curator_owned",
request.action !== "create" && existing?.blob !== request.baseBlob "Workspace descriptor is curator-owned and must be changed through Git",
)) { );
const contentOnlyStale = request.action !== "create"
&& request.baseCommit !== status.head
&& existing?.blob === request.baseBlob;
if (contentOnlyStale) {
throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
}
throw await this.conflictFor(request, status.head!, existing, local);
} }
if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local); if (request.baseCommit !== status.head) {
if (request.action !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local); throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
if (request.action !== "delete") {
await this.assertEvidenceContext(request.workspace, status.head!);
} }
const catalog = current.catalog ?? { schema_version: 1 as const, workspaces: [] };
const entry = catalog.workspaces.find((candidate) => candidate.id === id);
if (!entry) {
throw new WorkspaceRegistryError(
"workspace_invalid",
"Workspace is not listed in the root catalog",
);
}
assertCatalogMatchesDescriptor(entry, request.workspace);
await this.assertEvidenceContext(request.workspace, status.head!);
const yamlPath = workspacePath(id); const yamlPath = workspacePath(id);
const docPaths = this.documentationPaths(id); const docPaths = this.documentationPaths(id);
if (request.action === "delete") { const canonical = request.workspace;
await this.repository.removeRegistryFile(yamlPath); const source = serializeWorkspaceYaml(canonical);
await this.repository.removeRegistryFile(docPaths.contract); const docs = renderWorkspaceDocs(canonical);
await this.repository.removeRegistryFile(docPaths.readme); await this.repository.createRegistryFile(yamlPath, source);
} else { await this.repository.writeRegistryFile(docPaths.contract, docs.envExample);
const canonical = request.workspace; await this.repository.writeRegistryFile(docPaths.readme, docs.markdown);
const source = serializeWorkspaceYaml(canonical);
const docs = renderWorkspaceDocs(canonical);
await this.repository.writeRegistryFile(yamlPath, source);
await this.repository.writeRegistryFile(docPaths.contract, docs.envExample);
await this.repository.writeRegistryFile(docPaths.readme, docs.markdown);
}
const next = await this.repository.commitAndPush( const next = await this.repository.commitAndPush(
[yamlPath, docPaths.contract, docPaths.readme], [yamlPath, docPaths.contract, docPaths.readme],
request.action === "delete" ? `Delete workspace ${id}` : `Publish workspace ${id}`, `Publish workspace ${id}`,
); );
await this.activate(next.head!); await this.activate(next.head!);
return (await this.activeState()).revisions.find((revision) => revision.id === id); return (await this.activeState()).revisions.find((revision) => revision.id === id);
@@ -478,6 +501,13 @@ export class WorkspaceRegistry {
private async activate(commit: string): Promise<void> { private async activate(commit: string): Promise<void> {
const safeHead = safeCommit(commit); const safeHead = safeCommit(commit);
const catalog = parseWorkspaceCatalogYaml(await this.repository.readCatalog(safeHead));
const catalogById = new Map(catalog.workspaces.map((entry) => [entry.id, entry]));
for (const id of await this.repository.workspaceDirectories()) {
if (!catalogById.has(id)) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace directory is not listed in the catalog");
}
}
const files = await this.repository.workspacePaths(); const files = await this.repository.workspacePaths();
const snapshots: Array<{ const snapshots: Array<{
@@ -489,12 +519,15 @@ export class WorkspaceRegistry {
const collectionOwners = new Map<string, string>(); const collectionOwners = new Map<string, string>();
try { try {
for (const path of files) { for (const path of files) {
const id = path.slice("workspaces/".length, -".yaml".length); const id = path.slice(0, -"/workspace.yaml".length);
const source = await this.repository.readWorkspace(path); const entry = catalogById.get(id);
if (!entry) throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is not listed in the catalog");
const source = await this.repository.readWorkspace(path, safeHead);
const workspace = parseWorkspaceYaml(source); const workspace = parseWorkspaceYaml(source);
if (workspace.workspace.id !== id) { if (workspace.workspace.id !== id) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path"); throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
} }
assertCatalogMatchesDescriptor(entry, workspace);
await this.assertEvidenceContext(workspace, safeHead); await this.assertEvidenceContext(workspace, safeHead);
const collection = workspace.semantic_index.vector_store.collection; const collection = workspace.semantic_index.vector_store.collection;
const owner = collectionOwners.get(collection); const owner = collectionOwners.get(collection);
@@ -508,7 +541,7 @@ export class WorkspaceRegistry {
id, id,
source: serializeWorkspaceYaml(workspace), source: serializeWorkspaceYaml(workspace),
workspace, workspace,
blob: await this.repository.blob(path), blob: await this.repository.blob(path, safeHead),
}); });
} }
} catch (error) { } catch (error) {
@@ -523,7 +556,7 @@ export class WorkspaceRegistry {
snapshotPath: this.snapshotPath(safeHead, snapshot.id), snapshotPath: this.snapshotPath(safeHead, snapshot.id),
})); }));
if (this.pathExists(snapshotDirectory)) { if (this.pathExists(snapshotDirectory)) {
await this.assertSnapshotIntegrity({ head: safeHead, revisions }); await this.assertSnapshotIntegrity({ head: safeHead, revisions, catalog });
} else { } else {
const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`); const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`);
await mkdir(staging, { mode: 0o700 }); await mkdir(staging, { mode: 0o700 });
@@ -541,7 +574,7 @@ export class WorkspaceRegistry {
files[envName] = digest(docs.envExample); files[envName] = digest(docs.envExample);
files[docsName] = digest(docs.markdown); files[docsName] = digest(docs.markdown);
} }
await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, files }), { await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, catalog, files }), {
encoding: "utf8", mode: 0o400, encoding: "utf8", mode: 0o400,
}); });
await rename(staging, snapshotDirectory); await rename(staging, snapshotDirectory);
@@ -551,7 +584,7 @@ export class WorkspaceRegistry {
} }
} }
await this.writeActiveState({ head: safeHead, revisions }); await this.writeActiveState({ head: safeHead, revisions, catalog });
} }
private async gitFallback(error: unknown): Promise<GitStatus> { private async gitFallback(error: unknown): Promise<GitStatus> {
@@ -596,13 +629,15 @@ export class WorkspaceRegistry {
} }
private decodeActiveState(value: unknown): ActiveState { private decodeActiveState(value: unknown): ActiveState {
const state = this.strictObject(value, ["head", "revisions"]); const record = this.optionalKeyObject(value, ["head", "revisions"], ["catalog"]);
return this.decodeStateRevisions(state.head, state.revisions); const state = this.decodeStateRevisions(record.head, record.revisions);
return record.catalog === undefined ? state : { ...state, catalog: this.decodeCatalog(record.catalog) };
} }
private decodeSnapshotManifest(value: unknown): SnapshotManifest { private decodeSnapshotManifest(value: unknown): SnapshotManifest {
const manifest = this.strictObject(value, ["head", "revisions", "files"]); const manifest = this.optionalKeyObject(value, ["head", "revisions", "files"], ["catalog"]);
const state = this.decodeStateRevisions(manifest.head, manifest.revisions); const state = this.decodeStateRevisions(manifest.head, manifest.revisions);
const catalog = manifest.catalog === undefined ? undefined : this.decodeCatalog(manifest.catalog);
if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) { if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) {
throw new Error("bad manifest files"); throw new Error("bad manifest files");
} }
@@ -610,7 +645,7 @@ export class WorkspaceRegistry {
if (entries.some(([, contentsDigest]) => typeof contentsDigest !== "string")) { if (entries.some(([, contentsDigest]) => typeof contentsDigest !== "string")) {
throw new Error("bad manifest files"); throw new Error("bad manifest files");
} }
return { ...state, files: Object.fromEntries(entries) as Record<string, string> }; return { ...state, ...(catalog ? { catalog } : {}), files: Object.fromEntries(entries) as Record<string, string> };
} }
private decodeStateRevisions(headValue: unknown, revisionsValue: unknown): ActiveState { private decodeStateRevisions(headValue: unknown, revisionsValue: unknown): ActiveState {
@@ -664,6 +699,30 @@ export class WorkspaceRegistry {
return { id, commit, blob, snapshotPath }; return { id, commit, blob, snapshotPath };
} }
private decodeCatalog(value: unknown): WorkspaceCatalog {
if (typeof value !== "object" || value === null) throw new Error("bad catalog");
return parseWorkspaceCatalogYaml(JSON.stringify(value));
}
private optionalKeyObject(
value: unknown,
requiredKeys: readonly string[],
optionalKeys: readonly string[],
): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state");
const record = value as Record<string, unknown>;
const allowed = new Set([...requiredKeys, ...optionalKeys]);
const keys = Object.keys(record);
if (
keys.length !== requiredKeys.length + optionalKeys.length
|| !requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(record, key))
|| !keys.every((key) => allowed.has(key))
) {
throw new Error("bad state");
}
return record;
}
private strictObject(value: unknown, expectedKeys: readonly string[]): Record<string, unknown> { private strictObject(value: unknown, expectedKeys: readonly string[]): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state"); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state");
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
@@ -692,8 +751,9 @@ export class WorkspaceRegistry {
const directory = join(this.repository.snapshotsPath, state.head); const directory = join(this.repository.snapshotsPath, state.head);
try { try {
const manifest = this.decodeSnapshotManifest(await this.readSnapshotManifest(state.head)); const manifest = this.decodeSnapshotManifest(await this.readSnapshotManifest(state.head));
if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) { if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)
throw new Error("manifest revisions do not match active state"); || JSON.stringify(manifest.catalog ?? null) !== JSON.stringify(state.catalog ?? null)) {
throw new Error("manifest state does not match active state");
} }
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state)); await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state));
await this.assertSnapshotEvidenceContexts(state); await this.assertSnapshotEvidenceContexts(state);
+48 -43
View File
@@ -127,6 +127,9 @@ function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false, branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false,
})), })),
list: vi.fn(async () => [revision]), list: vi.fn(async () => [revision]),
listCatalog: vi.fn(async () => [{
id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision,
}]),
read: vi.fn(async () => ({ workspace, revision })), read: vi.fn(async () => ({ workspace, revision })),
publish: vi.fn(async () => revision), publish: vi.fn(async () => revision),
...overrides, ...overrides,
@@ -232,7 +235,7 @@ test("lists compatible workspace summaries and reads a validated workspace", asy
expect(list.statusCode).toBe(200); expect(list.statusCode).toBe(200);
expect(list.json()).toEqual([expect.objectContaining({ expect(list.json()).toEqual([expect.objectContaining({
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "Policlinico San Donato", id: "psd-clinical", name: "psd-clinical", file: "psd-clinical/workspace.yaml", displayName: "Policlinico San Donato", configurationState: "ready",
})]); })]);
expect(detail.statusCode).toBe(200); expect(detail.statusCode).toBe(200);
expect(detail.json()).toMatchObject({ workspace, revision }); expect(detail.json()).toMatchObject({ workspace, revision });
@@ -455,7 +458,7 @@ function withEvidence(
} }
const filesystemEvidenceWorkspace = withEvidence({ const filesystemEvidenceWorkspace = withEvidence({
type: "filesystem", uri: "workspace-content/psd-clinical/evidence", type: "filesystem", uri: "psd-clinical/evidence",
}); });
const httpEvidenceWorkspace = withEvidence({ const httpEvidenceWorkspace = withEvidence({
type: "http", type: "http",
@@ -480,12 +483,21 @@ async function createRealRouteFixture(
await realGit(author, ["init", "--initial-branch=main"]); await realGit(author, ["init", "--initial-branch=main"]);
await realGit(author, ["config", "user.name", "Workspace Route Test"]); await realGit(author, ["config", "user.name", "Workspace Route Test"]);
await realGit(author, ["config", "user.email", "workspace-route@example.invalid"]); await realGit(author, ["config", "user.email", "workspace-route@example.invalid"]);
mkdirSync(join(author, "workspaces")); const catalogName = initialWorkspace.workspace.name;
writeFileSync(join(author, "workspaces", "psd-clinical.yaml"), serializeWorkspaceYaml(initialWorkspace)); const catalogDescription = initialWorkspace.workspace.description;
writeFileSync(join(author, "thoth-workspaces.yaml"), [
"schema_version: 1",
"workspaces:",
` - id: psd-clinical\n name: ${catalogName}${catalogDescription ? `\n description: ${catalogDescription}` : ""}`,
` - id: research-clinical\n name: Research Clinical${catalogDescription ? `\n description: ${catalogDescription}` : ""}`,
` - id: missing-evidence\n name: Missing Evidence${catalogDescription ? `\n description: ${catalogDescription}` : ""}`,
].join("\n") + "\n");
mkdirSync(join(author, "psd-clinical"), { recursive: true });
writeFileSync(join(author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(initialWorkspace));
if (initialWorkspace.evidence?.source.type === "filesystem") { if (initialWorkspace.evidence?.source.type === "filesystem") {
mkdirSync(join(author, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); mkdirSync(join(author, "psd-clinical", "evidence"), { recursive: true });
writeFileSync( writeFileSync(
join(author, "workspace-content", "psd-clinical", "evidence", "guide.md"), join(author, "psd-clinical", "evidence", "guide.md"),
EVIDENCE_FILE_BYTES, EVIDENCE_FILE_BYTES,
); );
} }
@@ -545,7 +557,7 @@ afterEach(() => {
test.each([ test.each([
{ {
source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, source: { type: "filesystem", uri: "psd-clinical/evidence" },
expectedVariables: [], expectedVariables: [],
}, },
{ {
@@ -582,7 +594,7 @@ test.each([
.map(({ name }: { name: string }) => name)).toEqual(expectedVariables); .map(({ name }: { name: string }) => name)).toEqual(expectedVariables);
}); });
test("real publish create/update, pull, list, and read preserve a complete Evidence descriptor", async () => { test("real bootstrap create, curator push/pull, list, and read preserve a complete Evidence descriptor", async () => {
const fixture = await createRealRouteFixture(httpEvidenceWorkspace); const fixture = await createRealRouteFixture(httpEvidenceWorkspace);
const status = await fixture.app.inject({ method: "GET", url: "/workspace-registry/status" }); const status = await fixture.app.inject({ method: "GET", url: "/workspace-registry/status" });
const created = validateWorkspaceDescriptor({ const created = validateWorkspaceDescriptor({
@@ -597,57 +609,52 @@ test("real publish create/update, pull, list, and read preserve a complete Evide
method: "POST", url: "/workspaces/publish", method: "POST", url: "/workspaces/publish",
payload: { action: "create", workspace: created, baseCommit: status.json().head }, payload: { action: "create", workspace: created, baseCommit: status.json().head },
}); });
expect(create.statusCode).toBe(200);
const createdRevision = create.json().revision as WorkspaceRevision; const createdRevision = create.json().revision as WorkspaceRevision;
const updated = validateWorkspaceDescriptor({
await realGit(fixture.author, ["pull", "--ff-only", "origin", "main"]);
const remotelyEdited = validateWorkspaceDescriptor({
...created, ...created,
evidence: { evidence: {
...created.evidence, ...created.evidence,
policy: { max_chunk_chars: 8_192, retain_published_generations: 7 },
},
});
const update = await fixture.app.inject({
method: "POST", url: "/workspaces/publish",
payload: {
action: "update", workspace: updated,
baseCommit: createdRevision.commit, baseBlob: createdRevision.blob,
},
});
expect(update.statusCode).toBe(200);
await realGit(fixture.author, ["pull", "--ff-only", "origin", "main"]);
const remotelyEdited = validateWorkspaceDescriptor({
...updated,
evidence: {
...updated.evidence,
policy: { max_chunk_chars: 9_001, retain_published_generations: 9 }, policy: { max_chunk_chars: 9_001, retain_published_generations: 9 },
}, },
}); });
writeFileSync( writeFileSync(
join(fixture.author, "workspaces", "research-clinical.yaml"), join(fixture.author, "research-clinical", "workspace.yaml"),
serializeWorkspaceYaml(remotelyEdited), serializeWorkspaceYaml(remotelyEdited),
); );
await realGit(fixture.author, ["add", "workspaces/research-clinical.yaml"]); await realGit(fixture.author, ["add", "research-clinical/workspace.yaml"]);
await realGit(fixture.author, ["commit", "-m", "Remote Evidence-only descriptor edit"]); await realGit(fixture.author, ["commit", "-m", "Remote Evidence-only descriptor edit"]);
await realGit(fixture.author, ["push", "origin", "main"]); await realGit(fixture.author, ["push", "origin", "main"]);
const remoteCommit = await realGit(fixture.author, ["rev-parse", "HEAD"]); const remoteCommit = await realGit(fixture.author, ["rev-parse", "HEAD"]);
const update = await fixture.app.inject({
method: "POST", url: "/workspaces/publish",
payload: {
action: "update", workspace: remotelyEdited,
baseCommit: createdRevision.commit, baseBlob: createdRevision.blob,
},
});
expect(update.statusCode).toBe(409);
expect(update.json()).toMatchObject({ code: "workspace_curator_owned" });
const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" }); const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" });
const list = await fixture.app.inject({ method: "GET", url: "/workspaces" }); const list = await fixture.app.inject({ method: "GET", url: "/workspaces" });
const read = await fixture.app.inject({ method: "GET", url: "/workspaces/research-clinical" }); const read = await fixture.app.inject({ method: "GET", url: "/workspaces/research-clinical" });
expect(status.statusCode).toBe(200); expect(status.statusCode).toBe(200);
expect(create.statusCode).toBe(200); expect(create.statusCode).toBe(200);
expect(update.statusCode).toBe(200);
expect(pull.statusCode).toBe(200); expect(pull.statusCode).toBe(200);
expect(pull.json().head).toBe(remoteCommit); expect(pull.json().head).toBe(remoteCommit);
expect(list.statusCode).toBe(200); expect(list.statusCode).toBe(200);
expect(list.json().find(({ id }: { id: string }) => id === "research-clinical").workspace) const summary = list.json().find(({ id }: { id: string }) => id === "research-clinical");
.toEqual(remotelyEdited); expect(summary.configurationState).toBe("ready");
expect(summary.revision.commit).toBe(remoteCommit);
expect(read.statusCode).toBe(200); expect(read.statusCode).toBe(200);
expect(read.json().workspace).toEqual(remotelyEdited); expect(read.json().workspace).toEqual(remotelyEdited);
}); });
test("real route refuses curator-owned updates with a safe 409 after 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.bootstrap();
const base = await fixture.registry.read("psd-clinical"); const base = await fixture.registry.read("psd-clinical");
@@ -655,8 +662,8 @@ test("real route reports a safe field for an Evidence-only concurrent edit", asy
{ ...httpEvidenceWorkspace.evidence!.source }, { ...httpEvidenceWorkspace.evidence!.source },
{ max_chunk_chars: 9_000, retain_published_generations: 3 }, { max_chunk_chars: 9_000, retain_published_generations: 3 },
); );
writeFileSync(join(fixture.author, "workspaces", "psd-clinical.yaml"), serializeWorkspaceYaml(remote)); writeFileSync(join(fixture.author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(remote));
await realGit(fixture.author, ["add", "workspaces/psd-clinical.yaml"]); await realGit(fixture.author, ["add", "psd-clinical/workspace.yaml"]);
await realGit(fixture.author, ["commit", "-m", "Change Evidence policy only"]); await realGit(fixture.author, ["commit", "-m", "Change Evidence policy only"]);
await realGit(fixture.author, ["push", "origin", "main"]); await realGit(fixture.author, ["push", "origin", "main"]);
const local = withEvidence( const local = withEvidence(
@@ -673,16 +680,13 @@ test("real route reports a safe field for an Evidence-only concurrent edit", asy
}); });
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ expect(response.json()).toMatchObject({ code: "workspace_curator_owned" });
code: "workspace_conflict", fields: ["evidence.policy.max_chunk_chars"],
});
expect(response.body).not.toContain(SECRET_CANARY); expect(response.body).not.toContain(SECRET_CANARY);
}); });
test.each([ test.each([
["absolute", "/tmp/CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], ["absolute", "/tmp/CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"],
["traversal", "workspace-content/psd-clinical/../CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], ["traversal", "psd-clinical/../CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"],
["cross-workspace", "workspace-content/research/evidence"], ["cross-workspace", "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.bootstrap();
@@ -747,7 +751,7 @@ test("real publish and pull fail safely when the contextual Evidence Git tree is
...workspace.semantic_index, ...workspace.semantic_index,
vector_store: { ...workspace.semantic_index.vector_store, collection: "missing-evidence" }, vector_store: { ...workspace.semantic_index.vector_store, collection: "missing-evidence" },
}, },
evidence: { source: { type: "filesystem", uri: "workspace-content/missing-evidence/evidence" } }, evidence: { source: { type: "filesystem", uri: "missing-evidence/evidence" } },
}); });
const publish = await fixture.app.inject({ const publish = await fixture.app.inject({
method: "POST", url: "/workspaces/publish", method: "POST", url: "/workspaces/publish",
@@ -755,10 +759,11 @@ test("real publish and pull fail safely when the contextual Evidence Git tree is
}); });
expect(publish.statusCode).toBe(400); expect(publish.statusCode).toBe(400);
expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." });
expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." });
expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"]))
.toBe(fixture.initialCommit); .toBe(fixture.initialCommit);
rmSync(join(fixture.author, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); rmSync(join(fixture.author, "psd-clinical", "evidence"), { recursive: true });
await realGit(fixture.author, ["add", "-A"]); await realGit(fixture.author, ["add", "-A"]);
await realGit(fixture.author, ["commit", "-m", "Remove Evidence tree"]); await realGit(fixture.author, ["commit", "-m", "Remove Evidence tree"]);
await realGit(fixture.author, ["push", "origin", "main"]); await realGit(fixture.author, ["push", "origin", "main"]);
+130 -96
View File
@@ -42,7 +42,7 @@ function withFilesystemEvidence(source: string, id = "psd-clinical"): string {
return source.concat(`evidence: return source.concat(`evidence:
source: source:
type: filesystem type: filesystem
uri: workspace-content/${id}/evidence uri: ${id}/evidence
`); `);
} }
@@ -177,6 +177,14 @@ async function gitOutput(cwd: string, args: string[]): Promise<string> {
return stdout.trim(); return stdout.trim();
} }
function catalogYaml(entries: Array<{ id: string; name: string; description?: string }>): string {
return `schema_version: 1
workspaces:
${entries.map((entry) => ` - id: ${entry.id}
name: ${entry.name}${entry.description ? `\n description: ${entry.description}` : ""}`).join("\n")}
`;
}
async function fixture(workspaceSource = validYaml): Promise<{ async function fixture(workspaceSource = validYaml): Promise<{
root: string; remote: string; source: string; initialCommit: string; root: string; remote: string; source: string; initialCommit: string;
}> { }> {
@@ -189,16 +197,22 @@ async function fixture(workspaceSource = validYaml): Promise<{
await git(source, ["init", "--initial-branch=main"]); await git(source, ["init", "--initial-branch=main"]);
await git(source, ["config", "user.name", "Workspace Registry Test"]); await git(source, ["config", "user.name", "Workspace Registry Test"]);
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
mkdirSync(join(source, "workspaces")); const workspace = workspaceSource.includes("schema_version: 3")
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource); ? parseWorkspaceYaml(workspaceSource) : undefined;
mkdirSync(join(source, "psd-clinical"), { recursive: true });
writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: workspace?.workspace.name ?? "Policlinico San Donato", ...(workspace?.workspace.description ? { description: workspace.workspace.description } : {}) },
{ id: "research", name: "research" },
]));
writeFileSync(join(source, "psd-clinical", "workspace.yaml"), workspaceSource);
if (workspaceSource.includes("type: filesystem")) { if (workspaceSource.includes("type: filesystem")) {
mkdirSync(join(source, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); mkdirSync(join(source, "psd-clinical", "evidence"), { recursive: true });
mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true }); mkdirSync(join(source, "research", "evidence"), { recursive: true });
writeFileSync(join(source, "workspace-content", "psd-clinical", "evidence", "guide.md"), "guide v1\n"); writeFileSync(join(source, "psd-clinical", "evidence", "guide.md"), "guide v1\n");
writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "research guide\n"); writeFileSync(join(source, "research", "evidence", "guide.md"), "research guide\n");
await git(source, ["add", "workspaces", "workspace-content"]); await git(source, ["add", "-A"]);
} else { } else {
await git(source, ["add", "workspaces/psd-clinical.yaml"]); await git(source, ["add", "thoth-workspaces.yaml", "psd-clinical/workspace.yaml"]);
} }
await git(source, ["commit", "-m", "Initial workspace"]); await git(source, ["commit", "-m", "Initial workspace"]);
await git(source, ["remote", "add", "origin", remote]); await git(source, ["remote", "add", "origin", remote]);
@@ -219,10 +233,11 @@ async function contentOnlyFixture(): Promise<{
await git(source, ["init", "--initial-branch=main"]); await git(source, ["init", "--initial-branch=main"]);
await git(source, ["config", "user.name", "Workspace Registry Test"]); await git(source, ["config", "user.name", "Workspace Registry Test"]);
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
const evidence = join(source, "workspace-content", "p1-filesystem", "evidence"); const evidence = join(source, "p1-filesystem", "evidence");
mkdirSync(evidence, { recursive: true }); mkdirSync(evidence, { recursive: true });
writeFileSync(join(evidence, "guide.md"), "curated content\n"); writeFileSync(join(evidence, "guide.md"), "curated content\n");
await git(source, ["add", "workspace-content"]); writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml([{ id: "p1-filesystem", name: "p1-filesystem" }]));
await git(source, ["add", "-A"]);
await git(source, ["commit", "-m", "Bootstrap curated content"]); await git(source, ["commit", "-m", "Bootstrap curated content"]);
await git(source, ["remote", "add", "origin", remote]); await git(source, ["remote", "add", "origin", remote]);
await git(source, ["push", "origin", "main"]); await git(source, ["push", "origin", "main"]);
@@ -242,11 +257,16 @@ async function multiWorkspaceFixture(workspaces: Record<string, string>): Promis
await git(source, ["init", "--initial-branch=main"]); await git(source, ["init", "--initial-branch=main"]);
await git(source, ["config", "user.name", "Workspace Registry Test"]); await git(source, ["config", "user.name", "Workspace Registry Test"]);
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
mkdirSync(join(source, "workspaces")); const entries = [];
for (const [id, workspaceSource] of Object.entries(workspaces)) { for (const [id, workspaceSource] of Object.entries(workspaces)) {
writeFileSync(join(source, "workspaces", `${id}.yaml`), workspaceSource); const workspace = workspaceSource.includes("schema_version: 3") ? parseWorkspaceYaml(workspaceSource) : undefined;
entries.push({ id, name: workspace.workspace.name, ...(workspace.workspace.description ? { description: workspace.workspace.description } : {}) });
mkdirSync(join(source, id), { recursive: true });
writeFileSync(join(source, id, "workspace.yaml"), workspaceSource);
if (workspaceSource.includes("type: filesystem")) mkdirSync(join(source, id, "evidence"), { recursive: true });
} }
await git(source, ["add", "workspaces"]); writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml(entries));
await git(source, ["add", "-A"]);
await git(source, ["commit", "-m", "Initial workspaces"]); await git(source, ["commit", "-m", "Initial workspaces"]);
await git(source, ["remote", "add", "origin", remote]); await git(source, ["remote", "add", "origin", remote]);
await git(source, ["push", "origin", "main"]); await git(source, ["push", "origin", "main"]);
@@ -306,8 +326,8 @@ async function checkoutStatus(checkout: string): Promise<{ porcelain: string; di
} }
async function pushInvalidWorkspace(source: string): Promise<void> { async function pushInvalidWorkspace(source: string): Promise<void> {
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), "workspace: invalid\n"); writeFileSync(join(source, "psd-clinical", "workspace.yaml"), "workspace: invalid\n");
await git(source, ["add", "workspaces/psd-clinical.yaml"]); await git(source, ["add", "psd-clinical/workspace.yaml"]);
await git(source, ["commit", "-m", "Invalid workspace"]); await git(source, ["commit", "-m", "Invalid workspace"]);
await git(source, ["push", "origin", "main"]); await git(source, ["push", "origin", "main"]);
} }
@@ -353,12 +373,15 @@ function persistedState(root: string, commit: string): { active: any; manifest:
}; };
} }
test("allows first API publication and delete-last from a content-only registry base", async () => { test("allows bootstrap creation from a catalog-only base and refuses later curator-owned writes", async () => {
const remote = await contentOnlyFixture(); const remote = await contentOnlyFixture();
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await expect(registry.bootstrap()).resolves.toMatchObject({ head: remote.initialCommit }); await expect(registry.bootstrap()).resolves.toMatchObject({ head: remote.initialCommit });
await expect(registry.list()).resolves.toEqual([]); await expect(registry.list()).resolves.toEqual([]);
await expect(registry.listCatalog()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem", configurationState: "configuration_required" }),
]);
const created = await registry.publish({ const created = await registry.publish({
action: "create", action: "create",
@@ -366,16 +389,23 @@ test("allows first API publication and delete-last from a content-only registry
baseCommit: remote.initialCommit, baseCommit: remote.initialCommit,
}); });
expect(created).toMatchObject({ id: "p1-filesystem" }); expect(created).toMatchObject({ id: "p1-filesystem" });
await expect(registry.list()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem", commit: created!.commit }),
]);
await expect(registry.listCatalog()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem", configurationState: "ready", revision: created }),
]);
await expect(registry.publish({ await expect(registry.publish({
action: "delete", action: "delete",
id: "p1-filesystem", id: "p1-filesystem",
baseCommit: created!.commit, baseCommit: created!.commit,
baseBlob: created!.blob, baseBlob: created!.blob,
})).resolves.toBeUndefined(); })).rejects.toMatchObject({ code: "workspace_curator_owned" });
await expect(registry.list()).resolves.toEqual([]); await expect(registry.list()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem" }),
]);
}); });
test("bootstraps a checkout and activates a validated immutable snapshot", async () => { test("bootstraps a checkout and activates a validated immutable snapshot", async () => {
const remote = await fixture(); const remote = await fixture();
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
@@ -411,7 +441,7 @@ test("publishes a filesystem descriptor only when its Evidence tree exists in th
const root = join(remote.root, "registry"); const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote)); const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap(); await registry.bootstrap();
const evidencePath = "workspace-content/research/evidence"; const evidencePath = "research/evidence";
const initialTree = await gitOutput(remote.root, [ const initialTree = await gitOutput(remote.root, [
"--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`, "--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`,
]); ]);
@@ -424,7 +454,7 @@ test("publishes a filesystem descriptor only when its Evidence tree exists in th
expect(created?.commit).not.toBe(remote.initialCommit); expect(created?.commit).not.toBe(remote.initialCommit);
await expect(runFile("git", [ await expect(runFile("git", [
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:workspaces/research.yaml`, "--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:research/workspace.yaml`,
], { cwd: remote.root })).resolves.toBeDefined(); ], { cwd: remote.root })).resolves.toBeDefined();
await expect(runFile("git", [ await expect(runFile("git", [
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:${evidencePath}/guide.md`, "--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:${evidencePath}/guide.md`,
@@ -451,10 +481,10 @@ test.each(["missing", "blob"])(
const remote = await fixture(withFilesystemEvidence(validYaml)); const remote = await fixture(withFilesystemEvidence(validYaml));
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await registry.bootstrap(); await registry.bootstrap();
const evidenceRoot = join(remote.source, "workspace-content", "psd-clinical", "evidence"); const evidenceRoot = join(remote.source, "psd-clinical", "evidence");
rmSync(evidenceRoot, { recursive: true, force: true }); rmSync(evidenceRoot, { recursive: true, force: true });
if (invalidKind === "blob") writeFileSync(evidenceRoot, "not a tree\n"); if (invalidKind === "blob") writeFileSync(evidenceRoot, "not a tree\n");
await git(remote.source, ["add", "-A", "workspace-content/psd-clinical/evidence"]); await git(remote.source, ["add", "-A", "psd-clinical/evidence"]);
await git(remote.source, ["commit", "-m", `Make Evidence root ${invalidKind}`]); await git(remote.source, ["commit", "-m", `Make Evidence root ${invalidKind}`]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
const invalidCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); const invalidCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
@@ -471,10 +501,10 @@ test("activation validates filesystem Evidence against its exact safeHead rather
const remote = await fixture(withFilesystemEvidence(validYaml)); const remote = await fixture(withFilesystemEvidence(validYaml));
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await registry.bootstrap(); await registry.bootstrap();
rmSync(join(remote.source, "workspace-content", "psd-clinical", "evidence"), { rmSync(join(remote.source, "psd-clinical", "evidence"), {
recursive: true, force: true, recursive: true, force: true,
}); });
await git(remote.source, ["add", "-A", "workspace-content/psd-clinical/evidence"]); await git(remote.source, ["add", "-A", "psd-clinical/evidence"]);
await git(remote.source, ["commit", "-m", "Remove current Evidence root"]); await git(remote.source, ["commit", "-m", "Remove current Evidence root"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
const invalidHead = await gitOutput(remote.source, ["rev-parse", "HEAD"]); const invalidHead = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
@@ -496,7 +526,7 @@ test("creates an immutable descriptor revision for a content-only Evidence commi
const registry = new WorkspaceRegistry(config(root, remote.remote)); const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap(); await registry.bootstrap();
const initial = await registry.read("psd-clinical"); const initial = await registry.read("psd-clinical");
const evidencePath = "workspace-content/psd-clinical/evidence"; const evidencePath = "psd-clinical/evidence";
const initialTree = await gitOutput(remote.source, ["rev-parse", `${remote.initialCommit}:${evidencePath}`]); const initialTree = await gitOutput(remote.source, ["rev-parse", `${remote.initialCommit}:${evidencePath}`]);
writeFileSync(join(remote.source, evidencePath, "guide.md"), "guide v2\n"); writeFileSync(join(remote.source, evidencePath, "guide.md"), "guide v2\n");
await git(remote.source, ["add", `${evidencePath}/guide.md`]); await git(remote.source, ["add", `${evidencePath}/guide.md`]);
@@ -524,9 +554,9 @@ test("rejects a stale API update after a content-only Evidence commit", async ()
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await registry.bootstrap(); await registry.bootstrap();
const initial = await registry.read("psd-clinical"); const initial = await registry.read("psd-clinical");
const guide = join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md"); const guide = join(remote.source, "psd-clinical", "evidence", "guide.md");
writeFileSync(guide, "curator content\n"); writeFileSync(guide, "curator content\n");
await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); await git(remote.source, ["add", "psd-clinical/evidence/guide.md"]);
await git(remote.source, ["commit", "-m", "Curator Evidence update"]); await git(remote.source, ["commit", "-m", "Curator Evidence update"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
@@ -536,7 +566,7 @@ test("rejects a stale API update after a content-only Evidence commit", async ()
workspace: filesystemWorkspace("psd-clinical"), workspace: filesystemWorkspace("psd-clinical"),
baseCommit: initial.revision.commit, baseCommit: initial.revision.commit,
baseBlob: initial.revision.blob, baseBlob: initial.revision.blob,
})).rejects.toMatchObject({ code: "workspace_stale" }); })).rejects.toMatchObject({ code: "workspace_curator_owned" });
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit); expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit);
}); });
@@ -545,10 +575,10 @@ test("keeps content-only historical descriptor revisions distinguishable by comm
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await registry.bootstrap(); await registry.bootstrap();
writeFileSync( writeFileSync(
join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md"), join(remote.source, "psd-clinical", "evidence", "guide.md"),
"historical content\n", "historical content\n",
); );
await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); await git(remote.source, ["add", "psd-clinical/evidence/guide.md"]);
await git(remote.source, ["commit", "-m", "Retained Evidence update"]); await git(remote.source, ["commit", "-m", "Retained Evidence update"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
@@ -563,14 +593,14 @@ test("keeps content-only historical descriptor revisions distinguishable by comm
expect(oldPinned.workspace).toEqual(newPinned.workspace); expect(oldPinned.workspace).toEqual(newPinned.workspace);
}); });
test("publishes create, update, and delete with the configured Git author identity", async () => { test("publishes one bootstrap descriptor with the configured Git author identity and refuses curator-owned mutation", async () => {
const remote = await fixture(); const remote = await fixture();
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, { const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, {
gitAuthorName: "Configured Workspace Publisher", gitAuthorName: "Configured Workspace Publisher",
gitAuthorEmail: "publisher@example.invalid", gitAuthorEmail: "publisher@example.invalid",
})); }));
await registry.bootstrap(); await registry.bootstrap();
const createdWorkspace = workspaceWith("research-registry", { name: "Research registry" }); const createdWorkspace = workspaceWith("research");
const created = await registry.publish({ const created = await registry.publish({
action: "create", action: "create",
@@ -578,89 +608,80 @@ test("publishes create, update, and delete with the configured Git author identi
baseCommit: remote.initialCommit, baseCommit: remote.initialCommit,
}); });
expect(created).toMatchObject({ id: "research-registry", commit: expect.stringMatching(/^[0-9a-f]{40}$/) }); expect(created).toMatchObject({ id: "research", commit: expect.stringMatching(/^[0-9a-f]{40}$/) });
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "log", "-1", "--format=%an <%ae>"])).toBe( expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "log", "-1", "--format=%an <%ae>"])).toBe(
"Configured Workspace Publisher <publisher@example.invalid>", "Configured Workspace Publisher <publisher@example.invalid>",
); );
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspace-docs/research-registry/README.md"], { await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspace-docs/research/README.md"], {
cwd: remote.root, cwd: remote.root,
})).resolves.toBeDefined(); })).resolves.toBeDefined();
const updated = await registry.publish({ const before = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]);
await expect(registry.publish({
action: "update", action: "update",
workspace: workspaceWith("research-registry", { description: "Updated workspace description" }), workspace: workspaceWith("research", { name: "Research", description: "Updated workspace description" }),
baseCommit: created!.commit, baseCommit: created!.commit,
baseBlob: created!.blob, baseBlob: created!.blob,
}); })).rejects.toMatchObject({ code: "workspace_curator_owned" });
expect(updated).toMatchObject({ id: "research-registry" });
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "show", "HEAD:workspaces/research-registry.yaml"])).toContain(
"description: Updated workspace description",
);
await expect(registry.publish({ await expect(registry.publish({
action: "delete", action: "delete",
id: "research-registry", id: "research",
baseCommit: updated!.commit, baseCommit: created!.commit,
baseBlob: updated!.blob, baseBlob: created!.blob,
})).resolves.toBeUndefined(); })).rejects.toMatchObject({ code: "workspace_curator_owned" });
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspaces/research-registry.yaml"], { expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(before);
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:research/workspace.yaml"], {
cwd: remote.root, cwd: remote.root,
})).rejects.toBeDefined(); })).resolves.toBeDefined();
}); });
test("rejects curator-owned update after a curator push and leaves the active snapshot intact", async () => {
test("reports stale publish conflicts with expected and actual revisions", async () => {
const remote = await fixture(); const remote = await fixture();
const root = join(remote.root, "registry"); const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote)); const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap(); await registry.bootstrap();
const initial = await registry.read("psd-clinical"); const initial = await registry.read("psd-clinical");
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace(
"schema: datawarehouse", "schema: analytics", "schema: datawarehouse", "schema: analytics",
)); ));
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); await git(remote.source, ["add", "psd-clinical/workspace.yaml"]);
await git(remote.source, ["commit", "-m", "Change dwh schema"]); await git(remote.source, ["commit", "-m", "Change dwh schema"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
const actualCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); const actualCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
const actualBlob = await gitOutput(remote.source, ["rev-parse", "HEAD:workspaces/psd-clinical.yaml"]);
await registry.pull();
await expect(registry.publish({ await expect(registry.publish({
action: "update", action: "update",
workspace: workspaceWith("psd-clinical", { description: "Local stale change" }), workspace: workspaceWith("psd-clinical", { description: "Local stale change" }),
baseCommit: initial.revision.commit, baseCommit: initial.revision.commit,
baseBlob: initial.revision.blob, baseBlob: initial.revision.blob,
})).rejects.toMatchObject({ })).rejects.toMatchObject({ code: "workspace_curator_owned" });
code: "workspace_conflict", await expect(registry.read("psd-clinical")).resolves.toMatchObject({
fields: ["dwh.schema"], revision: { commit: actualCommit },
expected: { commit: initial.revision.commit, blob: initial.revision.blob },
actual: { commit: actualCommit, blob: actualBlob },
}); });
}); });
test.each([ test.each([
["adds", validYaml, withDwhRestDiagnostic(validYaml)], ["adds", validYaml, withDwhRestDiagnostic(validYaml)],
["removes", withDwhRestDiagnostic(validYaml), validYaml], ["removes", withDwhRestDiagnostic(validYaml), validYaml],
])("reports an optional diagnostics branch when the registry %s it", async (_operation, baseSource, remoteSource) => { ])("pulls a curator change that %s a diagnostics branch without API rewrite", async (_operation, baseSource, remoteSource) => {
const remote = await fixture(baseSource); const remote = await fixture(baseSource);
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await registry.bootstrap(); await registry.bootstrap();
const initial = await registry.read("psd-clinical"); const initial = await registry.read("psd-clinical");
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), remoteSource); writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), remoteSource);
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); await git(remote.source, ["add", "psd-clinical/workspace.yaml"]);
await git(remote.source, ["commit", "-m", `Registry ${_operation} diagnostic branch`]); await git(remote.source, ["commit", "-m", `Registry ${_operation} diagnostic branch`]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
await registry.pull();
const updated = await registry.read("psd-clinical");
expect(updated.revision.commit).not.toBe(initial.revision.commit);
await expect(registry.publish({ await expect(registry.publish({
action: "update", action: "update",
workspace: workspaceWith("psd-clinical", { description: "Local stale change" }), workspace: workspaceWith("psd-clinical", { description: "Local stale change" }),
baseCommit: initial.revision.commit, baseCommit: initial.revision.commit,
baseBlob: initial.revision.blob, baseBlob: initial.revision.blob,
})).rejects.toMatchObject({ })).rejects.toMatchObject({ code: "workspace_curator_owned" });
code: "workspace_conflict",
fields: ["dwh.supported_transports", "diagnostics"],
});
}); });
test("restores a clean checkout after a failed commit and retries publication", async () => { test("restores a clean checkout after a failed commit and retries publication", async () => {
const remote = await fixture(); const remote = await fixture();
const root = join(remote.root, "registry"); const root = join(remote.root, "registry");
@@ -670,7 +691,7 @@ test("restores a clean checkout after a failed commit and retries publication",
chmodSync(objects, 0o500); chmodSync(objects, 0o500);
const request = { const request = {
action: "create" as const, action: "create" as const,
workspace: workspaceWith("commit-recovery"), workspace: workspaceWith("research"),
baseCommit: remote.initialCommit, baseCommit: remote.initialCommit,
}; };
@@ -681,7 +702,7 @@ test("restores a clean checkout after a failed commit and retries publication",
} }
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" }); expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit }); await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
await expect(registry.publish(request)).resolves.toMatchObject({ id: "commit-recovery" }); await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" });
}); });
test("resets an ahead checkout after a rejected push and retries publication", async () => { test("resets an ahead checkout after a rejected push and retries publication", async () => {
@@ -693,7 +714,7 @@ test("resets an ahead checkout after a rejected push and retries publication", a
writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const request = { const request = {
action: "create" as const, action: "create" as const,
workspace: workspaceWith("push-recovery"), workspace: workspaceWith("research"),
baseCommit: remote.initialCommit, baseCommit: remote.initialCommit,
}; };
@@ -701,7 +722,7 @@ test("resets an ahead checkout after a rejected push and retries publication", a
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" }); expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
rmSync(hook); rmSync(hook);
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit }); await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
await expect(registry.publish(request)).resolves.toMatchObject({ id: "push-recovery" }); await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" });
}); });
test.each([ test.each([
@@ -722,8 +743,8 @@ test("writes only state-free revisions and never exposes revision state", async
await registry.bootstrap(); await registry.bootstrap();
const initial = persistedState(root, remote.initialCommit); const initial = persistedState(root, remote.initialCommit);
expect(Object.keys(initial.active).sort()).toEqual(["head", "revisions"]); expect(Object.keys(initial.active).sort()).toEqual(["catalog", "head", "revisions"]);
expect(Object.keys(initial.manifest).sort()).toEqual(["files", "head", "revisions"]); expect(Object.keys(initial.manifest).sort()).toEqual(["catalog", "files", "head", "revisions"]);
expect(Object.keys(initial.active.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]); expect(Object.keys(initial.active.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]);
expect(Object.keys(initial.manifest.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]); expect(Object.keys(initial.manifest.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]);
@@ -733,10 +754,9 @@ test("writes only state-free revisions and never exposes revision state", async
expect(read.revision).not.toHaveProperty("state"); expect(read.revision).not.toHaveProperty("state");
const published = await registry.publish({ const published = await registry.publish({
action: "update", action: "create",
workspace: workspaceWith("psd-clinical", { name: "State-free revision" }), workspace: workspaceWith("research"),
baseCommit: remote.initialCommit, baseCommit: remote.initialCommit,
baseBlob: listed[0]!.blob,
}); });
const updated = persistedState(root, published!.commit); const updated = persistedState(root, published!.commit);
expect(updated.active.revisions[0]).not.toHaveProperty("state"); expect(updated.active.revisions[0]).not.toHaveProperty("state");
@@ -827,10 +847,13 @@ test("normalizes operational state in retained historical snapshots without rewr
const root = join(remote.root, "registry"); const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote)); const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap(); await registry.bootstrap();
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace(
"name: Policlinico San Donato", "name: Current workspace", "name: Policlinico San Donato", "name: Current workspace",
)); ));
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Current workspace" }, { id: "research", name: "Research" },
]));
await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Update active workspace"]); await git(remote.source, ["commit", "-m", "Update active workspace"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
await registry.pull(); await registry.pull();
@@ -907,17 +930,17 @@ test("rejects duplicate schema v3 collection ownership and keeps the previous ac
await registry.bootstrap(); await registry.bootstrap();
writeFileSync( writeFileSync(
join(remote.source, "workspaces", "research-clinical.yaml"), join(remote.source, "research-clinical", "workspace.yaml"),
v3Yaml v3Yaml
.replace("id: psd-clinical", "id: research-clinical") .replace("id: psd-clinical", "id: research-clinical")
.replace("name: Policlinico San Donato", "name: Research Clinical") .replace("name: Policlinico San Donato", "name: Research Clinical")
.replace("collection: psd-clinical", "collection: shared"), .replace("collection: psd-clinical", "collection: shared"),
); );
writeFileSync( writeFileSync(
join(remote.source, "workspaces", "psd-clinical.yaml"), join(remote.source, "psd-clinical", "workspace.yaml"),
v3Yaml.replace("collection: psd-clinical", "collection: shared"), v3Yaml.replace("collection: psd-clinical", "collection: shared"),
); );
await git(remote.source, ["add", "workspaces"]); await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Duplicate collection ownership"]); await git(remote.source, ["commit", "-m", "Duplicate collection ownership"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
@@ -939,10 +962,13 @@ test("retains a historical snapshot while a resumable manifest still references
const registry = new WorkspaceRegistry(config(root, remote.remote)); const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap(); await registry.bootstrap();
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace(
"name: Policlinico San Donato", "name: Updated Policlinico San Donato", "name: Policlinico San Donato", "name: Updated Policlinico San Donato",
)); ));
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Updated Policlinico San Donato" }, { id: "research", name: "Research" },
]));
await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Update workspace"]); await git(remote.source, ["commit", "-m", "Update workspace"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
const currentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]); const currentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
@@ -964,10 +990,13 @@ test("a session revision lease survives stale retention scans until its manifest
await registry.bootstrap(); await registry.bootstrap();
const lease = await registry.acquireSessionRevision("psd-clinical"); const lease = await registry.acquireSessionRevision("psd-clinical");
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace(
"name: Policlinico San Donato", "name: Concurrent revision", "name: Policlinico San Donato", "name: Concurrent revision",
)); ));
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Concurrent revision" }, { id: "research", name: "Research" },
]));
await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Publish while session is starting"]); await git(remote.source, ["commit", "-m", "Publish while session is starting"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
await registry.pull(); await registry.pull();
@@ -990,15 +1019,20 @@ test("lists operational descriptors retained after their workspace was removed f
const registry = new WorkspaceRegistry(config(root, remote.remote)); const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap(); await registry.bootstrap();
writeFileSync(join(remote.source, "workspaces", "archive-only.yaml"), validYaml.replace( mkdirSync(join(remote.source, "archive-only"), { recursive: true });
writeFileSync(join(remote.source, "archive-only", "workspace.yaml"), validYaml.replace(
"id: psd-clinical", "id: archive-only", "id: psd-clinical", "id: archive-only",
).replace("collection: psd-clinical", "collection: archive-only")); ).replace("collection: psd-clinical", "collection: archive-only"));
await git(remote.source, ["add", "workspaces/archive-only.yaml"]); writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Policlinico San Donato" }, { id: "research", name: "Research" },
{ id: "archive-only", name: "Policlinico San Donato" },
]));
await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Add retained workspace"]); await git(remote.source, ["commit", "-m", "Add retained workspace"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
await registry.pull(); await registry.pull();
rmSync(join(remote.source, "workspaces", "psd-clinical.yaml")); rmSync(join(remote.source, "psd-clinical", "workspace.yaml"));
await git(remote.source, ["add", "-u"]); await git(remote.source, ["add", "-u"]);
await git(remote.source, ["commit", "-m", "Remove original workspace"]); await git(remote.source, ["commit", "-m", "Remove original workspace"]);
await git(remote.source, ["push", "origin", "main"]); await git(remote.source, ["push", "origin", "main"]);
@@ -1050,12 +1084,12 @@ test("rejects a locally-ahead checkout instead of activating local-only content"
const registry = new WorkspaceRegistry(config(root, remote.remote)); const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap(); await registry.bootstrap();
const checkout = join(root, "repo"); const checkout = join(root, "repo");
writeFileSync(join(checkout, "workspaces", "psd-clinical.yaml"), validYaml.replace( writeFileSync(join(checkout, "psd-clinical", "workspace.yaml"), validYaml.replace(
"name: Policlinico San Donato", "name: Local only workspace", "name: Policlinico San Donato", "name: Local only workspace",
)); ));
await git(checkout, ["config", "user.name", "Workspace Registry Test"]); await git(checkout, ["config", "user.name", "Workspace Registry Test"]);
await git(checkout, ["config", "user.email", "workspace-registry@example.invalid"]); await git(checkout, ["config", "user.email", "workspace-registry@example.invalid"]);
await git(checkout, ["add", "workspaces/psd-clinical.yaml"]); await git(checkout, ["add", "psd-clinical/workspace.yaml"]);
await git(checkout, ["commit", "-m", "Local-only workspace"]); await git(checkout, ["commit", "-m", "Local-only workspace"]);
await expect(registry.pull()).rejects.toMatchObject({ code: "git_non_fast_forward" }); await expect(registry.pull()).rejects.toMatchObject({ code: "git_non_fast_forward" });
@@ -1143,10 +1177,10 @@ test("snapshots canonical Evidence artifacts at the active commit without copyin
expect(status.head).toBe(remote.initialCommit); expect(status.head).toBe(remote.initialCommit);
const committedDescriptor = parseWorkspaceYaml(await gitOutput(remote.source, [ const committedDescriptor = parseWorkspaceYaml(await gitOutput(remote.source, [
"show", `${remote.initialCommit}:workspaces/psd-clinical.yaml`, "show", `${remote.initialCommit}:psd-clinical/workspace.yaml`,
])) as CanonicalWorkspace; ])) as CanonicalWorkspace;
const committedBlob = await gitOutput(remote.source, [ const committedBlob = await gitOutput(remote.source, [
"rev-parse", `${remote.initialCommit}:workspaces/psd-clinical.yaml`, "rev-parse", `${remote.initialCommit}:psd-clinical/workspace.yaml`,
]); ]);
expect(active.revision.blob).toBe(committedBlob); expect(active.revision.blob).toBe(committedBlob);
expect(expectedFiles["psd-clinical.yaml"]).toBe(serializeWorkspaceYaml(committedDescriptor)); expect(expectedFiles["psd-clinical.yaml"]).toBe(serializeWorkspaceYaml(committedDescriptor));
@@ -1164,7 +1198,7 @@ test("snapshots canonical Evidence artifacts at the active commit without copyin
} }
expect(JSON.stringify(manifest)).not.toContain("workspace-content/"); expect(JSON.stringify(manifest)).not.toContain("workspace-content/");
expect(readdirSync(snapshotDirectory).some((name) => name === "workspace-content")).toBe(false); expect(readdirSync(snapshotDirectory).some((name) => name === "workspace-content")).toBe(false);
expect(readFileSync(join(remote.source, "workspace-content/psd-clinical/evidence/guide.md"), "utf8")) expect(readFileSync(join(remote.source, "psd-clinical/evidence/guide.md"), "utf8"))
.toBe("guide v1\n"); .toBe("guide v1\n");
}); });
@@ -46,7 +46,7 @@ llm_policy:
const filesystemWorkspace = `${canonicalWorkspace}evidence: const filesystemWorkspace = `${canonicalWorkspace}evidence:
source: source:
type: filesystem type: filesystem
uri: workspace-content/psd-clinical/evidence uri: psd-clinical/evidence
`; `;
function evidenceWorkspace(source: string, policy = ""): string { function evidenceWorkspace(source: string, policy = ""): string {
@@ -77,9 +77,10 @@ async function fixture(workspaceSource = filesystemWorkspace) {
await git(source, ["init", "--initial-branch=main"]); await git(source, ["init", "--initial-branch=main"]);
await git(source, ["config", "user.name", "Runtime Handoff Test"]); await git(source, ["config", "user.name", "Runtime Handoff Test"]);
await git(source, ["config", "user.email", "runtime-handoff@example.invalid"]); await git(source, ["config", "user.email", "runtime-handoff@example.invalid"]);
mkdirSync(join(source, "workspaces")); writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: psd-clinical, name: Runtime handoff}]\n");
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource); mkdirSync(join(source, "psd-clinical"), { recursive: true });
const evidenceRoot = join(source, "workspace-content", "psd-clinical", "evidence"); writeFileSync(join(source, "psd-clinical", "workspace.yaml"), workspaceSource);
const evidenceRoot = join(source, "psd-clinical", "evidence");
mkdirSync(evidenceRoot, { recursive: true }); mkdirSync(evidenceRoot, { recursive: true });
writeFileSync(join(evidenceRoot, "guide.md"), "# Immutable revision evidence\n"); writeFileSync(join(evidenceRoot, "guide.md"), "# Immutable revision evidence\n");
await git(source, ["add", "."]); await git(source, ["add", "."]);
@@ -174,7 +175,6 @@ test("separate runtime leases hand off byte-identical revision Evidence configs
f.registryConfig.root, f.registryConfig.root,
"snapshots", "snapshots",
f.revision.commit, f.revision.commit,
"workspace-content",
"psd-clinical", "psd-clinical",
"evidence", "evidence",
); );
@@ -228,10 +228,10 @@ test("real Evidence-content-only commit changes runtime identity and root with i
const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
const descriptorBefore = readFileSync(f.revision.snapshotPath, "utf8"); const descriptorBefore = readFileSync(f.revision.snapshotPath, "utf8");
writeFileSync( writeFileSync(
join(f.source, "workspace-content", "psd-clinical", "evidence", "guide.md"), join(f.source, "psd-clinical", "evidence", "guide.md"),
"# Content-only revision two\n", "# Content-only revision two\n",
); );
await git(f.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]); await git(f.source, ["add", "psd-clinical/evidence/guide.md"]);
await git(f.source, ["commit", "-m", "Update Evidence content only"]); await git(f.source, ["commit", "-m", "Update Evidence content only"]);
await git(f.source, ["push", "origin", "main"]); await git(f.source, ["push", "origin", "main"]);
await f.registry.pull(); await f.registry.pull();
@@ -250,7 +250,6 @@ test("real Evidence-content-only commit changes runtime identity and root with i
f.registryConfig.root, f.registryConfig.root,
"snapshots", "snapshots",
current.commit, current.commit,
"workspace-content",
"psd-clinical", "psd-clinical",
"evidence", "evidence",
)); ));
@@ -193,7 +193,7 @@ function evidenceRender(
test("renders filesystem Evidence below the immutable revision content root with default policy", () => { test("renders filesystem Evidence below the immutable revision content root with default policy", () => {
const yaml = evidenceRender({ const yaml = evidenceRender({
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
}); });
const rendered = parse(yaml); const rendered = parse(yaml);
@@ -201,7 +201,7 @@ test("renders filesystem Evidence below the immutable revision content root with
expect(rendered.evidence).toEqual({ expect(rendered.evidence).toEqual({
sources: [{ sources: [{
type: "filesystem", type: "filesystem",
root: `/srv/registry/snapshots/${evidenceRevision}/workspace-content/psd-clinical/evidence`, root: `/srv/registry/snapshots/${evidenceRevision}/psd-clinical/evidence`,
patterns: ["**/*.md"], patterns: ["**/*.md"],
max_bytes: 10_485_760, max_bytes: 10_485_760,
}], }],
@@ -400,7 +400,7 @@ test.each([
test("is byte deterministic and revision-bound for descriptor-identical content-only commits", () => { test("is byte deterministic and revision-bound for descriptor-identical content-only commits", () => {
const source = { const source = {
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
}; };
const first = evidenceRender(source); const first = evidenceRender(source);
expect(evidenceRender(source)).toBe(first); expect(evidenceRender(source)).toBe(first);
@@ -424,7 +424,7 @@ test("is byte deterministic and revision-bound for descriptor-identical content-
expect(next).not.toBe(first); expect(next).not.toBe(first);
expect(nextParsed.runtime_identity.workspace_revision).toBe(nextRevision); expect(nextParsed.runtime_identity.workspace_revision).toBe(nextRevision);
expect(nextParsed.evidence.sources[0].root).toBe( expect(nextParsed.evidence.sources[0].root).toBe(
`/srv/registry/snapshots/${nextRevision}/workspace-content/psd-clinical/evidence`, `/srv/registry/snapshots/${nextRevision}/psd-clinical/evidence`,
); );
expect(nextParsed.evidence.sources[0].root).not.toBe(firstParsed.evidence.sources[0].root); expect(nextParsed.evidence.sources[0].root).not.toBe(firstParsed.evidence.sources[0].root);
}); });
+1 -1
View File
@@ -164,7 +164,7 @@ evidence:
const evidenceVariable = (suffix: string) => `THT_WS_PSD_CLINICAL_EVIDENCE_${suffix}`; const evidenceVariable = (suffix: string) => `THT_WS_PSD_CLINICAL_EVIDENCE_${suffix}`;
test.each([ test.each([
{ type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, { type: "filesystem", uri: "psd-clinical/evidence" },
{ type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "none" }, { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "none" },
{ type: "s3", uri: "s3://clinical-evidence/published/", credentials: "ambient" }, { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "ambient" },
])("does not resolve Evidence variables for $type modes without file credentials", (source) => { ])("does not resolve Evidence variables for $type modes without file credentials", (source) => {
+4 -4
View File
@@ -103,7 +103,7 @@ test.each([
}); });
test.each([ test.each([
{ type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, { type: "filesystem", uri: "psd-clinical/evidence" },
{ type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "none" }, { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "none" },
{ type: "s3", uri: "s3://clinical-evidence/published/", credentials: "ambient" }, { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "ambient" },
])("omits Evidence installation variables for $type modes without file credentials", (source) => { ])("omits Evidence installation variables for $type modes without file credentials", (source) => {
@@ -134,7 +134,7 @@ llm_policy: { allowed: [zai/glm-5.2] }
const evidenceSources = [ const evidenceSources = [
{ {
label: "filesystem", label: "filesystem",
source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, source: { type: "filesystem", uri: "psd-clinical/evidence" },
variables: [], variables: [],
}, },
{ {
@@ -207,11 +207,11 @@ test("documents the S3 session token file as optional", () => {
test("documents same-revision filesystem ownership without claiming P1 materialization", () => { test("documents same-revision filesystem ownership without claiming P1 materialization", () => {
const descriptor = parseWorkspaceYaml( const descriptor = parseWorkspaceYaml(
`${renderWorkspaceWithoutEvidence()}evidence:\n source: { type: filesystem, uri: workspace-content/psd-clinical/evidence }\n`, `${renderWorkspaceWithoutEvidence()}evidence:\n source: { type: filesystem, uri: psd-clinical/evidence }\n`,
); );
const docs = renderWorkspaceDocs(descriptor).markdown; const docs = renderWorkspaceDocs(descriptor).markdown;
expect(docs).toContain("`workspace-content/psd-clinical/evidence`"); expect(docs).toContain("`psd-clinical/evidence`");
expect(docs).toMatch(/same Git revision/i); expect(docs).toMatch(/same Git revision/i);
expect(docs).toMatch(/P6.*materializ/i); expect(docs).toMatch(/P6.*materializ/i);
expect(docs).toMatch(/containment.*symlink/i); expect(docs).toMatch(/containment.*symlink/i);
+21 -21
View File
@@ -297,7 +297,7 @@ const validEvidenceSources = [
name: "filesystem with explicit values", name: "filesystem with explicit values",
source: { source: {
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
patterns: ["documents/**/*.pdf", "notes/*.md"], patterns: ["documents/**/*.pdf", "notes/*.md"],
max_bytes: 12_000_000, max_bytes: 12_000_000,
}, },
@@ -374,14 +374,14 @@ test.each(validEvidenceSources)("accepts evidence source: $name", ({ source }) =
test("applies filesystem and policy defaults to the canonical descriptor", () => { test("applies filesystem and policy defaults to the canonical descriptor", () => {
const parsed = validateWorkspaceDescriptor(withEvidence({ const parsed = validateWorkspaceDescriptor(withEvidence({
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
})); }));
expect(parsed).toMatchObject({ expect(parsed).toMatchObject({
evidence: { evidence: {
source: { source: {
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
patterns: ["**/*.md"], patterns: ["**/*.md"],
max_bytes: 10 * 1024 * 1024, max_bytes: 10 * 1024 * 1024,
}, },
@@ -397,7 +397,7 @@ test("keeps evidence optional on schema v3", () => {
test("serializes defaulted evidence canonically and parses it without loss", () => { test("serializes defaulted evidence canonically and parses it without loss", () => {
const canonical = validateWorkspaceDescriptor(withEvidence({ const canonical = validateWorkspaceDescriptor(withEvidence({
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
})); }));
if (canonical.workspace.schema_version !== 3) throw new Error("expected schema v3"); if (canonical.workspace.schema_version !== 3) throw new Error("expected schema v3");
@@ -405,16 +405,16 @@ test("serializes defaulted evidence canonically and parses it without loss", ()
}); });
const invalidFilesystemPaths = [ const invalidFilesystemPaths = [
"/workspace-content/psd-clinical/evidence", "/psd-clinical/evidence",
"workspace-content/../psd-clinical/evidence", "../psd-clinical/evidence",
"workspace-content/./psd-clinical/evidence", "./psd-clinical/evidence",
"workspace-content//psd-clinical/evidence", "/psd-clinical/evidence",
"workspace-content/psd-clinical/evidence/..", "psd-clinical/evidence/..",
"workspace-content\\psd-clinical\\evidence", "\\psd-clinical\\evidence",
"workspace-content/psd-clinical/evidence\u0000", "psd-clinical/evidence\u0000",
"workspace-content/other-workspace/evidence", "other-workspace/evidence",
"workspace-content/psd-clinical", "psd-clinical",
"workspace-content/psd-clinical/evidence/nested", "psd-clinical/evidence/nested",
]; ];
test.each(invalidFilesystemPaths)("rejects unsafe or noncanonical filesystem URI %#", (uri) => { test.each(invalidFilesystemPaths)("rejects unsafe or noncanonical filesystem URI %#", (uri) => {
@@ -426,27 +426,27 @@ const invalidPatterns = ["", "/absolute", "../escape", ".", "folder/./file", "fo
test.each(invalidPatterns)("rejects unsafe evidence glob %#", (pattern) => { test.each(invalidPatterns)("rejects unsafe evidence glob %#", (pattern) => {
expectSafeEvidenceError(withEvidence({ expectSafeEvidenceError(withEvidence({
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
patterns: [pattern], patterns: [pattern],
}), /evidence.*source.*patterns/i); }), /evidence.*source.*patterns/i);
}); });
test("rejects empty and duplicate filesystem patterns", () => { test("rejects empty and duplicate filesystem patterns", () => {
const source = { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }; const source = { type: "filesystem", uri: "psd-clinical/evidence" };
expectSafeEvidenceError(withEvidence({ ...source, patterns: [] }), /patterns/i); expectSafeEvidenceError(withEvidence({ ...source, patterns: [] }), /patterns/i);
expectSafeEvidenceError(withEvidence({ ...source, patterns: ["**/*.pdf", "**/*.pdf"] }), /patterns/i); expectSafeEvidenceError(withEvidence({ ...source, patterns: ["**/*.pdf", "**/*.pdf"] }), /patterns/i);
}); });
test.each(["ftp", "git", "unknown"])("rejects unsupported evidence discriminator %s", (type) => { test.each(["ftp", "git", "unknown"])("rejects unsupported evidence discriminator %s", (type) => {
expectSafeEvidenceError(withEvidence({ type, uri: "workspace-content/psd-clinical/evidence" }), /evidence.*source.*type/i); expectSafeEvidenceError(withEvidence({ type, uri: "psd-clinical/evidence" }), /evidence.*source.*type/i);
}); });
test("rejects unknown evidence keys", () => { test("rejects unknown evidence keys", () => {
expectSafeEvidenceError({ ...withEvidence({ expectSafeEvidenceError({ ...withEvidence({
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
}), evidence: { }), evidence: {
source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence", mystery: true }, source: { type: "filesystem", uri: "psd-clinical/evidence", mystery: true },
policy: explicitPolicy, policy: explicitPolicy,
mystery: true, mystery: true,
} }, /unrecognized|mystery/i); } }, /unrecognized|mystery/i);
@@ -460,7 +460,7 @@ test.each(credentialFields)("rejects credential-shaped evidence field %s without
const canary = `CANARY-${field}-DO-NOT-LEAK`; const canary = `CANARY-${field}-DO-NOT-LEAK`;
expectSafeEvidenceError(withEvidence({ expectSafeEvidenceError(withEvidence({
type: "filesystem", type: "filesystem",
uri: "workspace-content/psd-clinical/evidence", uri: "psd-clinical/evidence",
[field]: canary, [field]: canary,
}), /evidence.*source/i, canary); }), /evidence.*source/i, canary);
}); });
@@ -545,6 +545,6 @@ test.each([
["retain_published_generations", Number.MAX_SAFE_INTEGER + 1], ["retain_published_generations", Number.MAX_SAFE_INTEGER + 1],
] as const)("rejects invalid evidence policy bound %s=%s", (field, value) => { ] as const)("rejects invalid evidence policy bound %s=%s", (field, value) => {
expectSafeEvidenceError(withEvidence({ expectSafeEvidenceError(withEvidence({
type: "filesystem", uri: "workspace-content/psd-clinical/evidence", type: "filesystem", uri: "psd-clinical/evidence",
}, { ...explicitPolicy, [field]: value }), new RegExp(field, "i")); }, { ...explicitPolicy, [field]: value }), new RegExp(field, "i"));
}); });