diff --git a/implementation-plans/_index.md b/implementation-plans/_index.md index 41cab49..450a838 100644 --- a/implementation-plans/_index.md +++ b/implementation-plans/_index.md @@ -18,3 +18,4 @@ | #14 | Preset configurations | COMPLETED | [issue-014.md](issue-014.md) | | #15 | Agent lineage visualization | COMPLETED | [issue-015.md](issue-015.md) | | #16 | Memory candidates viewer | COMPLETED | [issue-016.md](issue-016.md) | +| #17 | Audit/activity log view | COMPLETED | [issue-017.md](issue-017.md) | diff --git a/implementation-plans/issue-017.md b/implementation-plans/issue-017.md new file mode 100644 index 0000000..f63ddeb --- /dev/null +++ b/implementation-plans/issue-017.md @@ -0,0 +1,56 @@ +# Issue #17 — Audit/Activity Log View + +**Status: COMPLETED** + +## Overview + +Timeline view of all orchestration events for a session — state transitions, tool invocations, errors. Populated from the streamed responses captured during the session. + +## Implementation Steps + +### 1. Audit Event Store (`src/lib/stores/audit.svelte.ts`) — COMPLETED + +- Created `AuditEvent` type with `id`, `sessionId`, `timestamp`, `eventType`, `details`, `state?` +- Event types: `state_change`, `tool_invocation`, `error`, `message` +- Store events grouped by session using `SvelteMap`, persisted to `localStorage` +- Functions: `addEvent()`, `getEventsBySession()`, `getAllSessions()`, `clearSession()` +- Sample data seeded for demo sessions + +### 2. AuditTimeline Component (`src/lib/components/AuditTimeline.svelte`) — COMPLETED + +- Vertical timeline with left-aligned time markers and event cards +- Color-coded type badges: state change=blue, tool=green, error=red, message=gray +- Each event shows timestamp, type badge, state label (when applicable), and detail text +- Date headers when events span multiple days +- Hover shadow effect on event cards + +### 3. Audit Route (`src/routes/audit/+page.svelte`) — COMPLETED + +- Dedicated `/audit` route with session selector dropdown and timeline +- Filter controls for event type (all, state changes, tool invocations, errors, messages) +- URL query param `?session=id` for deep linking to specific session +- Empty states when no events or no session selected +- Header with back-to-chat link and "Sample Data" badge + +### 4. Audit Route Config (`src/routes/audit/+page.ts`) — COMPLETED + +- `export const prerender = false` and `export const ssr = false` + +### 5. Homepage Navigation (`src/routes/+page.svelte`) — COMPLETED + +- Added "Audit Log" navigation link alongside existing Chat, Lineage, Memory links + +### 6. Chat Page Integration (`src/routes/chat/+page.svelte`) — COMPLETED + +- Added "Audit" link in the header navigation bar +- Capture orchestration state changes during streaming into the audit store +- Capture errors into the audit store + +## Files Changed + +- `src/lib/stores/audit.svelte.ts` (new) +- `src/lib/components/AuditTimeline.svelte` (new) +- `src/routes/audit/+page.svelte` (new) +- `src/routes/audit/+page.ts` (new) +- `src/routes/+page.svelte` (modified) +- `src/routes/chat/+page.svelte` (modified) diff --git a/src/lib/components/AuditTimeline.svelte b/src/lib/components/AuditTimeline.svelte new file mode 100644 index 0000000..176473f --- /dev/null +++ b/src/lib/components/AuditTimeline.svelte @@ -0,0 +1,110 @@ + + +{#if events.length === 0} +
+
📄
+

No events for this session

+

Events will appear here as orchestration runs.

