fix: preserve workspace evidence in browser drafts
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { server } from "../test/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 = {
|
const workspace: CanonicalWorkspace = {
|
||||||
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
|
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"] },
|
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 () => {
|
test("uploads a workspace bundle without JSON content type", async () => {
|
||||||
let contentType: string | null = null;
|
let contentType: string | null = null;
|
||||||
server.use(http.post("/api/workspaces/import", ({ request }) => {
|
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();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,49 @@ export interface CanonicalDiagnostics {
|
|||||||
dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } };
|
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 {
|
export interface CanonicalWorkspace {
|
||||||
workspace: {
|
workspace: {
|
||||||
schema_version: 3;
|
schema_version: 3;
|
||||||
@@ -48,6 +91,7 @@ export interface CanonicalWorkspace {
|
|||||||
};
|
};
|
||||||
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
|
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
|
||||||
diagnostics?: CanonicalDiagnostics;
|
diagnostics?: CanonicalDiagnostics;
|
||||||
|
evidence?: WorkspaceEvidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRevision {
|
export interface WorkspaceRevision {
|
||||||
@@ -132,6 +176,16 @@ const conflictFields = new Set([
|
|||||||
"diagnostics", "diagnostics.dwh_rest",
|
"diagnostics", "diagnostics.dwh_rest",
|
||||||
"diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth",
|
"diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth",
|
||||||
"diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema",
|
"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>([
|
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 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 getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
|
||||||
export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" });
|
export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" });
|
||||||
export const validateWorkspace = (workspace: CanonicalWorkspace) =>
|
export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
|
||||||
apiFetch<{ workspace: CanonicalWorkspace; contract: unknown }>("/workspaces/validate", {
|
const safe = requireCanonicalWorkspace(workspace);
|
||||||
method: "POST", body: JSON.stringify({ 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) =>
|
export const testWorkspace = (id: string) =>
|
||||||
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
|
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
|
||||||
export const publishWorkspace = (request: PublishWorkspaceRequest) =>
|
export const publishWorkspace = (request: PublishWorkspaceRequest) => {
|
||||||
apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", {
|
const safeRequest: PublishWorkspaceRequest = request.action === "delete"
|
||||||
method: "POST", body: JSON.stringify(request),
|
? request
|
||||||
|
: { ...request, workspace: requireCanonicalWorkspace(request.workspace) };
|
||||||
|
return apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", {
|
||||||
|
method: "POST", body: JSON.stringify(safeRequest),
|
||||||
});
|
});
|
||||||
|
};
|
||||||
export const exportWorkspace = (id: string) =>
|
export const exportWorkspace = (id: string) =>
|
||||||
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
|
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
|
||||||
export const importWorkspace = (bundle: File) => {
|
export const importWorkspace = (bundle: File) => {
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ const workspace: CanonicalWorkspace = {
|
|||||||
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
|
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const evidenceWorkspace: CanonicalWorkspace = {
|
||||||
|
...workspace,
|
||||||
|
evidence: {
|
||||||
|
source: {
|
||||||
|
type: "filesystem",
|
||||||
|
uri: "workspace-content/psd-clinical/evidence",
|
||||||
|
patterns: ["documents/**/*.pdf"],
|
||||||
|
max_bytes: 12_000_000,
|
||||||
|
},
|
||||||
|
policy: { max_chunk_chars: 8_000, retain_published_generations: 5 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const draft: WorkspaceDraft = {
|
const draft: WorkspaceDraft = {
|
||||||
workspaceId: "psd-clinical",
|
workspaceId: "psd-clinical",
|
||||||
baseCommit: "a".repeat(40),
|
baseCommit: "a".repeat(40),
|
||||||
@@ -99,3 +112,50 @@ test("rejects a non-positive DWH timeout without saving a draft", async () => {
|
|||||||
expect(screen.getByLabelText("DWH timeout (ms)")).toHaveAttribute("aria-invalid", "true");
|
expect(screen.getByLabelText("DWH timeout (ms)")).toHaveAttribute("aria-invalid", "true");
|
||||||
expect(onSaveDraft).not.toHaveBeenCalled();
|
expect(onSaveDraft).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("shows a safe read-only Evidence summary without authoring or secret binding controls", () => {
|
||||||
|
render(<WorkspaceEditor draft={{ ...draft, workspace: evidenceWorkspace }} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
|
||||||
|
|
||||||
|
const summary = screen.getByRole("region", { name: "Evidence" });
|
||||||
|
expect(summary).toHaveTextContent("filesystem");
|
||||||
|
expect(summary).toHaveTextContent("workspace-content/psd-clinical/evidence");
|
||||||
|
expect(summary).toHaveTextContent("8,000");
|
||||||
|
expect(summary).toHaveTextContent("5");
|
||||||
|
expect(summary).toHaveTextContent("Evidence is managed by the registry descriptor in P1.");
|
||||||
|
expect(summary).not.toHaveTextContent(/signed_urls_file|static_files|secret|binding/i);
|
||||||
|
expect(screen.queryByLabelText(/evidence.*(source|uri|pattern|credential)/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishes an edited DWH and LLM field without dropping or mutating Evidence", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onPublish = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<WorkspaceEditor draft={{ ...draft, workspace: evidenceWorkspace }} onSaveDraft={vi.fn()} onPublish={onPublish} />);
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText("DWH database"));
|
||||||
|
await user.type(screen.getByLabelText("DWH database"), "research");
|
||||||
|
await user.clear(screen.getByLabelText("Allowed models"));
|
||||||
|
await user.type(screen.getByLabelText("Allowed models"), "openai/gpt-5");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Publish draft" }));
|
||||||
|
|
||||||
|
expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: "update",
|
||||||
|
workspace: expect.objectContaining({
|
||||||
|
dwh: expect.objectContaining({ database: "research" }),
|
||||||
|
llm_policy: { allowed: ["openai/gpt-5"] },
|
||||||
|
evidence: evidenceWorkspace.evidence,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(evidenceWorkspace.evidence?.source).toEqual({
|
||||||
|
type: "filesystem",
|
||||||
|
uri: "workspace-content/psd-clinical/evidence",
|
||||||
|
patterns: ["documents/**/*.pdf"],
|
||||||
|
max_bytes: 12_000_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not show an Evidence summary for a workspace without Evidence", () => {
|
||||||
|
render(<WorkspaceEditor draft={draft} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("region", { name: "Evidence" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -108,6 +108,23 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EvidenceSummary({ evidence }: { evidence: NonNullable<CanonicalWorkspace["evidence"]> }) {
|
||||||
|
const sourceIdentity = evidence.source.type === "http"
|
||||||
|
? `${evidence.source.uris.length} canonical URI${evidence.source.uris.length === 1 ? "" : "s"}`
|
||||||
|
: evidence.source.uri;
|
||||||
|
return (
|
||||||
|
<Section title="Evidence">
|
||||||
|
<dl className="grid gap-2 text-sm sm:col-span-2 sm:grid-cols-2">
|
||||||
|
<div><dt className="font-medium text-foreground">Source type</dt><dd className="text-muted-foreground">{evidence.source.type}</dd></div>
|
||||||
|
<div><dt className="font-medium text-foreground">Source</dt><dd className="break-all text-muted-foreground">{sourceIdentity}</dd></div>
|
||||||
|
<div><dt className="font-medium text-foreground">Chunk size</dt><dd className="text-muted-foreground">{evidence.policy.max_chunk_chars.toLocaleString("en-US")} characters</dd></div>
|
||||||
|
<div><dt className="font-medium text-foreground">Retention</dt><dd className="text-muted-foreground">{evidence.policy.retain_published_generations.toLocaleString("en-US")} published generations</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p className="sm:col-span-2 text-sm text-muted-foreground">Evidence is managed by the registry descriptor in P1.</p>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive";
|
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive";
|
||||||
|
|
||||||
export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) {
|
export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) {
|
||||||
@@ -220,6 +237,8 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool
|
|||||||
</Field>
|
</Field>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{workspace.evidence && <EvidenceSummary evidence={workspace.evidence} />}
|
||||||
|
|
||||||
<Section title="Installation requirements">
|
<Section title="Installation requirements">
|
||||||
<p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.</p>
|
<p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { beforeEach, expect, test } from "vitest";
|
import { beforeEach, expect, test } from "vitest";
|
||||||
import type { CanonicalWorkspace } from "../api/workspaces";
|
import type { CanonicalWorkspace } from "../api/workspaces";
|
||||||
import { workspaceDeletionDrafts, workspaceDrafts, workspacePreferences } from "./drafts";
|
import {
|
||||||
|
sanitizeCanonicalWorkspace, workspaceDeletionDrafts, workspaceDrafts, workspacePreferences,
|
||||||
|
} from "./drafts";
|
||||||
|
|
||||||
const workspace: CanonicalWorkspace = {
|
const workspace: CanonicalWorkspace = {
|
||||||
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
|
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
|
||||||
@@ -17,6 +19,69 @@ const workspace: CanonicalWorkspace = {
|
|||||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const policy = { max_chunk_chars: 8_000, retain_published_generations: 5 };
|
||||||
|
|
||||||
|
const evidenceWorkspaces = [
|
||||||
|
{
|
||||||
|
name: "filesystem",
|
||||||
|
workspace: {
|
||||||
|
...workspace,
|
||||||
|
evidence: {
|
||||||
|
source: {
|
||||||
|
type: "filesystem",
|
||||||
|
uri: "workspace-content/psd-clinical/evidence",
|
||||||
|
patterns: ["documents/**/*.pdf", "notes/*.md"],
|
||||||
|
max_bytes: 12_000_000,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
},
|
||||||
|
} satisfies CanonicalWorkspace,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http",
|
||||||
|
workspace: {
|
||||||
|
...workspace,
|
||||||
|
evidence: {
|
||||||
|
source: {
|
||||||
|
type: "http",
|
||||||
|
uris: ["https://evidence.example/manifest.json", "http://evidence.example/files/list.txt"],
|
||||||
|
authentication: "signed_urls_file",
|
||||||
|
connect_timeout_ms: 2_000,
|
||||||
|
read_timeout_ms: 20_000,
|
||||||
|
max_bytes: 12_000_000,
|
||||||
|
max_redirects: 2,
|
||||||
|
allow_private_hosts: false,
|
||||||
|
max_cache_bytes: 24_000_000,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
},
|
||||||
|
} satisfies CanonicalWorkspace,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "s3",
|
||||||
|
workspace: {
|
||||||
|
...workspace,
|
||||||
|
evidence: {
|
||||||
|
source: {
|
||||||
|
type: "s3",
|
||||||
|
uri: "s3://clinical-evidence/published/",
|
||||||
|
endpoint_url: "https://objects.example",
|
||||||
|
region: "eu-west-1",
|
||||||
|
credentials: "static_files",
|
||||||
|
trusted_endpoint: true,
|
||||||
|
allow_private_endpoint: false,
|
||||||
|
allow_insecure_endpoint: false,
|
||||||
|
max_bytes: 12_000_000,
|
||||||
|
max_objects: 2_000,
|
||||||
|
max_pages: 20,
|
||||||
|
page_size: 100,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
},
|
||||||
|
} satisfies CanonicalWorkspace,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
test("keeps an anonymous user's model selection in browser storage", () => {
|
test("keeps an anonymous user's model selection in browser storage", () => {
|
||||||
workspacePreferences.save({
|
workspacePreferences.save({
|
||||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||||
@@ -146,3 +211,73 @@ test("rejects a draft that tries to persist removed external semantic configurat
|
|||||||
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
|
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
|
||||||
expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull();
|
expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test.each(evidenceWorkspaces)("deep-sanitizes canonical $name Evidence", ({ workspace: configured }) => {
|
||||||
|
const sanitized = sanitizeCanonicalWorkspace(configured);
|
||||||
|
|
||||||
|
expect(sanitized).toEqual(configured);
|
||||||
|
expect(sanitized).not.toBe(configured);
|
||||||
|
expect(sanitized?.evidence).not.toBe(configured.evidence);
|
||||||
|
expect(sanitized?.evidence?.source).not.toBe(configured.evidence.source);
|
||||||
|
expect(sanitized?.evidence?.policy).not.toBe(configured.evidence.policy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(evidenceWorkspaces)("saves and reloads canonical $name Evidence", ({ workspace: configured }) => {
|
||||||
|
workspaceDrafts.save({
|
||||||
|
workspaceId: "psd-clinical",
|
||||||
|
baseCommit: "a".repeat(40),
|
||||||
|
workspace: configured,
|
||||||
|
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceDrafts.load("psd-clinical")?.workspace.evidence).toEqual(configured.evidence);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["an unknown Evidence key", { ...evidenceWorkspaces[0].workspace.evidence, extra: "unexpected" }],
|
||||||
|
["a secret-shaped source key", {
|
||||||
|
...evidenceWorkspaces[1].workspace.evidence,
|
||||||
|
source: { ...evidenceWorkspaces[1].workspace.evidence.source, signed_urls_file: "/run/secrets/urls" },
|
||||||
|
}],
|
||||||
|
["an HTTP URI with credentials", {
|
||||||
|
...evidenceWorkspaces[1].workspace.evidence,
|
||||||
|
source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://user:secret@evidence.example/file"] },
|
||||||
|
}],
|
||||||
|
["an HTTP URI with a signed query", {
|
||||||
|
...evidenceWorkspaces[1].workspace.evidence,
|
||||||
|
source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://evidence.example/file?token=secret"] },
|
||||||
|
}],
|
||||||
|
["an unsafe S3 URI", {
|
||||||
|
...evidenceWorkspaces[2].workspace.evidence,
|
||||||
|
source: { ...evidenceWorkspaces[2].workspace.evidence.source, uri: "s3://user:secret@clinical-evidence/published/" },
|
||||||
|
}],
|
||||||
|
["an invalid zero policy value", {
|
||||||
|
...evidenceWorkspaces[0].workspace.evidence,
|
||||||
|
policy: { ...policy, max_chunk_chars: 0 },
|
||||||
|
}],
|
||||||
|
["an unsafe integer policy value", {
|
||||||
|
...evidenceWorkspaces[0].workspace.evidence,
|
||||||
|
policy: { ...policy, retain_published_generations: Number.MAX_SAFE_INTEGER + 1 },
|
||||||
|
}],
|
||||||
|
["a malformed source union", {
|
||||||
|
...evidenceWorkspaces[0].workspace.evidence,
|
||||||
|
source: { ...evidenceWorkspaces[0].workspace.evidence.source, uris: ["https://evidence.example/file"] },
|
||||||
|
}],
|
||||||
|
])("rejects %s instead of putting it in browser state", (_reason, evidence) => {
|
||||||
|
const invalid = { ...workspace, evidence };
|
||||||
|
expect(sanitizeCanonicalWorkspace(invalid)).toBeUndefined();
|
||||||
|
|
||||||
|
workspaceDrafts.save({
|
||||||
|
workspaceId: "psd-clinical",
|
||||||
|
baseCommit: "a".repeat(40),
|
||||||
|
workspace: invalid as CanonicalWorkspace,
|
||||||
|
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("continues to sanitize workspaces without Evidence", () => {
|
||||||
|
expect(sanitizeCanonicalWorkspace(workspace)).toEqual(workspace);
|
||||||
|
expect(sanitizeCanonicalWorkspace(workspace)).not.toHaveProperty("evidence");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { CanonicalDiagnostics, CanonicalWorkspace, RestDiagnosticRequest } from "../api/workspaces";
|
import type {
|
||||||
|
CanonicalDiagnostics, CanonicalWorkspace, EvidencePolicy, EvidenceSource,
|
||||||
|
RestDiagnosticRequest, WorkspaceEvidence,
|
||||||
|
} from "../api/workspaces";
|
||||||
export { workspacePreferences, type WorkspacePreference } from "./preferences";
|
export { workspacePreferences, type WorkspacePreference } from "./preferences";
|
||||||
|
|
||||||
export interface WorkspaceDraft {
|
export interface WorkspaceDraft {
|
||||||
@@ -156,7 +159,196 @@ function modelReference(value: unknown): `${string}/${string}` | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined {
|
function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined {
|
||||||
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= max ? value : undefined;
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= max ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonnegativeInteger(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeEvidencePattern(value: string): boolean {
|
||||||
|
const parts = value.split("/");
|
||||||
|
return value.length > 0
|
||||||
|
&& !value.startsWith("/")
|
||||||
|
&& !value.includes("\\")
|
||||||
|
&& !/[\u0000-\u001f\u007f]/u.test(value)
|
||||||
|
&& parts.every((part) => part !== "" && part !== "." && part !== "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePublicHttpUri(value: string): URL | undefined {
|
||||||
|
if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (
|
||||||
|
!["http:", "https:"].includes(parsed.protocol)
|
||||||
|
|| parsed.hostname.length === 0
|
||||||
|
|| parsed.username !== ""
|
||||||
|
|| parsed.password !== ""
|
||||||
|
|| parsed.search !== ""
|
||||||
|
|| parsed.hash !== ""
|
||||||
|
) return undefined;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeS3Uri(value: string): boolean {
|
||||||
|
if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
const bucket = parsed.hostname;
|
||||||
|
const validBucket = /^(?=.{3,63}$)(?!-)(?!.*\.\.)(?!.*\.-)(?!.*-\.)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(bucket)
|
||||||
|
&& !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bucket);
|
||||||
|
return parsed.protocol === "s3:"
|
||||||
|
&& validBucket
|
||||||
|
&& parsed.port === ""
|
||||||
|
&& parsed.username === ""
|
||||||
|
&& parsed.password === ""
|
||||||
|
&& parsed.search === ""
|
||||||
|
&& parsed.hash === ""
|
||||||
|
&& parsed.href === value;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeS3Endpoint(value: string): boolean {
|
||||||
|
const parsed = parsePublicHttpUri(value);
|
||||||
|
return parsed !== undefined && (parsed.pathname === "/" || parsed.pathname === "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyEvidencePolicy(value: unknown): EvidencePolicy | undefined {
|
||||||
|
const source = exactRecord(value, ["max_chunk_chars", "retain_published_generations"]);
|
||||||
|
const maxChunkChars = positiveInteger(source?.max_chunk_chars);
|
||||||
|
const retainedGenerations = positiveInteger(source?.retain_published_generations);
|
||||||
|
return source && maxChunkChars && retainedGenerations
|
||||||
|
? { max_chunk_chars: maxChunkChars, retain_published_generations: retainedGenerations }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFilesystemEvidence(value: unknown, id: string): EvidenceSource | undefined {
|
||||||
|
const source = exactRecord(value, ["type", "uri", "patterns", "max_bytes"]);
|
||||||
|
const uri = typeof source?.uri === "string" ? source.uri : undefined;
|
||||||
|
const patterns = source?.patterns;
|
||||||
|
const maxBytes = positiveInteger(source?.max_bytes);
|
||||||
|
if (
|
||||||
|
source?.type !== "filesystem"
|
||||||
|
|| uri !== `workspace-content/${id}/evidence`
|
||||||
|
|| !Array.isArray(patterns)
|
||||||
|
|| patterns.length === 0
|
||||||
|
|| !patterns.every((pattern) => typeof pattern === "string" && isSafeEvidencePattern(pattern))
|
||||||
|
|| new Set(patterns).size !== patterns.length
|
||||||
|
|| !maxBytes
|
||||||
|
) return undefined;
|
||||||
|
return { type: "filesystem", uri, patterns: [...patterns] as string[], max_bytes: maxBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyHttpEvidence(value: unknown): EvidenceSource | undefined {
|
||||||
|
const source = exactRecord(value, [
|
||||||
|
"type", "uris", "authentication", "connect_timeout_ms", "read_timeout_ms", "max_bytes",
|
||||||
|
"max_redirects", "allow_private_hosts", "max_cache_bytes",
|
||||||
|
]);
|
||||||
|
const uris = source?.uris;
|
||||||
|
const authentication = oneOf(source?.authentication, ["none", "signed_urls_file"] as const);
|
||||||
|
const connectTimeout = positiveInteger(source?.connect_timeout_ms);
|
||||||
|
const readTimeout = positiveInteger(source?.read_timeout_ms);
|
||||||
|
const maxBytes = positiveInteger(source?.max_bytes);
|
||||||
|
const maxRedirects = nonnegativeInteger(source?.max_redirects);
|
||||||
|
const maxCacheBytes = positiveInteger(source?.max_cache_bytes);
|
||||||
|
if (
|
||||||
|
source?.type !== "http"
|
||||||
|
|| !Array.isArray(uris)
|
||||||
|
|| uris.length === 0
|
||||||
|
|| !uris.every((uri) => typeof uri === "string" && parsePublicHttpUri(uri) !== undefined)
|
||||||
|
|| new Set(uris.map((uri) => parsePublicHttpUri(uri as string)?.href)).size !== uris.length
|
||||||
|
|| !authentication
|
||||||
|
|| !connectTimeout
|
||||||
|
|| !readTimeout
|
||||||
|
|| !maxBytes
|
||||||
|
|| maxRedirects === undefined
|
||||||
|
|| typeof source.allow_private_hosts !== "boolean"
|
||||||
|
|| !maxCacheBytes
|
||||||
|
) return undefined;
|
||||||
|
return {
|
||||||
|
type: "http",
|
||||||
|
uris: [...uris] as string[],
|
||||||
|
authentication,
|
||||||
|
connect_timeout_ms: connectTimeout,
|
||||||
|
read_timeout_ms: readTimeout,
|
||||||
|
max_bytes: maxBytes,
|
||||||
|
max_redirects: maxRedirects,
|
||||||
|
allow_private_hosts: source.allow_private_hosts,
|
||||||
|
max_cache_bytes: maxCacheBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyS3Evidence(value: unknown): EvidenceSource | undefined {
|
||||||
|
const source = exactRecord(value, [
|
||||||
|
"type", "uri", "endpoint_url", "region", "credentials", "trusted_endpoint",
|
||||||
|
"allow_private_endpoint", "allow_insecure_endpoint", "max_bytes", "max_objects",
|
||||||
|
"max_pages", "page_size",
|
||||||
|
]);
|
||||||
|
const uri = typeof source?.uri === "string" && isSafeS3Uri(source.uri) ? source.uri : undefined;
|
||||||
|
const endpoint = source?.endpoint_url === undefined
|
||||||
|
? undefined
|
||||||
|
: typeof source.endpoint_url === "string" && isSafeS3Endpoint(source.endpoint_url)
|
||||||
|
? source.endpoint_url
|
||||||
|
: null;
|
||||||
|
const region = source?.region === undefined ? undefined : text(source.region);
|
||||||
|
const credentials = oneOf(source?.credentials, ["ambient", "static_files"] as const);
|
||||||
|
const maxBytes = positiveInteger(source?.max_bytes);
|
||||||
|
const maxObjects = positiveInteger(source?.max_objects);
|
||||||
|
const maxPages = positiveInteger(source?.max_pages);
|
||||||
|
const pageSize = positiveInteger(source?.page_size, 1_000);
|
||||||
|
if (
|
||||||
|
source?.type !== "s3"
|
||||||
|
|| !uri
|
||||||
|
|| endpoint === null
|
||||||
|
|| (source.region !== undefined && !region)
|
||||||
|
|| !credentials
|
||||||
|
|| typeof source.trusted_endpoint !== "boolean"
|
||||||
|
|| typeof source.allow_private_endpoint !== "boolean"
|
||||||
|
|| typeof source.allow_insecure_endpoint !== "boolean"
|
||||||
|
|| !maxBytes
|
||||||
|
|| !maxObjects
|
||||||
|
|| !maxPages
|
||||||
|
|| !pageSize
|
||||||
|
|| (endpoint === undefined && (
|
||||||
|
source.trusted_endpoint || source.allow_private_endpoint || source.allow_insecure_endpoint
|
||||||
|
))
|
||||||
|
|| (endpoint !== undefined && !source.trusted_endpoint)
|
||||||
|
|| (endpoint !== undefined && parsePublicHttpUri(endpoint)?.protocol === "http:" && !source.allow_insecure_endpoint)
|
||||||
|
) return undefined;
|
||||||
|
return {
|
||||||
|
type: "s3",
|
||||||
|
uri,
|
||||||
|
...(endpoint === undefined ? {} : { endpoint_url: endpoint }),
|
||||||
|
...(region === undefined ? {} : { region }),
|
||||||
|
credentials,
|
||||||
|
trusted_endpoint: source.trusted_endpoint,
|
||||||
|
allow_private_endpoint: source.allow_private_endpoint,
|
||||||
|
allow_insecure_endpoint: source.allow_insecure_endpoint,
|
||||||
|
max_bytes: maxBytes,
|
||||||
|
max_objects: maxObjects,
|
||||||
|
max_pages: maxPages,
|
||||||
|
page_size: pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyEvidence(value: unknown, id: string): WorkspaceEvidence | undefined {
|
||||||
|
const source = exactRecord(value, ["source", "policy"]);
|
||||||
|
if (!source) return undefined;
|
||||||
|
const type = record(source.source)?.type;
|
||||||
|
const evidenceSource = type === "filesystem"
|
||||||
|
? copyFilesystemEvidence(source.source, id)
|
||||||
|
: type === "http"
|
||||||
|
? copyHttpEvidence(source.source)
|
||||||
|
: type === "s3"
|
||||||
|
? copyS3Evidence(source.source)
|
||||||
|
: undefined;
|
||||||
|
const policy = copyEvidencePolicy(source.policy);
|
||||||
|
return evidenceSource && policy ? { source: evidenceSource, policy } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function oneOf<T extends string>(value: unknown, choices: readonly T[]): T | undefined {
|
function oneOf<T extends string>(value: unknown, choices: readonly T[]): T | undefined {
|
||||||
@@ -217,7 +409,9 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
|
|||||||
|
|
||||||
/** Drops unknown fields before a server response can become a browser draft or conflict view. */
|
/** Drops unknown fields before a server response can become a browser draft or conflict view. */
|
||||||
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
|
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
|
||||||
const source = exactRecord(value, ["workspace", "dwh", "semantic_index", "llm_policy", "diagnostics"]);
|
const source = exactRecord(value, [
|
||||||
|
"workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence",
|
||||||
|
]);
|
||||||
const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]);
|
const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]);
|
||||||
const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]);
|
const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]);
|
||||||
const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]);
|
const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]);
|
||||||
@@ -227,6 +421,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
|||||||
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
|
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
|
||||||
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
|
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
|
||||||
const id = workspaceId(metadata.id);
|
const id = workspaceId(metadata.id);
|
||||||
|
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
|
||||||
const name = text(metadata.name);
|
const name = text(metadata.name);
|
||||||
const language = oneOf(metadata.language, ["en", "it"] as const);
|
const language = oneOf(metadata.language, ["en", "it"] as const);
|
||||||
const description = metadata.description === undefined ? undefined : text(metadata.description);
|
const description = metadata.description === undefined ? undefined : text(metadata.description);
|
||||||
@@ -253,6 +448,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
|||||||
|| embeddingModel !== "qwen3-embedding:0.6b"
|
|| embeddingModel !== "qwen3-embedding:0.6b"
|
||||||
) return undefined;
|
) return undefined;
|
||||||
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
|
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
|
||||||
|
if (source?.evidence !== undefined && !evidence) return undefined;
|
||||||
if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined;
|
if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined;
|
||||||
return {
|
return {
|
||||||
workspace: {
|
workspace: {
|
||||||
@@ -280,6 +476,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
|||||||
...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels,
|
...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels,
|
||||||
},
|
},
|
||||||
...(diagnostics ? { diagnostics } : {}),
|
...(diagnostics ? { diagnostics } : {}),
|
||||||
|
...(evidence ? { evidence } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user