import { fetchWithAuth } from './config' import type { ChatEvent, MessageContent } from './types' export type { ChatEvent } // Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual // origin (relative vs. configured apiUrl) fresh on every call via // config.ts's apiBase(), so these can't be pre-resolved once at module load // (the config may not be known yet at import time, e.g. before Config.svelte // or a Wails-injected __OIKOS_CONFIG__ runs). const BASE = '/agent' const API = '/api/v1' // A session IS a task: a goal-structured unit of work with a lifecycle status // and an outcome. goal/outcome/summary/entity_id are empty until the agent sets // them (see the task-board plan). status defaults to 'active'. export interface Session { id: string title: string actor: string goal?: string status?: string // active | planning | executing | awaiting_input | done | failed | abandoned outcome?: string // success | failure | partial summary?: string entity_id?: string pending_approvals?: number created_at: string last_active_at: string } export interface Message { id: string session_id: string role: string content: MessageContent | string created_at: string } export async function fetchSessions(): Promise { const res = await fetchWithAuth(`${BASE}/sessions`) if (!res.ok) return [] const data = await res.json() return data.sessions ?? [] } export async function fetchMessages(sessionId: string): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`) if (!res.ok) return [] const data = await res.json() return data.messages ?? [] } // null distinguishes "session doesn't exist" (404 — the session was deleted, // or a persisted/deep-linked window id was never valid) from a transient // fetch failure, which should keep returning []/retrying rather than // permanently flip a window into a "not found" state. Only the initial // per-window load (chat.ts's loadSessionChat) needs this distinction — the // polling loops keep using fetchMessages, where swallowing a blip into [] // and trying again next tick is the right behavior. export async function fetchMessagesOrNotFound(sessionId: string): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`) if (res.status === 404) return null if (!res.ok) return [] const data = await res.json() return data.messages ?? [] } export async function deleteSession(sessionId: string): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' }) return res.ok } export async function resumeSession(sessionId: string): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/resume`, { method: 'POST' }) return res.ok } export interface PlanStep { id: string seq: number title: string detail: string status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked' | 'replaced' generation?: number execution_id?: string target_slug?: string started_at?: string finished_at?: string } export async function fetchPlan(sessionId: string): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/plan`) if (!res.ok) return [] const data = await res.json() return data.steps ?? [] } export interface SessionQuestion { id: string prompt: string context: { why?: string; options?: string[]; entities?: string[] } status: 'open' | 'answered' | 'dismissed' answer?: string created_at: string answered_at?: string } export async function fetchQuestions(sessionId: string): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions`) if (!res.ok) return [] const data = await res.json() return data.questions ?? [] } export async function answerQuestion( sessionId: string, questionId: string, answer: string ): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, { method: 'POST', body: JSON.stringify({ answer }) }) return res.ok } export function streamChat( message: string, sessionId: string | null, onEvent: (ev: ChatEvent) => void, onError: (err: string) => void, onDone: () => void ): AbortController { const controller = new AbortController() fetchWithAuth(`${BASE}/chat`, { method: 'POST', body: JSON.stringify({ message, session_id: sessionId ?? undefined }), signal: controller.signal }) .then(async (res) => { if (!res.ok) { onError(`HTTP ${res.status}`) return } const reader = res.body?.getReader() if (!reader) { onError('no response body') return } const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) { if (line.startsWith('data: ')) { try { const ev: ChatEvent = JSON.parse(line.slice(6)) onEvent(ev) } catch { // skip malformed } } } } }) .catch((err) => { onError(err.message) }) .finally(() => { onDone() }) return controller } export interface DashboardSummary { entities_by_type: Record entities_by_state: Record health: { healthy: number; degraded: number; down: number; unknown: number } signals_by_severity: Record approvals_pending: number executions_by_state: Record event_rate: { bucket: string; count: number }[] } export async function fetchDashboardSummary(): Promise { const res = await fetchWithAuth(`${API}/dashboard/summary`) if (!res.ok) return null return res.json() } export type EntityHealth = 'healthy' | 'degraded' | 'down' | 'unknown' | 'stale' export interface Entity { id: string slug: string type: string name: string state?: string | null attributes: Record version: number created_at: string updated_at: string health?: EntityHealth | null last_check_at?: string | null maintenance_until?: string | null } export interface EntityFilters { type?: string state?: string domain?: string layer?: string q?: string } export async function fetchEntities(filters: EntityFilters = {}): Promise { const params = new URLSearchParams() if (filters.type) params.set('type', filters.type) if (filters.state) params.set('state', filters.state) if (filters.domain) params.set('domain', filters.domain) if (filters.layer) params.set('layer', filters.layer) if (filters.q) params.set('q', filters.q) params.set('limit', '200') const res = await fetchWithAuth(`${API}/entities?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } // The entities endpoint caps at 200/page — the Knowledge Base wants the // whole set (it filters by type/search client-side now instead of scoping // the fetch server-side), so page through via cursor until exhausted. export async function fetchAllEntities(): Promise { const all: Entity[] = [] let cursor: string | undefined do { const params = new URLSearchParams({ limit: '200' }) if (cursor) params.set('cursor', cursor) const res = await fetchWithAuth(`${API}/entities?${params}`) if (!res.ok) break const data = await res.json() all.push(...(data.items ?? [])) cursor = data.next_cursor ?? undefined } while (cursor) return all } export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition' export interface EntityType { name: string parent_type?: string | null is_abstract: boolean domain: string layer: OntologyLayer description?: string | null } export type RelationshipCardinality = 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many' export interface RelationshipTypeDef { name: string inverse?: string | null source_type: string target_type: string cardinality: RelationshipCardinality description?: string | null } export interface Ontology { entityTypes: EntityType[] relationshipTypes: RelationshipTypeDef[] } export async function fetchOntology(): Promise { const res = await fetchWithAuth(`${API}/ontology`) if (!res.ok) return { entityTypes: [], relationshipTypes: [] } const data = await res.json() return { entityTypes: data.entity_types ?? [], relationshipTypes: data.relationship_types ?? [] } } // The graph endpoint has no layer param, so callers build a type→layer map from // this to scope the graph client-side (the entities table filters server-side). export async function fetchEntityTypes(): Promise { return (await fetchOntology()).entityTypes } export interface EventFilters { type?: string severity?: string } export async function fetchEvents( filters: EventFilters = {} ): Promise { const params = new URLSearchParams() if (filters.type) params.set('type', filters.type) if (filters.severity) params.set('severity', filters.severity) params.set('limit', '100') const res = await fetchWithAuth(`${API}/events?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface Approval { id: string slug: string subject?: string | null action: string risk_class: string kind: 'execution' | 'policy-change' | 'pattern-activation' payload?: Record | null status: 'pending' | 'approved' | 'denied' | 'expired' | 'revoked' expires_at: string decided_at?: string | null decided_by?: string | null created_at: string } export async function fetchApprovals(status?: string): Promise { const params = new URLSearchParams() if (status) params.set('status', status) params.set('limit', '200') const res = await fetchWithAuth(`${API}/approvals?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export async function decideApproval( id: string, decision: 'approve' | 'deny' | 'revoke', note?: string ): Promise { const res = await fetchWithAuth(`${API}/approvals/${id}/decision`, { method: 'POST', body: JSON.stringify({ decision, note }) }) if (!res.ok) return null return res.json() } export interface Execution { id: string slug: string target?: string | null action: string risk_class: string status: string approval_id?: string | null agent_id?: string | null result?: Record | null duration_ms?: number | null verified: boolean correlation_id: string started_at?: string | null completed_at?: string | null created_at: string } export async function fetchExecutions(status?: string): Promise { const params = new URLSearchParams() if (status) params.set('status', status) params.set('limit', '200') const res = await fetchWithAuth(`${API}/executions?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export async function getExecution(id: string): Promise { const res = await fetchWithAuth(`${API}/executions/${id}`) if (!res.ok) return null return res.json() } export async function cancelExecution(id: string): Promise { const res = await fetchWithAuth(`${API}/executions/${id}/cancel`, { method: 'POST' }) if (!res.ok) return null return res.json() } export interface ActivityItem { id: string target: string verb: string summary: string risk_class: string status: string duration_ms: number | null error?: string created_at: string completed_at: string | null } export async function fetchRecentActivity(limit = 50): Promise { const res = await fetchWithAuth(`${API}/activity/recent?limit=${limit}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface CapabilityTimelineItem { verb: string first_success: string | null successes: number total: number } export async function fetchLearningTimeline(): Promise { const res = await fetchWithAuth(`${API}/learning/timeline`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface TrendBucket { day: string successes: number failures: number } export async function fetchLearningTrend(): Promise { const res = await fetchWithAuth(`${API}/learning/trend`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface Pattern { id: string slug: string applies_type: string action: string pattern: string confidence: number evidence_count: number success_count?: number failure_count?: number status: string quarantined?: boolean } export async function fetchPatterns(): Promise { const res = await fetchWithAuth(`${API}/patterns`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface Skill { id: string slug: string name: string applies_type?: string | null action: string status: string success_rate?: number | null last_used_at?: string | null } export async function fetchSkills(): Promise { const res = await fetchWithAuth(`${API}/skills`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface Signal { id: string slug: string kind: string severity: 'info' | 'warning' | 'critical' state: 'raised' | 'acknowledged' | 'acting' | 'muted' | 'resolved' | 'failed' target?: string | null evidence?: string | null likely_cause?: string | null occurrence_count: number flap_count: number hold_down_until?: string | null mute_until?: string | null first_seen_at: string last_seen_at: string } export async function fetchSignals( filters: { state?: string; severity?: string } = {} ): Promise { const params = new URLSearchParams() if (filters.state) params.set('state', filters.state) if (filters.severity) params.set('severity', filters.severity) params.set('limit', '200') const res = await fetchWithAuth(`${API}/signals?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export async function ackSignal(id: string): Promise { const res = await fetchWithAuth(`${API}/signals/${id}/ack`, { method: 'POST' }) if (!res.ok) return null return res.json() } export async function resolveSignal(id: string, note?: string): Promise { const res = await fetchWithAuth(`${API}/signals/${id}/resolve`, { method: 'POST', body: JSON.stringify({ note }) }) if (!res.ok) return null return res.json() } export async function muteSignal( id: string, muteUntil: string, note?: string ): Promise { const res = await fetchWithAuth(`${API}/signals/${id}/mute`, { method: 'POST', body: JSON.stringify({ mute_until: muteUntil, note }) }) if (!res.ok) return null return res.json() } export interface Relationship { source: string target: string type: string attributes?: Record | null valid_from: string valid_to?: string | null } // Direct relationships of an entity, both directions — unlike fetchGraph's // blast_radius (which only walks outgoing edges, so it can never surface an // edge some other entity points at this one unless that entity is also // reachable going forward from here), this hits a dedicated endpoint that // matches on source_id OR target_id directly. export async function fetchEntityRelations(id: string): Promise { const res = await fetchWithAuth( `${API}/entities/${encodeURIComponent(id)}/relations?direction=both` ) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export type Health = 'healthy' | 'degraded' | 'down' | 'unknown' export interface GraphView { nodes: Entity[] edges: Relationship[] truncated?: boolean health?: Record } export interface GraphFilters { root?: string depth?: number relType?: string[] includeStatus?: boolean } export async function fetchGraph(filters: GraphFilters = {}): Promise { const params = new URLSearchParams() if (filters.root) params.set('root', filters.root) if (filters.depth) params.set('depth', String(filters.depth)) for (const rt of filters.relType ?? []) params.append('rel_type', rt) if (filters.includeStatus) params.append('include', 'status') const res = await fetchWithAuth(`${API}/graph?${params}`) if (!res.ok) return null return res.json() } export interface BlastRadiusItem { entity: Entity depth: number } export async function fetchBlastRadius(id: string): Promise { const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } // id is commonly a slug, not a UUID (e.g. "document:infrastructure/network") — // slugs can contain '/', which must be percent-encoded or it splits the path // into extra segments the router won't match. export async function fetchEntity(id: string): Promise { const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}`) if (!res.ok) return null return res.json() } export interface MetricSample { ts: string value?: number | null avg?: number | null min?: number | null max?: number | null } export interface MetricSeries { entity_id: string metric: string rollup: 'raw' | '1h' | '1d' samples: MetricSample[] } export async function fetchMetrics(entityId: string): Promise { const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' }) const res = await fetchWithAuth(`${API}/metrics?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface KnowledgeHit { id: string slug: string type: 'document' | 'runbook' | 'investigation' title: string snippet?: string linked_entities?: string[] } export interface KnowledgeItem { slug: string title: string kind: 'document' | 'runbook' | 'investigation' source: string tags: string[] updated_at: string agent_authored: boolean } export interface RecentKnowledge { stats: { total: number by_kind: Record agent_authored: number last_7d: number } items: KnowledgeItem[] } export async function fetchRecentKnowledge(source?: string): Promise { const params = new URLSearchParams() if (source) params.set('source', source) const res = await fetchWithAuth(`${API}/knowledge/recent?${params}`) if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] } return res.json() } export async function fetchEntityKnowledge(entityId: string): Promise { const res = await fetchWithAuth(`${API}/knowledge/${entityId}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface KnowledgeContent { title: string content: string source: string tags: string[] updated_at: string } // Full markdown body for a document/investigation/runbook entity — distinct // from fetchEntityKnowledge, which returns knowledge that references OTHER // entities, not this entity's own content. export async function fetchKnowledgeContent(id: string): Promise { const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(id)}`) if (!res.ok) return null return res.json() } export async function fetchEntityEvents( entityId: string ): Promise { const params = new URLSearchParams({ entity_id: entityId, limit: '50' }) const res = await fetchWithAuth(`${API}/events?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export async function fetchEntitySignals(entityId: string): Promise { const params = new URLSearchParams({ entity_id: entityId, limit: '50' }) const res = await fetchWithAuth(`${API}/signals?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface ExecutionLogChunk { seq: number stream: string chunk: string ts: string } // Streamed command output. Chunks land in execution_logs as the command runs, // so this returns output for an execution that is still going — unlike // `result.output`, which is only written once at the terminal state. export async function fetchExecutionLogs( executionId: string ): Promise<{ items: ExecutionLogChunk[]; combined: string }> { const res = await fetchWithAuth(`${API}/executions/${executionId}/logs?limit=2000`) if (!res.ok) return { items: [], combined: '' } const data = await res.json() return { items: data.items ?? [], combined: data.combined ?? '' } } export async function fetchEntityExecutions(entityId: string): Promise { const params = new URLSearchParams({ target: entityId, limit: '50' }) const res = await fetchWithAuth(`${API}/executions?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface EntityTask { task: Entity executionCount: number } // Executions aren't directly browsable from an entity in a useful way — what // matters is which task/session acted on it, and how many times. There's no // "incoming relationships" endpoint (blast_radius/graph only walks outgoing // edges), so this composes it client-side: find executions targeting this // entity, then check each task's own outgoing edges (task --involves--> entity // directly, or task --involves--> execution) to attribute them. Cheap while // the task count is small; would want a dedicated query if that changes. export async function fetchEntityTasks(entity: Entity): Promise { const [executions, tasks] = await Promise.all([ fetchEntityExecutions(entity.id), fetchEntities({ type: 'task' }) ]) const executionIds = new Set(executions.map((e) => e.id)) const results = await Promise.all( tasks.map(async (task): Promise => { const g = await fetchGraph({ root: task.slug, depth: 1 }) if (!g) return null const involvesThisEntity = g.edges.some( (e) => e.type === 'involves' && e.target === entity.slug ) const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type])) const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id])) const executionCount = g.edges.filter((e) => { if (e.type !== 'involves') return false const targetId = idBySlug.get(e.target) return ( targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId) ) }).length if (!involvesThisEntity && executionCount === 0) return null return { task, executionCount } }) ) return results.filter((r): r is EntityTask => r !== null) } export interface Check { id: string slug: string kind: 'http' | 'tcp' | 'disk' | 'cert-expiry' | 'drift' | 'ping' | 'ssh-script' target?: string | null target_type?: string | null config?: Record interval_s: number timeout_s: number zone?: string | null enabled: boolean version: number } export async function fetchChecksForTarget(targetSlug: string): Promise { const params = new URLSearchParams({ target: targetSlug, limit: '50' }) const res = await fetchWithAuth(`${API}/checks?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export async function patchCheck( id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number } ): Promise { const res = await fetchWithAuth(`${API}/checks/${id}`, { method: 'PATCH', headers: { 'If-Match': `"${version}"` }, body: JSON.stringify(patch) }) if (!res.ok) return null return res.json() } export interface AgentActivity { id: number ts: string agent_id: string session_id?: string | null activity_type: 'tool_call' | 'reasoning' | 'decision' | 'mcp_query' | 'escalation' tool_name?: string | null entity_id?: string | null input_summary?: string | null output_summary?: string | null duration_ms?: number | null token_count?: number | null success?: boolean | null correlation_id?: string | null } export async function fetchAgentActivity( filters: { agent_id?: string activity_type?: string entity_id?: string limit?: number } = {} ): Promise { const params = new URLSearchParams() if (filters.agent_id) params.set('agent_id', filters.agent_id) if (filters.activity_type) params.set('activity_type', filters.activity_type) if (filters.entity_id) params.set('entity_id', filters.entity_id) params.set('limit', String(filters.limit ?? 200)) const res = await fetchWithAuth(`${API}/agent-activity?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export async function searchKnowledge(q: string, limit = 50): Promise { const params = new URLSearchParams({ q, limit: String(limit) }) const res = await fetchWithAuth(`${API}/knowledge/search?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } export interface AuditEntry { id: number ts: string actor_type: 'agent' | 'operator' | 'system' | 'scheduler' actor_id?: string | null action: string entity_id?: string | null method?: string | null path?: string | null status_code?: number | null detail?: Record source_ip?: string | null correlation_id?: string | null } export async function fetchAudit( filters: { actor_type?: string actor_id?: string entity_id?: string action?: string correlation_id?: string limit?: number } = {} ): Promise { const params = new URLSearchParams() if (filters.actor_type) params.set('actor_type', filters.actor_type) if (filters.actor_id) params.set('actor_id', filters.actor_id) if (filters.entity_id) params.set('entity_id', filters.entity_id) if (filters.action) params.set('action', filters.action) if (filters.correlation_id) params.set('correlation_id', filters.correlation_id) params.set('limit', String(filters.limit ?? 200)) const res = await fetchWithAuth(`${API}/audit?${params}`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] }