Files
ThothII/frontend/src/api/workspaces.ts
T

351 lines
12 KiB
TypeScript

import { ApiError, apiFetch } from "./client";
import { sanitizeCanonicalWorkspace } from "../workspaces/drafts";
export type WorkspaceErrorCode =
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
| "workspace_stale" | "git_unavailable"
| "git_auth_failed" | "git_non_fast_forward"
| "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;
}
export interface WorkspaceSummary {
id: string;
/** Kept for compatibility with the existing workspace selector. */
name: string;
file: string;
displayName: string;
description?: string;
configurationState: "ready" | "configuration_required";
revision?: WorkspaceRevision;
}
export interface WorkspaceRecord {
workspace: CanonicalWorkspace;
revision: WorkspaceRevision;
}
export interface WorkspaceRegistryStatus {
branch: string;
head?: string;
ahead: number;
behind: number;
degraded: boolean;
lastError?: WorkspaceErrorCode;
repository?: {
host: string;
repository: string;
transport: "https" | "ssh" | "local";
};
}
export interface WorkspaceDiagnostic {
level: "error" | "warning" | "info";
code: WorkspaceErrorCode | "binding_ok";
field?: string;
message: string;
}
export interface WorkspaceDiagnostics {
activatable: boolean;
diagnostics: WorkspaceDiagnostic[];
}
export interface WorkspaceSecretRequirement {
id: string;
connector: "dwh" | "evidence";
label: string;
description: string;
input: "password" | "textarea";
required: boolean;
configured: boolean;
}
export interface WorkspaceRuntimeConfiguration {
workspaceId: string;
revision: WorkspaceRevision;
configurationState: "ready" | "configuration_required";
requirements: WorkspaceSecretRequirement[];
}
export interface WorkspaceApiError {
status: number;
code: WorkspaceErrorCode;
message: string;
fields?: string[];
}
const workspaceErrorCodes = new Set<WorkspaceErrorCode>([
"workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "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 exactObject(value: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
const source = object(value);
return source && Object.keys(source).every((key) => keys.includes(key)) ? source : undefined;
}
function workspaceRevision(value: unknown, expectedId: string): WorkspaceRevision | undefined {
const source = exactObject(value, ["id", "commit", "blob", "snapshotPath"]);
if (!source) return undefined;
const { id, commit, blob, snapshotPath } = source;
if (
id !== expectedId
|| typeof id !== "string" || !/^[a-z][a-z0-9-]{2,62}$/.test(id)
|| typeof commit !== "string" || !/^[0-9a-f]{40}$/.test(commit)
|| typeof blob !== "string" || !/^[0-9a-f]{40}$/.test(blob)
|| typeof snapshotPath !== "string" || snapshotPath.length === 0
|| snapshotPath.trim() !== snapshotPath || /[\x00-]/u.test(snapshotPath)
) return undefined;
return { id, commit, blob, snapshotPath };
}
function workspaceSummary(value: unknown): WorkspaceSummary | undefined {
const source = exactObject(value, [
"id", "name", "file", "displayName", "description", "configurationState", "revision",
]);
if (!source) return undefined;
const { id, name, file, displayName, description, configurationState } = source;
const validText = (candidate: unknown) => typeof candidate === "string"
&& candidate.length > 0
&& candidate.trim() === candidate;
if (
typeof id !== "string" || !/^[a-z][a-z0-9-]{2,62}$/.test(id)
|| name !== id || file !== `${id}/workspace.yaml`
|| !validText(displayName)
|| (description !== undefined && !validText(description))
|| (configurationState !== "ready" && configurationState !== "configuration_required")
) return undefined;
const revision = source.revision === undefined
? undefined
: workspaceRevision(source.revision, id);
if (
!revision
) return undefined;
return {
id,
name: name as string,
file: file as string,
displayName: displayName as string,
...(description === undefined ? {} : { description: description as string }),
configurationState,
revision,
};
}
function requireWorkspaceRevision(value: unknown, expectedId: string): WorkspaceRevision {
const revision = workspaceRevision(value, expectedId);
if (!revision) throw new Error("Workspace API returned an invalid workspace revision");
return revision;
}
/** 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 } : {}) };
}
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 = async (): Promise<WorkspaceSummary[]> => {
const response = await apiFetch<unknown>("/workspaces");
if (!Array.isArray(response)) throw new Error("Workspace API returned an invalid workspace summary");
const summaries = response.map(workspaceSummary);
if (summaries.some((summary) => !summary)) {
throw new Error("Workspace API returned an invalid workspace summary");
}
return summaries as WorkspaceSummary[];
};
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");
const workspace = requireCanonicalWorkspace(source.workspace);
return {
workspace,
revision: requireWorkspaceRevision(source.revision, workspace.workspace.id),
};
};
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" });
function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration {
const source = exactObject(value, [
"workspaceId", "revision", "configurationState", "requirements",
]);
if (
!source
|| source.workspaceId !== expectedId
|| (source.configurationState !== "ready" && source.configurationState !== "configuration_required")
|| !Array.isArray(source.requirements)
) throw new Error("Workspace API returned an invalid runtime configuration");
const requirements = source.requirements.map((value) => {
const requirement = exactObject(value, [
"id", "connector", "label", "description", "input", "required", "configured",
]);
if (
!requirement
|| typeof requirement.id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,127}$/.test(requirement.id)
|| (requirement.connector !== "dwh" && requirement.connector !== "evidence")
|| typeof requirement.label !== "string" || requirement.label.length === 0
|| typeof requirement.description !== "string" || requirement.description.length === 0
|| (requirement.input !== "password" && requirement.input !== "textarea")
|| typeof requirement.required !== "boolean"
|| typeof requirement.configured !== "boolean"
) throw new Error("Workspace API returned an invalid runtime configuration");
return requirement as unknown as WorkspaceSecretRequirement;
});
return {
workspaceId: expectedId,
revision: requireWorkspaceRevision(source.revision, expectedId),
configurationState: source.configurationState,
requirements,
};
}
export const getWorkspaceRuntimeConfiguration = async (id: string) => runtimeConfiguration(
await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/runtime-configuration`),
id,
);
export const saveWorkspaceSecrets = async (id: string, values: Readonly<Record<string, string>>) => (
runtimeConfiguration(await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/secrets`, {
method: "PUT",
body: JSON.stringify({ values }),
}), id)
);
export const forgetWorkspaceSecret = async (id: string, requirementId: string) => (
runtimeConfiguration(await apiFetch<unknown>(
`/workspaces/${encodeURIComponent(id)}/secrets/${encodeURIComponent(requirementId)}`,
{ method: "DELETE" },
), id)
);