+
+{:else} +
+ +
+ +
    + {#each events as event, i (event.id)} + {@const showDate = i === 0 || formatDate(event.timestamp) !== formatDate(events[i - 1].timestamp)} + + {#if showDate} +
  1. + {formatDate(event.timestamp)} +
  2. + {/if} + +
  3. + +
    + + +
    +
    + {formatTimestamp(event.timestamp)} + + {typeLabel(event.eventType)} + + {#if event.state} + + {event.state} + + {/if} +
    +

    {event.details}

    +
    +
  4. + {/each} +
+
+{/if} diff --git a/src/lib/stores/audit.svelte.ts b/src/lib/stores/audit.svelte.ts new file mode 100644 index 0000000..662191f --- /dev/null +++ b/src/lib/stores/audit.svelte.ts @@ -0,0 +1,247 @@ +import { SvelteMap } from 'svelte/reactivity'; + +export type AuditEventType = 'state_change' | 'tool_invocation' | 'error' | 'message'; + +export interface AuditEvent { + id: string; + sessionId: string; + timestamp: Date; + eventType: AuditEventType; + details: string; + state?: string; +} + +export interface SessionAuditLog { + sessionId: string; + events: AuditEvent[]; +} + +const STORAGE_KEY = 'llm-multiverse-audit-events'; + +function loadEvents(): SvelteMap { + if (typeof localStorage === 'undefined') return new SvelteMap(); + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return new SvelteMap(); + const arr: [string, AuditEvent[]][] = JSON.parse(raw); + return new SvelteMap( + arr.map(([id, events]) => [ + id, + events.map((e) => ({ ...e, timestamp: new Date(e.timestamp) })) + ]) + ); + } catch { + return new SvelteMap(); + } +} + +function saveEvents(events: SvelteMap) { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify([...events.entries()])); +} + +function getSampleData(): SvelteMap { + const samples = new SvelteMap(); + const now = Date.now(); + + samples.set('session-demo-alpha', [ + { + id: 'evt-001', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 300_000), + eventType: 'state_change', + details: 'Orchestration started — decomposing user request', + state: 'DECOMPOSING' + }, + { + id: 'evt-002', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 280_000), + eventType: 'tool_invocation', + details: 'Invoked web_search tool for "SvelteKit best practices"', + state: 'DECOMPOSING' + }, + { + id: 'evt-003', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 260_000), + eventType: 'state_change', + details: 'Dispatching subtasks to subagents', + state: 'DISPATCHING' + }, + { + id: 'evt-004', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 240_000), + eventType: 'message', + details: 'Researcher agent assigned to subtask "Gather TypeScript patterns"' + }, + { + id: 'evt-005', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 220_000), + eventType: 'state_change', + details: 'Subagents executing tasks', + state: 'EXECUTING' + }, + { + id: 'evt-006', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 200_000), + eventType: 'tool_invocation', + details: 'Invoked code_analysis tool on project source files' + }, + { + id: 'evt-007', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 180_000), + eventType: 'error', + details: 'Timeout waiting for code_analysis response (retrying)' + }, + { + id: 'evt-008', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 160_000), + eventType: 'tool_invocation', + details: 'Retry: code_analysis tool succeeded' + }, + { + id: 'evt-009', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 140_000), + eventType: 'state_change', + details: 'Compacting results from subagents', + state: 'COMPACTING' + }, + { + id: 'evt-010', + sessionId: 'session-demo-alpha', + timestamp: new Date(now - 120_000), + eventType: 'state_change', + details: 'Orchestration complete', + state: 'COMPLETE' + } + ]); + + samples.set('session-demo-beta', [ + { + id: 'evt-011', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 600_000), + eventType: 'state_change', + details: 'Orchestration started — decomposing deployment request', + state: 'DECOMPOSING' + }, + { + id: 'evt-012', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 580_000), + eventType: 'state_change', + details: 'Dispatching to sysadmin agent', + state: 'DISPATCHING' + }, + { + id: 'evt-013', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 560_000), + eventType: 'state_change', + details: 'Executing deployment analysis', + state: 'EXECUTING' + }, + { + id: 'evt-014', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 540_000), + eventType: 'tool_invocation', + details: 'Invoked kubectl_check tool for cluster status' + }, + { + id: 'evt-015', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 520_000), + eventType: 'error', + details: 'Permission denied: kubectl access not granted for this session' + }, + { + id: 'evt-016', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 500_000), + eventType: 'message', + details: 'Agent requested elevated permissions for cluster access' + }, + { + id: 'evt-017', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 480_000), + eventType: 'state_change', + details: 'Compacting partial results', + state: 'COMPACTING' + }, + { + id: 'evt-018', + sessionId: 'session-demo-beta', + timestamp: new Date(now - 460_000), + eventType: 'state_change', + details: 'Orchestration complete with warnings', + state: 'COMPLETE' + } + ]); + + return samples; +} + +function createAuditStore() { + const events = $state>(loadEvents()); + + // Seed sample data if store is empty + if (events.size === 0) { + const samples = getSampleData(); + for (const [id, evts] of samples) { + events.set(id, evts); + } + saveEvents(events); + } + + function addEvent( + sessionId: string, + event: Omit + ) { + const auditEvent: AuditEvent = { + id: crypto.randomUUID(), + sessionId, + timestamp: new Date(), + ...event + }; + const existing = events.get(sessionId) ?? []; + events.set(sessionId, [...existing, auditEvent]); + saveEvents(events); + } + + function getEventsBySession(sessionId: string): AuditEvent[] { + return events.get(sessionId) ?? []; + } + + function getAllSessions(): SessionAuditLog[] { + return [...events.entries()] + .map(([sessionId, evts]) => ({ sessionId, events: evts })) + .sort((a, b) => { + const aLatest = a.events.length > 0 ? a.events[a.events.length - 1].timestamp.getTime() : 0; + const bLatest = b.events.length > 0 ? b.events[b.events.length - 1].timestamp.getTime() : 0; + return bLatest - aLatest; + }); + } + + function clearSession(sessionId: string) { + events.delete(sessionId); + saveEvents(events); + } + + return { + addEvent, + getEventsBySession, + getAllSessions, + clearSession + }; +} + +export const auditStore = createAuditStore(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d06f761..7e9179a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,6 +4,7 @@ const chatHref = resolveRoute('/chat'); const lineageHref = resolveRoute('/lineage'); const memoryHref = resolveRoute('/memory'); + const auditHref = resolveRoute('/audit');

LLM Multiverse UI

@@ -15,4 +16,6 @@ Agent Lineage Memory Candidates + + Audit Log diff --git a/src/routes/audit/+page.svelte b/src/routes/audit/+page.svelte new file mode 100644 index 0000000..f6ccfc8 --- /dev/null +++ b/src/routes/audit/+page.svelte @@ -0,0 +1,128 @@ + + +
+ +
+
+ + + ← Chat + + +

Audit Log

+
+ + Sample Data + +
+ + +
+
+
+ + +
+ + {#if selectedSessionId} +
+ + +
+ + + {totalEvents} event{totalEvents !== 1 ? 's' : ''} + + {/if} +
+
+ + +
+ {#if !selectedSessionId} + {#if allSessions.length === 0} +
+
📋
+

No audit events recorded

+

+ Events will appear here as orchestration sessions run. +

+
+ {:else} +
+
🔍
+

Select a session to view its audit log

+

+ Choose a session from the dropdown above. +

+
+ {/if} + {:else} + + {/if} +
+
diff --git a/src/routes/audit/+page.ts b/src/routes/audit/+page.ts new file mode 100644 index 0000000..ae88a27 --- /dev/null +++ b/src/routes/audit/+page.ts @@ -0,0 +1,2 @@ +export const prerender = false; +export const ssr = false; diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte index 06899e1..40b7ee5 100644 --- a/src/routes/chat/+page.svelte +++ b/src/routes/chat/+page.svelte @@ -19,6 +19,7 @@ import { OrchestrationState } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import { sessionStore } from '$lib/stores/sessions.svelte'; import { memoryStore } from '$lib/stores/memory.svelte'; + import { auditStore } from '$lib/stores/audit.svelte'; let messages: ChatMessage[] = $state([]); let isStreaming = $state(false); @@ -32,6 +33,7 @@ let showConfig = $state(false); const lineageHref = resolveRoute('/lineage'); const memoryHref = resolveRoute('/memory'); + const auditHref = resolveRoute('/audit'); const isNonDefaultConfig = $derived( sessionConfig.overrideLevel !== OverrideLevel.NONE || @@ -80,6 +82,7 @@ finalResult = null; const sessionId = sessionStore.activeSessionId!; + let lastAuditState = OrchestrationState.UNSPECIFIED; const userMessage: ChatMessage = { id: crypto.randomUUID(), @@ -103,6 +106,18 @@ try { for await (const response of processRequest(sessionId, content, sessionConfig)) { orchestrationState = response.state; + + // Capture state changes to the audit log + if (response.state !== lastAuditState) { + const stateLabel = OrchestrationState[response.state] ?? String(response.state); + auditStore.addEvent(sessionId, { + eventType: 'state_change', + details: response.message || `State changed to ${stateLabel}`, + state: stateLabel + }); + lastAuditState = response.state; + } + if (response.intermediateResult) { intermediateResult = response.intermediateResult; } @@ -131,6 +146,10 @@ ? `Error (${err.code}): ${err.message}` : 'An unexpected error occurred'; error = msg; + auditStore.addEvent(sessionId, { + eventType: 'error', + details: msg + }); const idx = messages.length - 1; messages[idx] = { ...messages[idx], @@ -163,6 +182,12 @@ > Memory + + Audit +