fix: preserve workspace evidence in browser drafts

This commit is contained in:
2026-08-09 20:27:20 +02:00
parent ba7596c2dd
commit a580c4ca8a
6 changed files with 637 additions and 12 deletions
+138 -1
View File
@@ -1,7 +1,10 @@
import { expect, test } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { asWorkspaceConflict, importWorkspace, publishWorkspace, type CanonicalWorkspace } from "./workspaces";
import {
asWorkspaceConflict, getWorkspace, importWorkspace, publishWorkspace, validateWorkspace,
type CanonicalWorkspace,
} from "./workspaces";
const workspace: CanonicalWorkspace = {
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
@@ -13,6 +16,27 @@ const workspace: CanonicalWorkspace = {
llm_policy: { allowed: ["zai/glm-5.2"] },
};
const evidenceWorkspace = {
...workspace,
evidence: {
source: {
type: "filesystem",
uri: "workspace-content/psd-clinical/evidence",
patterns: ["**/*.md"],
max_bytes: 10 * 1024 * 1024,
},
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
},
} satisfies CanonicalWorkspace;
const revision = {
id: "psd-clinical",
commit: "a".repeat(40),
blob: "b".repeat(40),
snapshotPath: "workspaces/psd-clinical.yaml",
state: "operational" as const,
};
test("uploads a workspace bundle without JSON content type", async () => {
let contentType: string | null = null;
server.use(http.post("/api/workspaces/import", ({ request }) => {
@@ -107,3 +131,116 @@ test("rejects a conflict payload that attempts to surface removed vector transpo
expect(asWorkspaceConflict(error)).toBeUndefined();
});
test("sanitizes read and validate responses while preserving Evidence", async () => {
server.use(
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, revision })),
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: evidenceWorkspace, contract: {} })),
);
const read = await getWorkspace("psd-clinical");
const validated = await validateWorkspace(evidenceWorkspace);
expect(read.workspace.evidence).toEqual(evidenceWorkspace.evidence);
expect(read.workspace).not.toBe(evidenceWorkspace);
expect(validated.workspace.evidence).toEqual(evidenceWorkspace.evidence);
});
test("rejects malformed workspace API responses instead of exposing unknown Evidence fields", async () => {
const malformed = {
...evidenceWorkspace,
evidence: { ...evidenceWorkspace.evidence, signed_urls_file: "/run/secrets/urls" },
};
server.use(
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: malformed, revision })),
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: malformed, contract: {} })),
);
await expect(getWorkspace("psd-clinical")).rejects.toThrow();
await expect(validateWorkspace(evidenceWorkspace)).rejects.toThrow();
});
test("publishes Evidence without mutating or dropping it from the request", async () => {
let sent: unknown;
server.use(http.post("/api/workspaces/publish", async ({ request }) => {
sent = await request.json();
return HttpResponse.json({ revision });
}));
await publishWorkspace({
action: "update", workspace: evidenceWorkspace,
baseCommit: revision.commit, baseBlob: revision.blob,
});
expect(sent).toMatchObject({ workspace: { evidence: evidenceWorkspace.evidence } });
expect(evidenceWorkspace.evidence.source.patterns).toEqual(["**/*.md"]);
});
const evidenceConflictFields = [
"evidence",
"evidence.source",
"evidence.source.type",
"evidence.source.uri",
"evidence.source.patterns",
"evidence.source.max_bytes",
"evidence.source.uris",
"evidence.source.authentication",
"evidence.source.connect_timeout_ms",
"evidence.source.read_timeout_ms",
"evidence.source.max_redirects",
"evidence.source.allow_private_hosts",
"evidence.source.max_cache_bytes",
"evidence.source.endpoint_url",
"evidence.source.region",
"evidence.source.credentials",
"evidence.source.trusted_endpoint",
"evidence.source.allow_private_endpoint",
"evidence.source.allow_insecure_endpoint",
"evidence.source.max_objects",
"evidence.source.max_pages",
"evidence.source.page_size",
"evidence.policy",
"evidence.policy.max_chunk_chars",
"evidence.policy.retain_published_generations",
] as const;
test.each(evidenceConflictFields)("accepts canonical Evidence conflict field %s", async (field) => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict",
message: "Workspace changed in the registry.",
fields: [field],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
base: evidenceWorkspace,
local: evidenceWorkspace,
remote: evidenceWorkspace,
}, { status: 409 })));
const error = await publishWorkspace({
action: "update", workspace: evidenceWorkspace,
baseCommit: "a".repeat(40), baseBlob: "b".repeat(40),
}).catch((cause: unknown) => cause);
expect(asWorkspaceConflict(error)).toMatchObject({ fields: [field] });
});
test("rejects unknown Evidence conflict paths", async () => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict",
message: "Workspace changed in the registry.",
fields: ["evidence.source.signed_url"],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
base: evidenceWorkspace,
local: evidenceWorkspace,
remote: evidenceWorkspace,
}, { status: 409 })));
const error = await publishWorkspace({
action: "update", workspace: evidenceWorkspace,
baseCommit: "a".repeat(40), baseBlob: "b".repeat(40),
}).catch((cause: unknown) => cause);
expect(asWorkspaceConflict(error)).toBeUndefined();
});
+84 -7
View File
@@ -17,6 +17,49 @@ export interface CanonicalDiagnostics {
dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } };
}
export interface EvidencePolicy {
max_chunk_chars: number;
retain_published_generations: number;
}
export type EvidenceSource =
| {
type: "filesystem";
uri: string;
patterns: string[];
max_bytes: number;
}
| {
type: "http";
uris: string[];
authentication: "none" | "signed_urls_file";
connect_timeout_ms: number;
read_timeout_ms: number;
max_bytes: number;
max_redirects: number;
allow_private_hosts: boolean;
max_cache_bytes: number;
}
| {
type: "s3";
uri: string;
endpoint_url?: string;
region?: string;
credentials: "ambient" | "static_files";
trusted_endpoint: boolean;
allow_private_endpoint: boolean;
allow_insecure_endpoint: boolean;
max_bytes: number;
max_objects: number;
max_pages: number;
page_size: number;
};
export interface WorkspaceEvidence {
source: EvidenceSource;
policy: EvidencePolicy;
}
export interface CanonicalWorkspace {
workspace: {
schema_version: 3;
@@ -48,6 +91,7 @@ export interface CanonicalWorkspace {
};
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
diagnostics?: CanonicalDiagnostics;
evidence?: WorkspaceEvidence;
}
export interface WorkspaceRevision {
@@ -132,6 +176,16 @@ const conflictFields = new Set([
"diagnostics", "diagnostics.dwh_rest",
"diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth",
"diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema",
"evidence", "evidence.source", "evidence.source.type", "evidence.source.uri",
"evidence.source.patterns", "evidence.source.max_bytes", "evidence.source.uris",
"evidence.source.authentication", "evidence.source.connect_timeout_ms",
"evidence.source.read_timeout_ms", "evidence.source.max_redirects",
"evidence.source.allow_private_hosts", "evidence.source.max_cache_bytes",
"evidence.source.endpoint_url", "evidence.source.region", "evidence.source.credentials",
"evidence.source.trusted_endpoint", "evidence.source.allow_private_endpoint",
"evidence.source.allow_insecure_endpoint", "evidence.source.max_objects",
"evidence.source.max_pages", "evidence.source.page_size", "evidence.policy",
"evidence.policy.max_chunk_chars", "evidence.policy.retain_published_generations",
]);
const workspaceErrorCodes = new Set<WorkspaceErrorCode>([
@@ -193,20 +247,43 @@ export function asWorkspaceConflict(error: unknown): WorkspaceConflict | undefin
};
}
function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace {
const workspace = sanitizeCanonicalWorkspace(value);
if (!workspace) throw new Error("Workspace API returned an invalid canonical workspace");
return workspace;
}
export const listWorkspaces = () => apiFetch<WorkspaceSummary[]>("/workspaces");
export const getWorkspace = (id: string) => apiFetch<WorkspaceRecord>(`/workspaces/${encodeURIComponent(id)}`);
export const getWorkspace = async (id: string): Promise<WorkspaceRecord> => {
const response = await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}`);
const source = object(response);
if (!source) throw new Error("Workspace API returned an invalid workspace record");
return {
workspace: requireCanonicalWorkspace(source.workspace),
revision: source.revision as WorkspaceRevision,
};
};
export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" });
export const validateWorkspace = (workspace: CanonicalWorkspace) =>
apiFetch<{ workspace: CanonicalWorkspace; contract: unknown }>("/workspaces/validate", {
method: "POST", body: JSON.stringify({ workspace }),
export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
const safe = requireCanonicalWorkspace(workspace);
const response = await apiFetch<unknown>("/workspaces/validate", {
method: "POST", body: JSON.stringify({ workspace: safe }),
});
const source = object(response);
if (!source) throw new Error("Workspace API returned an invalid validation result");
return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract };
};
export const testWorkspace = (id: string) =>
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
export const publishWorkspace = (request: PublishWorkspaceRequest) =>
apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", {
method: "POST", body: JSON.stringify(request),
export const publishWorkspace = (request: PublishWorkspaceRequest) => {
const safeRequest: PublishWorkspaceRequest = request.action === "delete"
? request
: { ...request, workspace: requireCanonicalWorkspace(request.workspace) };
return apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", {
method: "POST", body: JSON.stringify(safeRequest),
});
};
export const exportWorkspace = (id: string) =>
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
export const importWorkspace = (bundle: File) => {