351 lines
12 KiB
TypeScript
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)
|
|
);
|