296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import { ApiError, apiFetch, apiFetchBlob } from "./client";
|
|
import { sanitizeCanonicalWorkspace } from "../workspaces/drafts";
|
|
|
|
export type WorkspaceErrorCode =
|
|
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
|
|
| "workspace_stale" | "workspace_conflict" | "git_unavailable"
|
|
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
|
|
| "connector_unavailable" | "semantic_index_incompatible";
|
|
|
|
export interface RestDiagnosticRequest {
|
|
method: "GET" | "POST";
|
|
path: string;
|
|
auth: "none" | "bearer" | "x-api-key";
|
|
}
|
|
|
|
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;
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
language: "en" | "it";
|
|
};
|
|
dwh: {
|
|
engine: "postgres";
|
|
database: string;
|
|
schema: string;
|
|
port?: number;
|
|
timeout_ms?: number;
|
|
supported_transports: ("postgres_direct" | "rest_api" | "ssh_tunnel")[];
|
|
};
|
|
semantic_index: {
|
|
vector_store: {
|
|
engine: "qdrant";
|
|
collection: string;
|
|
dimensions: 1024;
|
|
distance: "cosine";
|
|
}
|
|
embedding: {
|
|
provider: "ollama_internal";
|
|
model: "qwen3-embedding:0.6b";
|
|
dimensions: 1024;
|
|
};
|
|
};
|
|
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
|
|
diagnostics?: CanonicalDiagnostics;
|
|
evidence?: WorkspaceEvidence;
|
|
}
|
|
|
|
export interface WorkspaceRevision {
|
|
id: string;
|
|
commit: string;
|
|
blob: string;
|
|
snapshotPath: string;
|
|
state: "operational" | "migration_required";
|
|
}
|
|
|
|
export interface WorkspaceSummary {
|
|
id: string;
|
|
/** Kept for compatibility with the existing workspace selector. */
|
|
name: string;
|
|
file: string;
|
|
displayName: string;
|
|
description?: string;
|
|
language: "en" | "it";
|
|
revision: WorkspaceRevision;
|
|
}
|
|
|
|
export interface WorkspaceRecord {
|
|
workspace: CanonicalWorkspace;
|
|
revision: WorkspaceRevision;
|
|
}
|
|
|
|
export interface WorkspaceRegistryStatus {
|
|
branch: string;
|
|
head?: string;
|
|
ahead: number;
|
|
behind: number;
|
|
degraded: boolean;
|
|
lastError?: WorkspaceErrorCode;
|
|
}
|
|
|
|
export interface WorkspaceDiagnostic {
|
|
level: "error" | "warning" | "info";
|
|
code: WorkspaceErrorCode | "binding_ok";
|
|
field?: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface WorkspaceDiagnostics {
|
|
activatable: boolean;
|
|
diagnostics: WorkspaceDiagnostic[];
|
|
}
|
|
|
|
export type PublishWorkspaceRequest =
|
|
| { action: "create"; workspace: CanonicalWorkspace; baseCommit: string }
|
|
| { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string }
|
|
| { action: "delete"; id: string; baseCommit: string; baseBlob: string };
|
|
|
|
export interface WorkspaceConflict {
|
|
code: "workspace_conflict";
|
|
fields: string[];
|
|
expected: WorkspaceConflictRevision;
|
|
actual: WorkspaceConflictRevision;
|
|
base: CanonicalWorkspace;
|
|
local: CanonicalWorkspace;
|
|
remote: CanonicalWorkspace;
|
|
}
|
|
|
|
export interface WorkspaceConflictRevision {
|
|
commit: string;
|
|
blob?: string;
|
|
}
|
|
|
|
export interface WorkspaceApiError {
|
|
status: number;
|
|
code: WorkspaceErrorCode;
|
|
message: string;
|
|
fields?: string[];
|
|
}
|
|
|
|
const conflictFields = new Set([
|
|
"workspace.schema_version", "workspace.id", "workspace.name", "workspace.description", "workspace.language",
|
|
"dwh.engine", "dwh.database", "dwh.schema", "dwh.port", "dwh.timeout_ms", "dwh.supported_transports",
|
|
"semantic_index.vector_store.engine", "semantic_index.vector_store.collection",
|
|
"semantic_index.vector_store.dimensions", "semantic_index.vector_store.distance",
|
|
"semantic_index.embedding.provider", "semantic_index.embedding.model", "semantic_index.embedding.dimensions",
|
|
"llm_policy.default", "llm_policy.allowed",
|
|
"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>([
|
|
"workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
|
|
"workspace_conflict", "git_unavailable", "git_auth_failed", "git_non_fast_forward",
|
|
"git_push_rejected", "connector_unavailable", "semantic_index_incompatible",
|
|
]);
|
|
|
|
function object(value: unknown): Record<string, unknown> | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: undefined;
|
|
}
|
|
|
|
function conflictRevision(value: unknown): WorkspaceConflictRevision | undefined {
|
|
const source = object(value);
|
|
const commit = source?.commit;
|
|
const blob = source?.blob;
|
|
if (typeof commit !== "string" || !/^[0-9a-f]{40}$/.test(commit)) return undefined;
|
|
if (blob !== undefined && (typeof blob !== "string" || !/^[0-9a-f]{40}$/.test(blob))) return undefined;
|
|
return { commit, ...(typeof blob === "string" ? { blob } : {}) };
|
|
}
|
|
|
|
/** Sanitized registry error data; it intentionally excludes the raw response body. */
|
|
export function asWorkspaceApiError(error: unknown): WorkspaceApiError | undefined {
|
|
if (!(error instanceof ApiError)) return undefined;
|
|
const payload = object(error.payload);
|
|
const code = payload?.code;
|
|
const message = payload?.message;
|
|
if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode) || typeof message !== "string") {
|
|
return undefined;
|
|
}
|
|
const fields = Array.isArray(payload?.fields) && payload.fields.every((field) => typeof field === "string")
|
|
? payload.fields
|
|
: undefined;
|
|
return { status: error.status, code: code as WorkspaceErrorCode, message, ...(fields ? { fields } : {}) };
|
|
}
|
|
|
|
/** Narrows a sanitized 409 payload without exposing ApiError's raw body. */
|
|
export function asWorkspaceConflict(error: unknown): WorkspaceConflict | undefined {
|
|
const safe = asWorkspaceApiError(error);
|
|
if (safe?.code !== "workspace_conflict") return undefined;
|
|
const payload = object((error as ApiError).payload);
|
|
const fields = safe.fields;
|
|
const base = payload && sanitizeCanonicalWorkspace(payload.base);
|
|
const local = payload && sanitizeCanonicalWorkspace(payload.local);
|
|
const remote = payload && sanitizeCanonicalWorkspace(payload.remote);
|
|
const expected = payload && conflictRevision(payload.expected);
|
|
const actual = payload && conflictRevision(payload.actual);
|
|
if (!payload || !fields || !fields.every((field) => conflictFields.has(field)) || !expected || !actual || !base || !local || !remote) return undefined;
|
|
return {
|
|
code: "workspace_conflict",
|
|
fields,
|
|
expected,
|
|
actual,
|
|
base,
|
|
local,
|
|
remote,
|
|
};
|
|
}
|
|
|
|
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 = 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 = 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) => {
|
|
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) => {
|
|
const body = new FormData();
|
|
body.set("bundle", bundle);
|
|
return apiFetch<{ draft: { workspace: CanonicalWorkspace; contract?: unknown } }>("/workspaces/import", {
|
|
method: "POST", body,
|
|
});
|
|
};
|