feat: global activity feed + session digest
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Ops "Executions" tab showed raw target UUIDs, alphabetical (not
recency) order, and a stale status vocabulary from an earlier schema
iteration — never actually usable as a live "what's happening" view.
Replaced with a new recency-ordered /api/v1/activity/recent endpoint
and matching table (human-readable action summaries, risk/status
badges, duration, inline error preview).

Also added /api/v1/activity/session/{id} + a collapsible SessionDigest
panel in the chat rail, answering "what did this session actually do"
(executions by status, entities touched, knowledge written) — the
missing piece for proactive outcome reporting to be visible in the UI,
not just in the chat transcript.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:09:09 +02:00
parent 40999b0b40
commit ac48390796
6 changed files with 427 additions and 30 deletions

View File

@@ -242,6 +242,41 @@ export async function cancelExecution(id: string): Promise<Execution | 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 Signal {
id: string
slug: string

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
import { currentSession, streaming } from '$lib/stores/chat'
import { Badge } from '$lib/components/ui/badge'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
let digest = $state<SessionDigest | null>(null)
let open = $state(false)
let loadedFor = $state<string | null>(null)
// Reload the digest whenever the session changes or a stream finishes —
// "what did this session actually do" is only meaningful once executions
// have had a chance to land.
$effect(() => {
const sid = $currentSession
const busy = $streaming
if (!sid || busy) return
if (loadedFor === sid) return
loadedFor = sid
fetchSessionDigest(sid).then((d) => (digest = d))
})
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
if (status === 'completed') return 'default'
if (['running', 'approved'].includes(status)) return 'secondary'
return 'outline'
}
</script>
{#if digest && digest.total_executions > 0}
<div class="border-b">
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
onclick={() => (open = !open)}
>
<span class="flex items-center gap-1.5">
{#if open}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
This session
</span>
<span class="flex items-center gap-1.5 text-muted-foreground">
{digest.total_executions} action{digest.total_executions === 1 ? '' : 's'}
{#if digest.knowledge_created.length}
<span class="flex items-center gap-0.5 text-primary">
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
</span>
{/if}
</span>
</button>
{#if open}
<div class="flex flex-col gap-3 px-3 pb-3 text-xs">
<div class="flex flex-wrap gap-1">
{#each Object.entries(digest.by_status) as [status, count]}
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
{/each}
</div>
{#if digest.entities_touched.length}
<div>
<div class="mb-1 text-muted-foreground">Entities touched</div>
<div class="flex flex-wrap gap-1">
{#each digest.entities_touched as target}
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{target}</span>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-1">
{#each digest.executions as ex}
<div class="flex items-start justify-between gap-2 rounded border px-2 py-1">
<div class="min-w-0">
<div class="font-mono text-[11px] text-muted-foreground">{ex.target}</div>
<div class="truncate">{ex.summary || ex.verb}</div>
</div>
<Badge variant={statusVariant(ex.status)} class="shrink-0">{ex.status}</Badge>
</div>
{/each}
</div>
{#if digest.knowledge_created.length}
<div>
<div class="mb-1 flex items-center gap-1 text-primary">
<SparklesIcon class="size-3" />Learned this session
</div>
<ul class="list-inside list-disc">
{#each digest.knowledge_created as title}
<li>{title}</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -2,6 +2,7 @@
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
import SessionRail from '$lib/components/SessionRail.svelte'
import SessionGraph from '$lib/components/SessionGraph.svelte'
import SessionDigest from '$lib/components/SessionDigest.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import InlineApproval from '$lib/components/InlineApproval.svelte'
import { Button } from '$lib/components/ui/button'
@@ -188,8 +189,11 @@
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="min-w-0 flex-1">
<SessionGraph />
<div class="flex min-w-0 flex-1 flex-col">
<SessionDigest />
<div class="min-h-0 flex-1">
<SessionGraph />
</div>
</div>
</div>
{/if}

View File

@@ -3,10 +3,10 @@
import {
fetchApprovals,
decideApproval,
fetchExecutions,
fetchRecentActivity,
cancelExecution,
type Approval,
type Execution
type ActivityItem
} from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import * as Tabs from '$lib/components/ui/tabs'
@@ -16,30 +16,55 @@
import { toast } from 'svelte-sonner'
let approvals = $state<Approval[]>([])
let executions = $state<Execution[]>([])
let activity = $state<ActivityItem[]>([])
let deciding = $state<string | null>(null)
async function loadApprovals() {
approvals = await fetchApprovals()
}
async function loadExecutions() {
executions = await fetchExecutions()
async function loadActivity() {
activity = await fetchRecentActivity()
}
onMount(() => {
loadApprovals()
loadExecutions()
loadActivity()
const unsubscribe = subscribeEvents()
return unsubscribe
// The activity feed has no dedicated SSE event type yet — a light poll
// keeps it live without waiting for that wiring. Cheap: one query, only
// while this page is open.
const interval = setInterval(loadActivity, 5000)
return () => {
unsubscribe()
clearInterval(interval)
}
})
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('approval.')) loadApprovals()
if (ev.type.startsWith('execution.')) loadExecutions()
if (ev.type.startsWith('execution.')) loadActivity()
})
function fmtDuration(ms: number | null): string {
if (ms == null) return '—'
if (ms < 1000) return `${ms}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
function fmtWhen(iso: string): string {
const d = new Date(iso).getTime()
if (!d) return ''
const s = Math.round((Date.now() - d) / 1000)
if (s < 60) return 'just now'
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
async function decide(id: string, decision: 'approve' | 'deny') {
deciding = id
const result = await decideApproval(id, decision)
@@ -56,22 +81,26 @@
const result = await cancelExecution(id)
if (result) {
toast.success('Execution cancelled')
loadExecutions()
loadActivity()
} else {
toast.error('Cancel failed')
}
}
function riskVariant(risk: string): 'default' | 'secondary' | 'destructive' {
if (risk === 'high' || risk === 'critical') return 'destructive'
if (risk === 'medium') return 'secondary'
if (risk === 'destructive') return 'destructive'
if (risk === 'config_mutation') return 'secondary'
return 'default'
}
// Real status vocabulary (internal/httpapi/phase3.go, cmd/nomos): the
// previous version checked statuses ('proposed', 'auto_approved',
// 'verified', 'executing'...) that don't exist anywhere in the actual
// schema — this table was never actually color-coding correctly.
function execStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (['failed', 'timed_out', 'rollback_failed', 'denied'].includes(status)) return 'destructive'
if (['verified', 'auto_approved'].includes(status)) return 'default'
if (['executing', 'verifying'].includes(status)) return 'secondary'
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
if (status === 'completed') return 'default'
if (['running', 'approved'].includes(status)) return 'secondary'
return 'outline'
}
@@ -87,7 +116,7 @@
<Tabs.Trigger value="approvals">
Approvals {#if pendingApprovals.length}<Badge variant="destructive" class="ml-1">{pendingApprovals.length}</Badge>{/if}
</Tabs.Trigger>
<Tabs.Trigger value="executions">Executions</Tabs.Trigger>
<Tabs.Trigger value="executions">Activity</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
@@ -168,31 +197,39 @@
<Table.Row>
<Table.Head>Target</Table.Head>
<Table.Head>Action</Table.Head>
<Table.Head>Risk</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Correlation</Table.Head>
<Table.Head>Started</Table.Head>
<Table.Head>Duration</Table.Head>
<Table.Head>When</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each executions as execution (execution.id)}
{#each activity as item (item.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{execution.target ?? '—'}</Table.Cell>
<Table.Cell>{execution.action}</Table.Cell>
<Table.Cell><Badge variant={execStatusVariant(execution.status)}>{execution.status}</Badge></Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground">{execution.correlation_id}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{execution.started_at ? new Date(execution.started_at).toLocaleString() : '—'}</Table.Cell
>
<Table.Cell class="font-mono text-xs">{item.target ?? '—'}</Table.Cell>
<Table.Cell>
<div>{item.verb}</div>
{#if item.summary}
<div class="text-xs text-muted-foreground">{item.summary}</div>
{/if}
{#if item.error}
<div class="text-xs text-destructive">{item.error}</div>
{/if}
</Table.Cell>
<Table.Cell><Badge variant={riskVariant(item.risk_class)}>{item.risk_class}</Badge></Table.Cell>
<Table.Cell><Badge variant={execStatusVariant(item.status)}>{item.status}</Badge></Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{fmtDuration(item.duration_ms)}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{fmtWhen(item.created_at)}</Table.Cell>
<Table.Cell class="text-right">
{#if ['proposed', 'approved', 'auto_approved', 'executing'].includes(execution.status)}
<Button size="sm" variant="outline" onclick={() => cancel(execution.id)}>Cancel</Button>
{#if ['pending_approval', 'approved', 'running'].includes(item.status)}
<Button size="sm" variant="outline" onclick={() => cancel(item.id)}>Cancel</Button>
{/if}
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={6} class="text-center text-muted-foreground">No executions yet.</Table.Cell>
<Table.Cell colspan={7} class="text-center text-muted-foreground">No activity yet.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>