Files
oikos/web/src/lib/api.ts
dtoro 991e7d0900 feat(tasks): phase 6 — live TaskContextPanel (goal, plan, question, entities)
Replaces the chat right rail's ad-hoc Digest+Graph stack with a single
TaskContextPanel that renders the task's live working state, driven by the
always-on events stream (not the per-turn chat SSE) so it keeps updating
during server-side auto-continuation/resume:

- GoalHeader: goal + status pill (planning/executing/awaiting_input/done/
  failed), sourced from the sessions list.
- PlanProgress: ordered steps with live status icons + progress bar, hydrated
  via new GET /sessions/{id}/plan; clicking a step with a target opens its
  EntitySheet (no fake "jump to transcript" — bits-ui Collapsible content
  isn't force-mounted, so a DOM-scroll jump would silently no-op for
  collapsed tool groups).
- OperatorQuestion: the pinned structured question card (prompt/why/entity
  chips/option buttons/free-text), hydrated via new GET /sessions/{id}/
  questions; answering POSTs to the existing answer endpoint.
- SessionGraph upgraded to a live entity panel: entity.touched pulses the
  node (animated ring) and shows "Now touching <slug>"; health.changed shows
  a transient diff badge for touched entities.
- SessionDigest gains a success/failure/partial outcome banner and now also
  refetches when the task's status changes, not just on session switch.

Two bugs found and fixed while wiring this up:
- workspace.ts's status-refresh trigger only covered goal.set/task.status;
  question.raised/answered didn't refresh the sessions list, so GoalHeader's
  pill went stale after answering via the panel (resumeSession runs entirely
  server-side — no client 'done' event to piggyback a refresh on). Now every
  status-affecting event triggers the (debounced) refetch.
- Forgot to rebuild the nomos container after adding the /plan and
  /questions endpoints, so they silently fell through to the old default GET
  handler — caught via a live curl diff against the running container,
  not a code read.

Verified end-to-end against the live stack: goal/plan/question all update
without a reload as the agent works; answering a question via the panel
resumes the agent and the header pill correctly flips to Executing;
entity.touched pulses the live graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 +02:00

706 lines
20 KiB
TypeScript

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
created_at: string
last_active_at: string
}
export interface Message {
id: string
session_id: string
role: string
content: any
created_at: string
}
export async function fetchSessions(): Promise<Session[]> {
const res = await fetch(`${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 fetch(`${BASE}/sessions/${sessionId}`)
if (!res.ok) return []
const data = await res.json()
return data.messages ?? []
}
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
}
export interface PlanStep {
id: string
seq: number
title: string
detail: string
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked'
execution_id?: string
target_slug?: string
started_at?: string
finished_at?: string
}
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
const res = await fetch(`${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 fetch(`${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 fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer })
})
return res.ok
}
export interface ChatEvent {
type: string
data: any
session_id?: string
iteration?: number
}
export function streamChat(
message: string,
sessionId: string | null,
onEvent: (ev: ChatEvent) => void,
onError: (err: string) => void,
onDone: () => void
): AbortController {
const controller = new AbortController()
fetch(`${BASE}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 fetch(`${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
}
export interface EntityFilters {
type?: string
state?: 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.q) params.set('q', filters.q)
params.set('limit', '200')
const res = await fetch(`${API}/entities?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
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 fetch(`${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 fetch(`${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 fetch(`${API}/approvals/${id}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 fetch(`${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 fetch(`${API}/executions/${id}`)
if (!res.ok) return null
return res.json()
}
export async function cancelExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${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 fetch(`${API}/activity/recent?limit=${limit}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface SessionDigest {
session_id: string
total_executions: number
by_status: Record<string, number>
entities_touched: string[]
executions: { target: string; verb: string; summary: string; risk_class: string; status: string }[]
knowledge_created: string[]
}
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
const res = await fetch(`${API}/activity/session/${sessionId}`)
if (!res.ok) return null
return res.json()
}
export interface CapabilityTimelineItem {
verb: string
first_success: string | null
successes: number
total: number
}
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
const res = await fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${API}/signals/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 fetch(`${API}/signals/${id}/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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
}
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 fetch(`${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 fetch(`${API}/entities/${id}/blast-radius`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntity(id: string): Promise<Entity | null> {
const res = await fetch(`${API}/entities/${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 fetch(`${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 fetch(`${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 fetch(`${API}/knowledge/${entityId}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
const res = await fetch(`${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 fetch(`${API}/signals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
const params = new URLSearchParams({ target: entityId, limit: '50' })
const res = await fetch(`${API}/executions?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
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 fetch(`${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 fetch(`${API}/checks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', '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 fetch(`${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 fetch(`${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 fetch(`${API}/audit?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}