458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
import type {
|
|
CanonicalDiagnostics, CanonicalWorkspace, EvidencePolicy, EvidenceSource,
|
|
RestDiagnosticRequest, WorkspaceEvidence,
|
|
} from "../api/workspaces";
|
|
export { workspacePreferences, type WorkspacePreference } from "./preferences";
|
|
|
|
export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry.";
|
|
export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry.";
|
|
|
|
export class WorkspaceSelectionError extends Error {}
|
|
|
|
type PolicySelection = {
|
|
workspaceId: string;
|
|
generation: number;
|
|
state: "summary" | "pending" | "ready" | "error";
|
|
error?: Error;
|
|
settled: Promise<void>;
|
|
settle: () => void;
|
|
superseded: Promise<void>;
|
|
supersede: () => void;
|
|
};
|
|
|
|
let selection: PolicySelection | undefined;
|
|
let nextGeneration = 0;
|
|
|
|
function pendingSelection(workspaceId: string, state: "summary" | "pending"): PolicySelection {
|
|
let settle!: () => void;
|
|
let supersede!: () => void;
|
|
return {
|
|
workspaceId,
|
|
generation: ++nextGeneration,
|
|
state,
|
|
settled: new Promise<void>((resolve) => { settle = resolve; }),
|
|
settle,
|
|
superseded: new Promise<void>((resolve) => { supersede = resolve; }),
|
|
supersede,
|
|
};
|
|
}
|
|
|
|
/** In-memory coordination between the footer's selected policy and session creation. */
|
|
export const workspacePolicyGate = {
|
|
beginSummary(workspaceId: string): void {
|
|
if (selection?.workspaceId === workspaceId && selection.state === "summary") return;
|
|
selection?.supersede();
|
|
selection = pendingSelection(workspaceId, "summary");
|
|
},
|
|
|
|
select(workspaceId: string): void {
|
|
if (selection?.workspaceId === workspaceId) {
|
|
if (selection.state === "summary") selection.state = "pending";
|
|
if (selection.state === "pending") return;
|
|
}
|
|
selection?.supersede();
|
|
selection = pendingSelection(workspaceId, "pending");
|
|
},
|
|
|
|
allowLegacy(workspaceId: string): void {
|
|
if (selection?.workspaceId !== workspaceId) {
|
|
this.clear();
|
|
return;
|
|
}
|
|
if (selection.state === "summary") {
|
|
selection.state = "ready";
|
|
selection.settle();
|
|
}
|
|
},
|
|
|
|
rejectSummary(workspaceId: string): void {
|
|
if (!selection || selection.workspaceId !== workspaceId || selection.state !== "summary") return;
|
|
selection.state = "error";
|
|
selection.error = new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
|
|
selection.settle();
|
|
},
|
|
|
|
clear(): void {
|
|
selection?.supersede();
|
|
selection = undefined;
|
|
},
|
|
|
|
resolve(workspaceId: string): void {
|
|
if (!selection || selection.workspaceId !== workspaceId || selection.state !== "pending") return;
|
|
selection.state = "ready";
|
|
selection.settle();
|
|
},
|
|
|
|
reject(workspaceId: string): void {
|
|
if (!selection || selection.workspaceId !== workspaceId || selection.state !== "pending") return;
|
|
selection.state = "error";
|
|
selection.error = new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
|
selection.settle();
|
|
},
|
|
|
|
async waitForCurrent<T extends { workspaceId?: string }>(read: () => T): Promise<T> {
|
|
while (true) {
|
|
const current = read();
|
|
const pending = selection;
|
|
if (!current.workspaceId || pending?.workspaceId !== current.workspaceId) return current;
|
|
const generation = pending.generation;
|
|
if (pending.state === "ready") return read();
|
|
if (pending.state === "error") throw pending.error;
|
|
await Promise.race([pending.settled, pending.superseded]);
|
|
const completed = selection;
|
|
if (!completed || completed.generation !== generation || read().workspaceId !== current.workspaceId) continue;
|
|
if (completed.state === "error") throw completed.error;
|
|
if (completed.state === "ready") return read();
|
|
}
|
|
},
|
|
};
|
|
|
|
function record(value: unknown): Record<string, unknown> | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: undefined;
|
|
}
|
|
|
|
function exactRecord(value: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
|
|
const source = record(value);
|
|
return source && Object.keys(source).every((key) => keys.includes(key)) ? source : undefined;
|
|
}
|
|
|
|
function text(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
}
|
|
|
|
function identifier(value: unknown): string | undefined {
|
|
return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) ? value : undefined;
|
|
}
|
|
|
|
function workspaceId(value: unknown): string | undefined {
|
|
return typeof value === "string" && /^[a-z][a-z0-9-]{2,62}$/.test(value) ? value : undefined;
|
|
}
|
|
|
|
function modelReference(value: unknown): `${string}/${string}` | undefined {
|
|
return typeof value === "string" && /^[^/\s]+\/[^/\s]+$/.test(value) ? value as `${string}/${string}` : undefined;
|
|
}
|
|
|
|
function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | 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 !== `${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 {
|
|
return typeof value === "string" && choices.includes(value as T) ? value as T : undefined;
|
|
}
|
|
|
|
function uniqueChoices<T extends string>(value: unknown, choices: readonly T[]): T[] | undefined {
|
|
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
const result: T[] = [];
|
|
for (const item of value) {
|
|
const choice = oneOf(item, choices);
|
|
if (!choice || result.includes(choice)) return undefined;
|
|
result.push(choice);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function uniqueModels(value: unknown): `${string}/${string}`[] | undefined {
|
|
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
const result: `${string}/${string}`[] = [];
|
|
for (const item of value) {
|
|
const model = modelReference(item);
|
|
if (!model || result.includes(model)) return undefined;
|
|
result.push(model);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function originRelativePath(value: unknown): string | undefined {
|
|
return typeof value === "string" && /^\/(?!\/)[^\\\u0000-\u001F\u007F?#]*$/.test(value) && !/%5c/i.test(value)
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function copyRequest(value: unknown, extraKeys: readonly string[] = []): RestDiagnosticRequest | undefined {
|
|
const source = exactRecord(value, ["method", "path", "auth", ...extraKeys]);
|
|
const method = oneOf(source?.method, ["GET", "POST"] as const);
|
|
const path = originRelativePath(source?.path);
|
|
const auth = oneOf(source?.auth, ["none", "bearer", "x-api-key"] as const);
|
|
return method && path && auth ? { method, path, auth } : undefined;
|
|
}
|
|
|
|
function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
|
|
const source = exactRecord(value, ["dwh_rest"]);
|
|
if (!source) return undefined;
|
|
const diagnostics: CanonicalDiagnostics = {};
|
|
if (source.dwh_rest !== undefined) {
|
|
const request = copyRequest(source.dwh_rest, ["response"]);
|
|
const raw = exactRecord(source.dwh_rest, ["method", "path", "auth", "response"]);
|
|
const response = exactRecord(raw?.response, ["database", "schema"]);
|
|
const database = identifier(response?.database);
|
|
const schema = identifier(response?.schema);
|
|
if (!request || !database || !schema) return undefined;
|
|
diagnostics.dwh_rest = { ...request, response: { database, schema } };
|
|
}
|
|
return diagnostics;
|
|
}
|
|
|
|
/** Drops unknown fields before a server response can become a browser draft or conflict view. */
|
|
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
|
|
const source = exactRecord(value, [
|
|
"workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence",
|
|
]);
|
|
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 semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]);
|
|
const vectorStore = exactRecord(semanticIndex?.vector_store, ["engine", "collection", "dimensions", "distance"]);
|
|
const embedding = exactRecord(semanticIndex?.embedding, ["provider", "model", "dimensions"]);
|
|
const policy = exactRecord(source?.llm_policy, ["default", "allowed"]);
|
|
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
|
|
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
|
|
const id = workspaceId(metadata.id);
|
|
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
|
|
const name = text(metadata.name);
|
|
const language = oneOf(metadata.language, ["en", "it"] as const);
|
|
const description = metadata.description === undefined ? undefined : text(metadata.description);
|
|
const database = identifier(dwh.database);
|
|
const schema = identifier(dwh.schema);
|
|
const dwhPort = dwh.port === undefined ? undefined : positiveInteger(dwh.port, 65_535);
|
|
const dwhTimeout = dwh.timeout_ms === undefined ? undefined : positiveInteger(dwh.timeout_ms);
|
|
const dwhTransports = uniqueChoices(dwh.supported_transports, ["postgres_direct", "rest_api", "ssh_tunnel"] as const);
|
|
const collection = workspaceId(vectorStore.collection);
|
|
const vectorDimensions = positiveInteger(vectorStore.dimensions, 32_768);
|
|
const distance = oneOf(vectorStore.distance, ["cosine"] as const);
|
|
const embeddingProvider = oneOf(embedding.provider, ["ollama_internal"] as const);
|
|
const embeddingModel = text(embedding.model);
|
|
const embeddingDimensions = positiveInteger(embedding.dimensions, 32_768);
|
|
const allowedModels = uniqueModels(policy.allowed);
|
|
const defaultModel = policy.default === undefined ? undefined : modelReference(policy.default);
|
|
if (
|
|
metadata.schema_version !== 3 || !id || !name || !language || (metadata.description !== undefined && !description)
|
|
|| dwh.engine !== "postgres" || !database || !schema || (dwh.port !== undefined && !dwhPort) || (dwh.timeout_ms !== undefined && !dwhTimeout) || !dwhTransports
|
|
|| vectorStore.engine !== "qdrant" || !collection || !vectorDimensions || !distance
|
|
|| !embeddingProvider || !embeddingModel || !embeddingDimensions || !allowedModels
|
|
|| (defaultModel !== undefined && !allowedModels.includes(defaultModel)) || vectorDimensions !== embeddingDimensions
|
|
|| vectorDimensions !== 1024 || embeddingDimensions !== 1024
|
|
|| embeddingModel !== "qwen3-embedding:0.6b"
|
|
) 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;
|
|
return {
|
|
workspace: {
|
|
schema_version: 3,
|
|
id,
|
|
name,
|
|
...(description ? { description } : {}),
|
|
language,
|
|
},
|
|
dwh: {
|
|
engine: "postgres", database, schema,
|
|
...(dwhPort ? { port: dwhPort } : {}),
|
|
...(dwhTimeout ? { timeout_ms: dwhTimeout } : {}),
|
|
supported_transports: dwhTransports,
|
|
},
|
|
semantic_index: {
|
|
vector_store: {
|
|
engine: "qdrant", collection, dimensions: 1024, distance: "cosine",
|
|
},
|
|
embedding: {
|
|
provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024,
|
|
},
|
|
},
|
|
llm_policy: {
|
|
...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels,
|
|
},
|
|
...(diagnostics ? { diagnostics } : {}),
|
|
...(evidence ? { evidence } : {}),
|
|
};
|
|
}
|