Monitoring coverage was 3 of 89 active entities. Three bugs, each hidden by discarded errors in checkdefaults: - writeCheck generated a fresh uuid, inserted the check entity ON CONFLICT (slug) DO NOTHING, then wrote a check_defs row referencing it. On any re-seed the slug already existed, the entity insert no-oped, and the FK violated — aborting the ingest transaction and surfacing as an unrelated failure several entities later. Re-seeding has been broken since; prod's coverage was frozen at its first successful seed. This is what TestSeedIngestIdempotentAndNoDuplicateEdges had been reporting. - shortSlug truncated to the last 8 chars, so all 21 ingress routes collapsed to ".network" and overwrote each other; service:jellyfin collided with lxc:jellyfin. - The ssh-script checker never read the `args` config checkdefaults wrote, so process_check.sh always ran without its unit name and returned "unknown". Coverage is now 75/89. Monitoring is declared per entity type in seeds/ontology.yaml and resolved through the is-a hierarchy, so a type can say it warrants nothing (site, lan, mesh, cluster) and never be reported as a gap. coverageSweep raises an `unmonitored` signal only where a type declares monitoring it lacks — 8 real gaps, no false positives. Also: - entity_types.attribute_schema was never ingested: the seed loader read "attribute_schema" but the YAML says "attributes", so all 60 types stored JSON null. - ListExecutions ignored its declared target/action/correlation_id filters and paginated on a non-unique target slug, dropping and repeating rows. - started_at was captured but only written at terminal state, so a running execution reported NULL for its whole life. The three MCP auto-run copies wrote no timing at all; they are now one autoRun helper. - SSH output was buffered to completion and discarded entirely on timeout. Both sshExec copies now stream through a shared execlog sink into execution_logs, and keep partial output when a command is cancelled. - executions.correlation_id was a random per-execution uuid that correlated nothing; it is now the chat session id, which is what lets the chat tail live output. - reversible_low had no auto-run branch despite policy declaring it unattended. Since computeCommandRisk never returns it, the class only arises when an agent declares it over a read_only command — so gating it penalised candor without adding safety. - backup-target gains a backup-freshness checker (portable find -mmin, since the first target is on macOS), resolving its host by walking backs-up-to backwards. The pre-deploy pg_dump is now a tracked backup target. UI: an Executions section on entity detail with live output tailing, and streamed output under a running `run` call in the chat timeline. Migrations 022-024. Ops.svelte and context.ts exclude execution.output from their refetch triggers, which would otherwise fire once a second per command. Co-Authored-By: Claude <noreply@anthropic.com>
896 lines
26 KiB
TypeScript
896 lines
26 KiB
TypeScript
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<Session[]> {
|
|
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<Message[]> {
|
|
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<Message[] | null> {
|
|
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<boolean> {
|
|
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
|
return res.ok
|
|
}
|
|
|
|
export async function resumeSession(sessionId: string): Promise<boolean> {
|
|
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<PlanStep[]> {
|
|
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<SessionQuestion[]> {
|
|
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<boolean> {
|
|
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<string, number>
|
|
entities_by_state: Record<string, number>
|
|
health: { healthy: number; degraded: number; down: number; unknown: number }
|
|
signals_by_severity: Record<string, number>
|
|
approvals_pending: number
|
|
executions_by_state: Record<string, number>
|
|
event_rate: { bucket: string; count: number }[]
|
|
}
|
|
|
|
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
|
|
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<string, unknown>
|
|
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<Entity[]> {
|
|
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<Entity[]> {
|
|
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<Ontology> {
|
|
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<EntityType[]> {
|
|
return (await fetchOntology()).entityTypes
|
|
}
|
|
|
|
export interface EventFilters {
|
|
type?: string
|
|
severity?: string
|
|
}
|
|
|
|
export async function fetchEvents(
|
|
filters: EventFilters = {}
|
|
): Promise<import('./stores/events').OikosEvent[]> {
|
|
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<string, unknown> | 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<Approval[]> {
|
|
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<Approval | null> {
|
|
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<string, unknown> | 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<Execution[]> {
|
|
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<Execution | null> {
|
|
const res = await fetchWithAuth(`${API}/executions/${id}`)
|
|
if (!res.ok) return null
|
|
return res.json()
|
|
}
|
|
|
|
export async function cancelExecution(id: string): Promise<Execution | null> {
|
|
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<ActivityItem[]> {
|
|
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<CapabilityTimelineItem[]> {
|
|
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<TrendBucket[]> {
|
|
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<Pattern[]> {
|
|
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<Skill[]> {
|
|
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<Signal[]> {
|
|
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<Signal | null> {
|
|
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<Signal | null> {
|
|
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<Signal | null> {
|
|
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<string, unknown> | 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<Relationship[]> {
|
|
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<string, Health>
|
|
}
|
|
|
|
export interface GraphFilters {
|
|
root?: string
|
|
depth?: number
|
|
relType?: string[]
|
|
includeStatus?: boolean
|
|
}
|
|
|
|
export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView | null> {
|
|
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<BlastRadiusItem[]> {
|
|
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<Entity | null> {
|
|
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<MetricSeries[]> {
|
|
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<string, number>
|
|
agent_authored: number
|
|
last_7d: number
|
|
}
|
|
items: KnowledgeItem[]
|
|
}
|
|
|
|
export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> {
|
|
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<KnowledgeHit[]> {
|
|
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<KnowledgeContent | null> {
|
|
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(id)}`)
|
|
if (!res.ok) return null
|
|
return res.json()
|
|
}
|
|
|
|
export async function fetchEntityEvents(
|
|
entityId: string
|
|
): Promise<import('./stores/events').OikosEvent[]> {
|
|
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<Signal[]> {
|
|
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<Execution[]> {
|
|
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<EntityTask[]> {
|
|
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<EntityTask | null> => {
|
|
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<string, unknown>
|
|
interval_s: number
|
|
timeout_s: number
|
|
zone?: string | null
|
|
enabled: boolean
|
|
version: number
|
|
}
|
|
|
|
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
|
|
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<Check | null> {
|
|
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<AgentActivity[]> {
|
|
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<KnowledgeHit[]> {
|
|
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<string, unknown>
|
|
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<AuditEntry[]> {
|
|
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 ?? []
|
|
}
|