Files
CASAN/packages/casan-control-panel/frontend/src/lib/api.ts
T

328 lines
12 KiB
TypeScript

// Single axios client for the Ops Console API. Mirrors the OKR app's api.ts:
// relative baseURL, unwrap response.data.data.
import axios from 'axios';
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api/v1',
});
async function get<T>(path: string): Promise<T> {
const res = await client.get(`/${path}`);
return res.data.data as T;
}
async function getWithHeaders<T>(path: string, headers: Record<string, string>): Promise<T> {
const res = await client.get(`/${path}`, { headers });
return res.data.data as T;
}
async function post<T>(path: string, body: unknown, headers: Record<string, string>): Promise<T> {
const res = await client.post(`/${path}`, body, { headers });
return res.data.data as T;
}
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number }
export interface Overview extends Freshness {
totals: {
runs: number; total_cost: number; avg_latency_ms: number; failures: number;
hallucination_signals: number; provider_tokens: number; provider_cost: number;
fallback_routes: number; tool_denies: number; action_blocks: number;
};
harness_signals: Record<string, Record<string, number | string>>;
audit_chain: { records: number; head: string | null; last_decision: string | null };
}
export interface SettingsActor {
actor: string;
role: string;
project: string;
tenant: string;
}
export interface KillSwitchState {
count: number;
engaged: Array<{ scope: string; id: string; reason?: string; engaged_at?: string; actor?: string; raw?: string }>;
}
export interface ApprovalsState {
count: number;
proposals: any[];
oversight: any[];
audit_verify: { ok: boolean; output: string };
}
export interface CommandEnvelope {
source: string;
artifact_path: string;
commit: string | null;
run_at: string | null;
verified: boolean;
status: 'verified' | 'present_unverified' | 'missing';
}
export interface CommandWidget {
id: string;
title: string;
status: string;
summary: string;
metrics: Record<string, unknown>;
envelope: CommandEnvelope;
evidence: Record<string, unknown>;
}
export interface CommandCenterState extends Freshness {
generated_at: string;
widgets: Record<string, CommandWidget>;
briefing: Array<{ label: string; value: string; status: string; widget: string }>;
ticker: Array<{ at: string | null; kind: string; text: string; status: string }>;
}
export interface ChatSource {
path: string;
title?: string;
line?: number;
excerpt?: string;
score: number;
hash?: string;
preview?: string;
envelope?: CommandEnvelope;
}
export interface ChatAnswer {
success: boolean;
chat_id?: string;
turn_id?: string;
trace_id?: string;
mode: 'READ_ONLY' | 'OPERATOR' | 'BLOCK' | 'NOT_SUPPORTED' | string;
risk: string;
decision: 'ANSWERED' | 'ACTION_COMPLETED' | 'ACTION_FAILED' | 'REQUIRES_APPROVAL' | 'DENIED' | 'NOT_SUPPORTED' | string;
answer: string;
sources: ChatSource[];
certified: boolean;
audit: { hash?: string; record_hash?: string; head?: string; seq?: number; path?: string };
audit_verify: { ok: boolean; output: string };
router: {
reason?: string;
matched_rules?: string[];
gates?: string[];
side_effect_allowed?: boolean;
needs_approval?: boolean;
};
action?: { id: string; label: string; description: string } | null;
action_gate?: { outcome?: string; reason?: string; exit_code?: number };
agent_binding?: {
success: boolean;
decision: string;
reason: string;
agent_selected: string;
skill_selected: string;
delegation_level: number;
model_role?: string;
mode?: string;
tool_allowlist: string[];
requires_approval: boolean;
audit?: { record_hash?: string; head?: string; seq?: number };
};
loop_run?: {
success: boolean;
decision: string;
reason: string;
run_id: string;
draft_ref?: string;
draft_certified?: boolean;
side_effect_released?: boolean;
artifact?: string;
success_criteria?: string;
trace_verify?: { ok: boolean; output: string };
replay?: { ok: boolean; output: string };
};
codegen?: {
artifact?: string;
artifact_scan?: { ok: boolean; output: string };
tool_output_scan?: { ok: boolean; output: string };
};
synthesis?: {
mode: 'deterministic' | 'model' | string;
reason?: string;
provider?: string;
model?: string;
class?: string;
input_tokens?: number;
output_tokens?: number;
cost_source?: string;
fallback_from?: string | null;
};
actor: SettingsActor;
}
export type ChatStreamPhase = Partial<ChatAnswer> & {
phase?: 'draft' | 'final';
answer: string;
decision: string;
certified: boolean;
mode: string;
sources: ChatSource[];
};
export interface ChatAction {
id: string;
label: string;
description: string;
triggers: string[];
}
export interface ChatAgent {
id: string;
label: string;
mode: string;
model_role: string;
roles_allowed: string[];
skills_allowed: string[];
tool_allowlist: string[];
max_delegation_level: number;
requires_approval?: boolean;
allowed_for_role?: boolean;
}
export interface ChatReplay {
ok: boolean;
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
records: number;
loop_replayed?: number;
diffs?: any[];
audit_path?: string;
}
export interface ChatConversation {
chat_id: string;
title: string;
updated_at: string;
turns: number;
last_decision: string;
last_mode: string;
}
export interface ChatHistoryTurn {
chat_id: string;
turn_id: string;
timestamp: string;
mode: string;
risk: string;
decision: string;
prompt_preview: string;
answer_preview: string;
certified: boolean;
audit_hash: string;
}
export interface ChatHistory {
ok: boolean;
conversations: ChatConversation[];
turns: ChatHistoryTurn[];
}
export interface SettingsState {
actor: SettingsActor;
capabilities: {
can_write_standard: boolean;
can_write_sensitive: boolean;
can_rollback: boolean;
};
policy: Record<string, { securitySensitive: boolean; description: string }>;
settings: Record<string, { value: unknown; version: number; updatedAt: string; actor: string; reason: string }>;
audit: any[];
audit_verify: { ok: boolean; output: string };
}
function actorHeaders(actor: SettingsActor): Record<string, string> {
return {
'x-casan-actor': actor.actor,
'x-casan-role': actor.role,
'x-casan-project': actor.project,
'x-casan-tenant': actor.tenant,
};
}
export const api = {
overview: () => get<Overview>('overview'),
runs: (limit = 50) => get<Freshness & { count: number; runs: any[] }>(`runs?limit=${limit}`),
governance: () => get<Freshness & { records: number; by_decision: Record<string, number>; head: string | null; recent: any[] }>('governance'),
security: () => get<Freshness & { verdicts: number; by_status: Record<string, number>; benign_fp: any; recent: any[] }>('security'),
incidents: () => get<Freshness & { total: number; kill_switch_scopes: string[]; incidents: any[] }>('incidents'),
traceability: () => get<Freshness & { matrix: any }>('traceability'),
cost: () => get<Freshness & { provider_tokens: number; provider_cost: number; by_provider: any[]; business_kpi: any }>('cost'),
command: () => get<CommandCenterState>('command'),
settings: (actor: SettingsActor) => getWithHeaders<SettingsState>('settings', actorHeaders(actor)),
setSetting: (actor: SettingsActor, body: { key: string; value: unknown; reason: string; approval?: string }) =>
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings', body, actorHeaders(actor)),
rollbackSetting: (actor: SettingsActor, body: { key: string; reason: string }) =>
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings/rollback', body, actorHeaders(actor)),
killSwitch: (actor: SettingsActor) => getWithHeaders<KillSwitchState>('kill-switch', actorHeaders(actor)),
engageKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
post<{ output: string; status: KillSwitchState }>('kill-switch/engage', body, actorHeaders(actor)),
clearKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
post<{ output: string; status: KillSwitchState }>('kill-switch/clear', body, actorHeaders(actor)),
approvals: (actor: SettingsActor, status = 'pending') => getWithHeaders<ApprovalsState>(`approvals?status=${status}`, actorHeaders(actor)),
submitApproval: (actor: SettingsActor, body: { action: string; target: string; risk?: string; sensitive?: boolean; reason: string; payload?: Record<string, unknown> }) =>
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string; approvalJwt?: string }) =>
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
askChatStream: async (
actor: SettingsActor,
body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number },
onPhase: (phase: ChatStreamPhase) => void,
): Promise<void> => {
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
const resp = await fetch(`${base}/chat/ask/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...actorHeaders(actor) },
body: JSON.stringify(body),
});
if (!resp.ok || !resp.body) {
throw new Error(`stream failed: ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
const flush = (line: string) => {
const trimmed = line.trim();
if (!trimmed) return;
try {
onPhase(JSON.parse(trimmed) as ChatStreamPhase);
} catch {
/* ignore partial/non-JSON chunk */
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n')) >= 0) {
flush(buf.slice(0, idx));
buf = buf.slice(idx + 1);
}
}
flush(buf);
},
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
chatHistory: (actor: SettingsActor, chatId = '', limit = 50) =>
getWithHeaders<ChatHistory>(`chat/history?chatId=${encodeURIComponent(chatId)}&limit=${limit}`, actorHeaders(actor)),
replayChat: (chatId = '', turnId = '', tenant = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}&tenant=${encodeURIComponent(tenant)}`),
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
};
// Health is raw (not enveloped) + carries HTTP status.
export async function health(): Promise<{ ok: boolean; status: string; metrics_age_s: number | null; runs: number }> {
try {
const res = await axios.get('/healthz', { baseURL: '', validateStatus: () => true });
return { ok: res.status === 200, ...res.data };
} catch {
return { ok: false, status: 'unreachable', metrics_age_s: null, runs: 0 };
}
}