From 5db52a2ed590500891f48607a07010043024b7af Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Thu, 16 Jul 2026 14:36:02 +0530 Subject: [PATCH] =?UTF-8?q?feat(sessions):=20session=20activity=20trail=20?= =?UTF-8?q?=E2=80=94=20every=20object=20a=20run=20opened,=20with=20live=20?= =?UTF-8?q?status=20(v0.221.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-session activity panel now answers "what is this session doing/done?", not just "which primitives did it touch". - Coverage: the classifier (src/state/session-activity.ts) gains secrets/skills/ policy categories, so secret.put/get/requested, skill.proposed/requested and policy.proposed surface as first-class entries instead of falling to 'other'. - Live status: each object-bearing entry carries an ActivityTarget {kind,id}; the /api/sessions/:id/activity route resolves that target's CURRENT state from its live store (task todo→doing→done, KB rev N, secret stored, proposal card pending→approved/rejected) and returns status/statusTone. - UI: SessionActivity is now a right-docked side panel with an Objects | Timeline toggle; Objects collapses to one row per live object (outstanding first) with a status chip; polls while the run is alive so status updates in place. Audit stays the single spine — no new tables, no migration; adding a plane is one classifier case + one resolver. Verified end-to-end via scripts/session-trail-test.cjs (13/13, real in-process HTTP server) and governance suite (68/68). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014fS9mHadnGxa2VKhEKn7a7 --- CHANGELOG.md | 17 ++++++ package-lock.json | 4 +- package.json | 2 +- scripts/session-trail-test.cjs | 98 ++++++++++++++++++++++++++++++++ src/server.ts | 67 +++++++++++++++++++++- src/state/session-activity.ts | 53 +++++++++++++---- web/src/App.tsx | 101 ++++++++++++++++++++++++++++----- web/src/lib/api.ts | 8 ++- 8 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 scripts/session-trail-test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1caa8a9..5fa6a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ new version heading in the same commit. ## [Unreleased] +## [0.221.0] — 2026-07-16 +### Added +- **Session activity trail — every object a run opened, with live status.** The per-session activity + panel (`GET /api/sessions/:id/activity` + the `SessionActivity` component) now answers "what is this + session doing / done?", not just "which primitives did it touch". Two changes: (1) **coverage** — the + classifier (`src/state/session-activity.ts`) gained `secrets` / `skills` / `policy` categories, so + `secret.put`/`secret.get`/`secret.requested`, `skill.proposed`/`skill.requested` and `policy.proposed` + surface as first-class trail entries instead of falling into `other`; (2) **live status** — each + object-bearing entry carries an `ActivityTarget` (`{kind,id}`), and the route resolves that target's + CURRENT state from its live store (a task's `todo→doing→done`, a KB page's `rev N`, a secret's + `stored`, a proposal card's `pending→approved/rejected`) and returns it as `status`/`statusTone`. The + console panel is now a right-docked **side panel** with an **Objects | Timeline** toggle: Objects + collapses the trail to one row per live object (outstanding ones first) with a status chip; Timeline is + the full chronological feed. It **polls while the run is alive**, so a task's status updates in place. + Audit stays the single spine — no new tables, no migration; adding a plane is one classifier case + one + resolver. + ## [0.220.0] — 2026-07-16 ### Added - **Chat renders Library deliverables inline.** When an agent produces a Library artifact mid-conversation diff --git a/package-lock.json b/package-lock.json index 005dacb..a8d2508 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.220.0", + "version": "0.221.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.220.0", + "version": "0.221.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 3b41771..6cefbb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.220.0", + "version": "0.221.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/session-trail-test.cjs b/scripts/session-trail-test.cjs new file mode 100644 index 0000000..53b629d --- /dev/null +++ b/scripts/session-trail-test.cjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/* Session-trail test: the /api/sessions/:id/activity endpoint now (1) classifies secrets/skills/policy + * effects and (2) attaches each object's LIVE status resolved from its store (task todo→done, KB rev, + * proposal pending→approved, secret stored). Boots a real in-process HTTP server + registry, seeds one + * session's audit stream + the live objects, and asserts the endpoint's output. Isolated home. */ +const fs = require('fs'); const os = require('os'); const path = require('path'); +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-trail-test-')); +process.env.AGENT_OS_HOME = HOME; process.env.AGENT_OS_TENANT = 'testco'; +process.env.AGENT_OS_OWNER_EMAIL = 'owner@test'; +delete process.env.AGENT_OS_SECRET_KEY; +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d ? ' — ' + d : ''}`)); + +const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js')); +const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); + +(async () => { + const registry = new TenantRegistry(ROOT, 0); + registry.bootAll(); + const rt = registry.get('testco'); + const { os: aos } = rt; + const T = aos.tenant; + + // owner cookie — invite + accept mints a real auth session id + const { token } = aos.team.invite({ email: 'tester@test', role: 'owner' }); + const sid = aos.team.acceptToken(token).sid; + const cookie = `aos_sid=${sid}`; + + // a session row (owner is run_as so canViewSession trivially passes) + const SID = 'ts_trail1'; + aos.db.prepare( + "INSERT INTO term_sessions (id,agent,title,task,tmux,status,headless,resident,run_as,spawned_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" + ).run(SID, 'website-bot', 'trail run', 'do stuff', 'aos-' + SID, 'running', 1, 0, null, null, Date.now(), Date.now()); + + // audit stream for the run — one effect per plane we care about + let t = Date.now(); + const audit = (type, data) => aos.db + .prepare("INSERT INTO audit_events (ts,run_id,tenant,type,principal,data) VALUES (?,?,?,?,?,?)") + .run(t++, SID, T, type, 'website-bot', JSON.stringify(data)); + audit('task.created', { id: 'tk1', title: 'migrate the DB' }); + audit('task.updated', { id: 'tk1', status: 'doing' }); + audit('secret.put', { key: 'STRIPE_KEY', principal: '*' }); + audit('secret.requested', { key: 'OPENAI_KEY', mode: 'provide' }); + audit('skill.proposed', { name: 'triage-flow', description: 'triage playbook' }); + audit('policy.proposed', { kind: 'tighten', capability: 'Bash' }); + audit('kb.written', { section: 'engineering', slug: 'deploy', rev: 1 }); + + // live objects the statuses resolve against + const now = Date.now(); + aos.db.prepare("INSERT INTO tasks (id,tenant,title,body,status,priority,labels,created_by,created_at,updated_at,updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)") + .run('tk1', T, 'migrate the DB', '', 'doing', 2, '[]', 'agent:website-bot', now, now, 'agent:website-bot'); + aos.db.prepare("INSERT INTO secrets (tenant,principal,key,value_enc,updated_at,updated_by) VALUES (?,?,?,?,?,?)") + .run(T, '*', 'STRIPE_KEY', 'x', now, 'agent:website-bot'); + aos.db.prepare("INSERT INTO kb_pages (id,tenant,section,slug,title,tags,body,rel_path,rev,created_at,updated_at,updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)") + .run('kp1', T, 'engineering', 'deploy', 'Deploy', '[]', '# deploy', 'kb/engineering/deploy.md', 3, now, now, 'agent:website-bot'); + // proposal cards carry their own live status; secret-request approved, skill/policy still pending(open) + const addMsg = (id, type, status, args) => aos.db + .prepare("INSERT INTO messages (id,type,session_id,agent,title,body,status,args,created_at) VALUES (?,?,?,?,?,?,?,?,?)") + .run(id, type, SID, 'website-bot', 't', 'b', status, JSON.stringify(args), now); + addMsg('m_sec', 'secret.request', 'approved', { key: 'OPENAI_KEY', mode: 'provide' }); + addMsg('m_skl', 'skill.proposed', 'open', { skill: 'triage-flow' }); + addMsg('m_pol', 'policy.proposal', 'open', { delta: { kind: 'tighten', match: { capability: 'Bash' } } }); + + const server = createHttpServer(registry); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}/api/sessions/${SID}/activity`, { headers: { cookie } }); + const body = await res.json(); + + console.log('\n\x1b[1mSession activity trail — coverage + live status\x1b[0m'); + assert(res.status === 200, 'endpoint returns 200', `${res.status} ${JSON.stringify(body)}`); + const ev = body.events || []; + const by = (prim) => ev.find((e) => e.primitive === prim) || {}; + + // (1) coverage — the new categories surface (were previously 'other') + assert(by('secret_put').category === 'secrets', 'secret.put → secrets category'); + assert(by('secret_request').category === 'secrets', 'secret.requested → secrets category'); + assert(by('skill_propose').category === 'skills', 'skill.proposed → skills category'); + assert(by('policy_propose').category === 'policy', 'policy.proposed → policy category'); + + // (2) live status resolution + assert(by('task_create').status === 'doing' && by('task_create').statusTone === 'open', 'task → live status "doing" (open)', JSON.stringify(by('task_create'))); + assert(by('task_update').status === 'doing', 'task_update entry also resolves to current "doing"'); + assert(by('secret_put').status === 'stored', 'secret → "stored" (exists in vault)', JSON.stringify(by('secret_put'))); + assert(by('kb_write').status === 'rev 3' && by('kb_write').statusTone === 'muted', 'kb_write → current "rev 3"', JSON.stringify(by('kb_write'))); + assert(by('secret_request').status === 'approved' && by('secret_request').statusTone === 'done', 'secret_request → card status "approved" (done)', JSON.stringify(by('secret_request'))); + assert(by('skill_propose').status === 'pending' && by('skill_propose').statusTone === 'open', 'skill_propose → card "pending" (open)', JSON.stringify(by('skill_propose'))); + assert(by('policy_propose').status === 'pending' && by('policy_propose').statusTone === 'open', 'policy_propose → card "pending" (open)', JSON.stringify(by('policy_propose'))); + + // summary still groups counts + assert((body.summary || []).some((s) => s.category === 'policy'), 'summary includes the policy category'); + + console.log(`\n${fail === 0 ? '\x1b[32m' : '\x1b[31m'}SESSION TRAIL: ${pass}/${pass + fail} passed\x1b[0m`); + server.close(); + try { fs.rmSync(HOME, { recursive: true, force: true }); } catch {} + process.exit(fail === 0 ? 0 : 1); +})().catch((e) => { console.error(e); try { fs.rmSync(HOME, { recursive: true, force: true }); } catch {} process.exit(1); }); diff --git a/src/server.ts b/src/server.ts index 4bcacd4..cd350f5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,7 +16,7 @@ import { pendingAlerts } from './edge/alerts'; import { exampleCapabilities } from './capabilities/examples'; import { evaluate } from './observability/evaluation'; import { TerminalManager, AGENT_OS_OPERATING_NOTES } from './terminal'; -import { classifyActivity, clipText, ActivityCategory, ActivityEffect } from './state/session-activity'; +import { classifyActivity, clipText, ActivityCategory, ActivityEffect, ActivityTarget } from './state/session-activity'; import { readConversation, type ChatArtifactRef } from './edge/conversation'; import { summarizeConversation } from './edge/summarize'; import { Automation, Automations, nextCronRun, derivedConcurrencyCap, chatTitle } from './edge/automations'; @@ -2301,7 +2301,8 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const rows = os.db .prepare('SELECT ts, type, data FROM audit_events WHERE tenant = ? AND run_id = ? ORDER BY ts ASC, id ASC') .all<{ ts: number; type: string; data: string }>(os.tenant, id); - type Row = { ts: number; category: ActivityCategory; primitive: string; summary: string; effect?: ActivityEffect }; + type StatusTone = 'open' | 'done' | 'blocked' | 'denied' | 'muted'; + type Row = { ts: number; category: ActivityCategory; primitive: string; summary: string; effect?: ActivityEffect; target?: ActivityTarget; status?: string; statusTone?: StatusTone }; const events: Row[] = []; for (const r of rows) { const d = classifyActivity(r.type, safeJson(r.data)); @@ -2314,6 +2315,68 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: .all<{ ts: number; body: string }>(id); for (const u of ups) events.push({ ts: u.ts, category: 'operator', primitive: 'update', summary: clipText(u.body) }); events.sort((a, b) => a.ts - b.ts); + + // Attach each object-bearing entry's CURRENT status — audit is point-in-time ("task created"), so + // this is what turns the trail into "what is it doing/done" (a task's todo→done, a proposal's + // pending→approved). Resolved from the live stores; cached per target so a task touched by several + // events is queried once. + const statusCache = new Map(); + const msgTone = (s: string): { status: string; tone: StatusTone } => + s === 'approved' ? { status: 'approved', tone: 'done' } + : s === 'rejected' ? { status: 'rejected', tone: 'denied' } + : s === 'resolved' ? { status: 'resolved', tone: 'muted' } + : { status: 'pending', tone: 'open' }; + // Proposal cards (secret-request / skill / policy) are `messages` rows that carry their own live + // status — load this session's once, indexed by the discriminator the classifier stored as target.id. + const skillProp = new Map(); // skill name → status + const secretReq = new Map(); // secret key → status + const policyProp: Array<{ cap: string; status: string }> = []; // capability → status (in order) + for (const m of os.db + .prepare("SELECT type, status, args FROM messages WHERE session_id = ? AND type IN ('secret.request','skill.proposed','policy.proposal')") + .all<{ type: string; status: string; args: string | null }>(id)) { + const a = safeJson(m.args ?? '') as Record; + if (m.type === 'skill.proposed' && typeof a.skill === 'string') skillProp.set(a.skill, m.status); + else if (m.type === 'secret.request' && typeof a.key === 'string') secretReq.set(a.key, m.status); + else if (m.type === 'policy.proposal') { + const cap = (a.delta as { match?: { capability?: unknown } } | undefined)?.match?.capability; + if (typeof cap === 'string') policyProp.push({ cap, status: m.status }); + } + } + const resolveStatus = (t: ActivityTarget): { status: string; tone: StatusTone } | null => { + const ck = `${t.kind}:${t.id}`; + const cached = statusCache.get(ck); + if (cached !== undefined) return cached; + let out: { status: string; tone: StatusTone } | null = null; + if (t.kind === 'task') { + const row = os.db.prepare('SELECT status FROM tasks WHERE tenant = ? AND id = ?').get<{ status: string }>(os.tenant, t.id); + out = !row ? { status: 'deleted', tone: 'muted' } + : { status: row.status, tone: row.status === 'done' ? 'done' : row.status === 'blocked' || row.status === 'cancelled' ? 'blocked' : 'open' }; + } else if (t.kind === 'kb') { + const slash = t.id.indexOf('/'); + const section = slash >= 0 ? t.id.slice(0, slash) : t.id, slug = slash >= 0 ? t.id.slice(slash + 1) : ''; + const row = os.db.prepare('SELECT rev FROM kb_pages WHERE tenant = ? AND section = ? AND slug = ?').get<{ rev: number }>(os.tenant, section, slug); + out = row ? { status: `rev ${row.rev}`, tone: 'muted' } : { status: 'deleted', tone: 'muted' }; + } else if (t.kind === 'approval') { + const row = os.db.prepare('SELECT status FROM approvals WHERE tenant = ? AND id = ?').get<{ status: string }>(os.tenant, t.id); + if (row) out = row.status === 'approved' ? { status: 'approved', tone: 'done' } : row.status === 'rejected' ? { status: 'rejected', tone: 'denied' } : { status: 'pending', tone: 'open' }; + } else if (t.kind === 'secret') { + const row = os.db.prepare('SELECT 1 AS ok FROM secrets WHERE tenant = ? AND key = ? LIMIT 1').get<{ ok: number }>(os.tenant, t.id); + out = row ? { status: 'stored', tone: 'muted' } : { status: 'removed', tone: 'muted' }; + } else if (t.kind === 'secret-request') { + const s = secretReq.get(t.id); out = s ? msgTone(s) : null; + } else if (t.kind === 'skill-proposal') { + const s = skillProp.get(t.id); out = s ? msgTone(s) : null; + } else if (t.kind === 'policy-proposal') { + const s = policyProp.find((p) => p.cap === t.id)?.status; out = s ? msgTone(s) : null; + } + statusCache.set(ck, out); + return out; + }; + for (const e of events) { + if (!e.target) continue; + const r = resolveStatus(e.target); + if (r) { e.status = r.status; e.statusTone = r.tone; } + } // Grouped count summary (primitive → how many times), most-used first. const byPrim = new Map(); for (const e of events) { diff --git a/src/state/session-activity.ts b/src/state/session-activity.ts index 18e64d8..7758a92 100644 --- a/src/state/session-activity.ts +++ b/src/state/session-activity.ts @@ -12,10 +12,18 @@ export type ActivityCategory = | 'action' | 'operator' | 'memory' | 'knowledge' | 'tasks' - | 'scheduling' | 'agents' | 'approval' | 'other'; + | 'scheduling' | 'agents' | 'approval' | 'secrets' | 'skills' | 'policy' | 'other'; export type ActivityEffect = 'allow' | 'approve' | 'deny' | 'error'; +/** The kind of live object a trail entry opened — the key the route uses to resolve the object's + * CURRENT status (audit is point-in-time; "task created" ≠ "task is now done"). `id` is whatever + * uniquely locates the row in its store: a task id, a `section/slug` for KB, the key for a secret, + * the skill name / secret key / policy capability for the matching proposal card. */ +export type TargetKind = + | 'task' | 'kb' | 'approval' | 'secret' | 'secret-request' | 'skill-proposal' | 'policy-proposal'; +export interface ActivityTarget { kind: TargetKind; id: string } + /** One classified primitive-use, sans timestamp (the route stamps `ts` from the audit row). */ export interface ActivityDescriptor { category: ActivityCategory; @@ -26,6 +34,9 @@ export interface ActivityDescriptor { summary: string; /** For governed actions/approvals: how the gate classified it (allow/approve/deny) or the outcome. */ effect?: ActivityEffect; + /** The live object this entry opened, if any — lets the route attach the object's CURRENT status + * (a task's todo→done, a proposal's pending→approved) so the trail tracks state, not just history. */ + target?: ActivityTarget; } /** Session plumbing + the paired/duplicate half of a governed action — not, on their own, a primitive @@ -47,6 +58,7 @@ const PREFIX: ReadonlyArray = [ ['memory.', 'memory'], ['kb.', 'knowledge'], ['task.', 'tasks'], ['agent.', 'agents'], ['automation.', 'scheduling'], ['approval.', 'approval'], ['gate.', 'action'], ['action.', 'action'], ['question.', 'operator'], ['artifact.', 'operator'], + ['secret.', 'secrets'], ['skill.', 'skills'], ['policy.', 'policy'], ]; const str = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v)); @@ -57,6 +69,12 @@ export function clipText(v: unknown, n = 140): string { return t.length > n ? t.slice(0, n - 1) + '…' : t; } +/** A KB target keyed by `section/slug` — the route resolves the page's current rev / existence. */ +const kbTarget = (data: Record): ActivityTarget | undefined => { + const section = str(data.section), slug = str(data.slug); + return section && slug ? { kind: 'kb', id: `${section}/${slug}` } : undefined; +}; + const normEffect = (e: string): ActivityEffect | undefined => e === 'allow' || e === 'approve' || e === 'deny' || e === 'error' ? e : undefined; @@ -106,17 +124,30 @@ export function classifyActivity(type: string, data: Record): A case 'memory.forgotten': return { category: 'memory', primitive: 'forget', summary: id() ? `#${id()}` : '' }; // ── knowledge base ── - case 'kb.written': return { category: 'knowledge', primitive: 'kb_write', summary: `${str(data.section)}/${str(data.slug)}${data.rev ? ` · rev ${str(data.rev)}` : ''}` }; - case 'kb.reverted': return { category: 'knowledge', primitive: 'kb_revert', summary: `${str(data.section)}/${str(data.slug)} → rev ${str(data.rev)}` }; - case 'kb.deleted': return { category: 'knowledge', primitive: 'kb_delete', summary: `${str(data.section)}/${str(data.slug)}` }; + case 'kb.written': return { category: 'knowledge', primitive: 'kb_write', summary: `${str(data.section)}/${str(data.slug)}${data.rev ? ` · rev ${str(data.rev)}` : ''}`, target: kbTarget(data) }; + case 'kb.reverted': return { category: 'knowledge', primitive: 'kb_revert', summary: `${str(data.section)}/${str(data.slug)} → rev ${str(data.rev)}`, target: kbTarget(data) }; + case 'kb.deleted': return { category: 'knowledge', primitive: 'kb_delete', summary: `${str(data.section)}/${str(data.slug)}`, target: kbTarget(data) }; // ── tasks plane ── - case 'task.created': return { category: 'tasks', primitive: 'task_create', summary: clipText(data.title) || id() }; - case 'task.claimed': return { category: 'tasks', primitive: 'task_claim', summary: id() }; - case 'task.updated': return { category: 'tasks', primitive: 'task_update', summary: `${id()} → ${str(data.status)}` }; - case 'task.completed': return { category: 'tasks', primitive: 'task_update', summary: `${id()} → ${str(data.status) || 'done'}`, effect: 'allow' }; - case 'task.dispatched': return { category: 'tasks', primitive: 'task_dispatch', summary: id() }; - case 'task.deleted': return { category: 'tasks', primitive: 'task_delete', summary: id() }; + case 'task.created': return { category: 'tasks', primitive: 'task_create', summary: clipText(data.title) || id(), target: { kind: 'task', id: id() } }; + case 'task.claimed': return { category: 'tasks', primitive: 'task_claim', summary: id(), target: { kind: 'task', id: id() } }; + case 'task.updated': return { category: 'tasks', primitive: 'task_update', summary: `${id()} → ${str(data.status)}`, target: { kind: 'task', id: id() } }; + case 'task.completed': return { category: 'tasks', primitive: 'task_update', summary: `${id()} → ${str(data.status) || 'done'}`, effect: 'allow', target: { kind: 'task', id: id() } }; + case 'task.dispatched': return { category: 'tasks', primitive: 'task_dispatch', summary: id(), target: { kind: 'task', id: id() } }; + case 'task.deleted': return { category: 'tasks', primitive: 'task_delete', summary: id(), target: { kind: 'task', id: id() } }; + + // ── secrets vault (shared credential handoff) ── + case 'secret.put': return { category: 'secrets', primitive: 'secret_put', summary: str(data.key), target: { kind: 'secret', id: str(data.key) } }; + case 'secret.get': return { category: 'secrets', primitive: 'secret_get', summary: str(data.key), effect: data.found === false ? 'error' : 'allow', target: { kind: 'secret', id: str(data.key) } }; + case 'secret.get.denied': return { category: 'secrets', primitive: 'secret_get', summary: `${str(data.key)} — ${clipText(data.reason, 90)}`.replace(/ — $/, ''), effect: 'deny' }; + case 'secret.requested': return { category: 'secrets', primitive: 'secret_request', summary: `${str(data.key)}${data.mode ? ` · ${str(data.mode)}` : ''}`, target: { kind: 'secret-request', id: str(data.key) } }; + + // ── skills (procedural memory — propose/request, an owner publishes) ── + case 'skill.proposed': return { category: 'skills', primitive: 'skill_propose', summary: str(data.name), target: { kind: 'skill-proposal', id: str(data.name) } }; + case 'skill.requested': return { category: 'skills', primitive: 'skill_request', summary: str(data.name) || str(data.skill) }; + + // ── policy (propose-don't-apply — an owner approves the tightening) ── + case 'policy.proposed': return { category: 'policy', primitive: 'policy_propose', summary: `${str(data.kind)} · ${str(data.capability)}`.replace(/^ · | · $/g, ''), target: { kind: 'policy-proposal', id: str(data.capability) } }; // ── scheduling (agent-deferred self-runs) ── case 'automation.scheduled': return { category: 'scheduling', primitive: 'schedule', summary: `${str(data.agent)} @ ${fmtWhen(data.runAt)}`.trim() }; @@ -130,7 +161,7 @@ export function classifyActivity(type: string, data: Record): A case 'agent.deleted': return { category: 'agents', primitive: 'agent_delete', summary: str(data.agent) || id() }; // ── approvals the agent triggered (the human resolution is folded away as noise) ── - case 'approval.requested': return { category: 'approval', primitive: 'approval', summary: `${str(data.level)}${data.reason ? ` · ${clipText(data.reason, 90)}` : ''}`.replace(/^ · /, ''), effect: 'approve' }; + case 'approval.requested': return { category: 'approval', primitive: 'approval', summary: `${str(data.level)}${data.reason ? ` · ${clipText(data.reason, 90)}` : ''}`.replace(/^ · /, ''), effect: 'approve', target: { kind: 'approval', id: str(data.approvalId) || id() } }; default: break; } diff --git a/web/src/App.tsx b/web/src/App.tsx index d774475..1cd27cb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10197,6 +10197,9 @@ const ACTIVITY_STYLE: Record, string> = { error: 'border-red-500/40 text-red-600', } -/** A modal timeline of the agent-os primitives a session used: grouped counts + a chronological feed, - * read from the run's audit stream via /api/sessions/:id/activity. */ +/** Tint for the live-status chip — the object's CURRENT state, not the point-in-time effect. */ +const STATUS_TONE_CLS: Record, string> = { + open: 'border-sky-500/40 text-sky-600', + done: 'border-emerald-500/40 text-emerald-600', + blocked: 'border-orange-500/40 text-orange-600', + denied: 'border-red-500/40 text-red-600', + muted: 'border-border text-muted-foreground', +} + +/** A live status chip — the object's CURRENT state (todo→done, pending→approved, rev N). */ +function StatusChip({ e }: { e: ActivityEvent }) { + if (!e.status) return null + return ( + {e.status} + ) +} + +/** A right-docked side panel tracking everything a session opened and where it stands NOW — the + * objects it created/touched (tasks, secrets, policy proposals, KB pages, skills) with their live + * status, plus the full chronological trail. Reads /api/sessions/:id/activity, polling while the run + * is alive so a task's todo→doing→done (or a proposal's pending→approved) updates in place. */ function SessionActivity({ session, onClose }: { session: Session; onClose: () => void }) { const [events, setEvents] = useState([]) const [summary, setSummary] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [view, setView] = useState<'objects' | 'timeline'>('objects') + useEffect(() => { let live = true - setLoading(true) - api.sessionActivity(session.id) - .then((r) => { if (!live) return; if (r.error) setError(r.error); else { setEvents(r.events ?? []); setSummary(r.summary ?? []) } }) - .catch(() => { if (live) setError('Could not load activity') }) - .finally(() => { if (live) setLoading(false) }) - return () => { live = false } - }, [session.id]) + const load = (initial: boolean) => { + if (initial) setLoading(true) + api.sessionActivity(session.id) + .then((r) => { if (!live) return; if (r.error) setError(r.error); else { setError(''); setEvents(r.events ?? []); setSummary(r.summary ?? []) } }) + .catch(() => { if (live && initial) setError('Could not load activity') }) + .finally(() => { if (live && initial) setLoading(false) }) + } + load(true) + // Keep statuses fresh while the agent is still working (todo→done lands without a manual reopen). + const timer = session.alive ? window.setInterval(() => load(false), 4000) : undefined + return () => { live = false; if (timer) window.clearInterval(timer) } + }, [session.id, session.alive]) + + // Latest entry per live object — a task touched 3× collapses to its current row — so the object + // list and its status counts read true. Outstanding (open/blocked) float to the top. + const objects = useMemo(() => { + const byTarget = new Map() + for (const e of events) if (e.target && e.status) byTarget.set(`${e.target.kind}:${e.target.id}`, e) + const rank = (e: ActivityEvent) => (e.statusTone === 'open' ? 0 : e.statusTone === 'blocked' ? 1 : e.statusTone === 'denied' ? 2 : 3) + return [...byTarget.values()].sort((a, b) => rank(a) - rank(b) || b.ts - a.ts) + }, [events]) + const outstanding = objects.filter((o) => o.statusTone === 'open' || o.statusTone === 'blocked').length return ( { if (!o) onClose() }}> - + {session.title}
- {session.agent} · {session.id} · primitives this session used + {session.agent} · {session.id} · what this session opened{session.alive && · live}
@@ -10254,12 +10293,43 @@ function SessionActivity({ session, onClose }: { session: Session; onClose: () = )} - {/* chronological timeline */} + {/* Objects | Timeline toggle */} +
+ + +
+
{loading ? (
Loading activity…
) : error ? (
{error}
+ ) : view === 'objects' ? ( + objects.length === 0 ? ( +
+ This session hasn't opened any tracked objects yet — tasks, secrets, policy proposals, KB + pages and skills show up here with their live status. Switch to Timeline for the full trail. +
+ ) : ( +
+ {objects.map((e, i) => { + const st = ACTIVITY_STYLE[e.category] ?? ACTIVITY_STYLE.other + const Icon = st.icon + return ( +
+ + {e.primitive} + {e.summary} + +
+ ) + })} +
+ ) ) : events.length === 0 ? (
No governed primitives recorded for this session yet. Read-only tools (recall, searches, inbox @@ -10272,7 +10342,7 @@ function SessionActivity({ session, onClose }: { session: Session; onClose: () = const Icon = st.icon return (
- + {new Date(e.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} @@ -10281,6 +10351,7 @@ function SessionActivity({ session, onClose }: { session: Session; onClose: () = {e.effect} )} {e.summary} +
) })} @@ -10288,7 +10359,9 @@ function SessionActivity({ session, onClose }: { session: Session; onClose: () = )}
- {loading ? '' : `${events.length} primitive${events.length === 1 ? '' : 's'} · from the session's audit trail`} + {loading ? '' : view === 'objects' + ? `${objects.length} object${objects.length === 1 ? '' : 's'}${outstanding > 0 ? ` · ${outstanding} still open` : ''}` + : `${events.length} primitive${events.length === 1 ? '' : 's'} · from the session's audit trail`}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d12748c..a12d98b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -252,12 +252,18 @@ export interface AuditResp { /** One classified primitive-use in a session's activity timeline (from /api/sessions/:id/activity). */ export interface ActivityEvent { ts: number - category: 'action' | 'operator' | 'memory' | 'knowledge' | 'tasks' | 'scheduling' | 'agents' | 'approval' | 'other' + category: 'action' | 'operator' | 'memory' | 'knowledge' | 'tasks' | 'scheduling' | 'agents' | 'approval' | 'secrets' | 'skills' | 'policy' | 'other' /** OS tool name (remember/ask/task_create…) or, for a governed effect, the capability id. */ primitive: string summary: string /** For governed actions/approvals: how the gate classified it, or the outcome. */ effect?: 'allow' | 'approve' | 'deny' | 'error' + /** The live object this entry opened, if any (a task, KB page, secret, proposal card). */ + target?: { kind: string; id: string } + /** The object's CURRENT status, resolved from its live store — 'doing', 'done', 'pending', 'rev 4'… */ + status?: string + /** How to tint the status chip. */ + statusTone?: 'open' | 'done' | 'blocked' | 'denied' | 'muted' } export interface ActivitySummaryRow { primitive: string