feat: proactive agent approval bell with approve/deny UI (#370)

This commit is contained in:
Tanvir Bhathal
2026-05-22 17:38:48 -07:00
committed by GitHub
parent fecbc51767
commit c84f1fddee
6 changed files with 740 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Bell, CheckCircle, ChevronDown, ChevronUp, Clock, XCircle } from 'lucide-react';
import { approveAction, denyAction, fetchPendingApprovals } from '../lib/api';
import type { PendingApproval } from '../lib/api';
const TIER_STYLES: Record<string, { label: string; color: string; bg: string }> = {
trivial: { label: 'Trivial', color: 'var(--color-text-secondary)', bg: 'color-mix(in srgb, var(--color-text-secondary) 10%, transparent)' },
low: { label: 'Low', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' },
medium: { label: 'Medium', color: 'var(--color-warning)', bg: 'color-mix(in srgb, var(--color-warning) 12%, transparent)' },
high: { label: 'High', color: 'var(--color-error)', bg: 'color-mix(in srgb, var(--color-error) 12%, transparent)' },
};
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function ApprovalBell() {
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [processing, setProcessing] = useState<Record<string, boolean>>({});
const containerRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
try {
setApprovals(await fetchPendingApprovals());
} catch {
// backend may not be running yet
}
}, []);
useEffect(() => {
load();
const id = setInterval(load, 10000);
return () => clearInterval(id);
}, [load]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleApprove = async (id: string) => {
setProcessing(p => ({ ...p, [id]: true }));
try {
await approveAction(id);
setApprovals(prev => prev.filter(a => a.id !== id));
} finally {
setProcessing(p => ({ ...p, [id]: false }));
}
};
const handleDeny = async (id: string) => {
setProcessing(p => ({ ...p, [id]: true }));
try {
await denyAction(id);
setApprovals(prev => prev.filter(a => a.id !== id));
} finally {
setProcessing(p => ({ ...p, [id]: false }));
}
};
const count = approvals.length;
return (
<div ref={containerRef} className="fixed top-2 right-3 z-40">
{/* Bell trigger */}
<button
onClick={() => setOpen(o => !o)}
className="relative p-2 rounded-lg transition-colors cursor-pointer"
title="Agent approvals"
style={{
color: count > 0 ? 'var(--color-text)' : 'var(--color-text-secondary)',
background: open
? 'var(--color-bg-tertiary)'
: count > 0
? 'color-mix(in srgb, var(--color-error) 8%, transparent)'
: 'transparent',
}}
>
<Bell size={17} />
{count > 0 && (
<span
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 flex items-center justify-center rounded-full text-[10px] font-bold px-1 leading-none"
style={{ background: 'var(--color-error)', color: '#fff' }}
>
{count > 99 ? '99+' : count}
</span>
)}
</button>
{/* Dropdown */}
{open && (
<div
className="absolute right-0 top-full mt-1 rounded-xl shadow-2xl overflow-hidden flex flex-col"
style={{
width: '340px',
maxHeight: '500px',
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2">
<Bell size={13} style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
Agent Approvals
</span>
</div>
{count > 0 && (
<span
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
style={{
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
color: 'var(--color-error)',
}}
>
{count} pending
</span>
)}
</div>
{/* Body */}
<div className="overflow-y-auto flex-1">
{count === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<CheckCircle size={26} style={{ color: 'var(--color-text-secondary)', opacity: 0.35 }} />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
No pending approvals
</span>
</div>
) : (
approvals.map((action, idx) => {
const tier = TIER_STYLES[action.tier] ?? TIER_STYLES.medium;
const isExpanded = !!expanded[action.id];
const isLoading = !!processing[action.id];
const hasPayload = Object.keys(action.payload ?? {}).length > 0;
return (
<div
key={action.id}
className="px-4 py-3"
style={{
borderBottom: idx < count - 1 ? '1px solid var(--color-border)' : 'none',
}}
>
{/* Row 1: action type + tier + time */}
<div className="flex items-center justify-between mb-1.5">
<span
className="text-[11px] font-mono font-semibold"
style={{ color: 'var(--color-accent)' }}
>
{action.action_type}
</span>
<div className="flex items-center gap-2">
<span
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded"
style={{ background: tier.bg, color: tier.color }}
>
{tier.label}
</span>
<span
className="text-[10px] flex items-center gap-0.5"
style={{ color: 'var(--color-text-secondary)' }}
>
<Clock size={9} />
{timeAgo(action.created_at)}
</span>
</div>
</div>
{/* Description */}
<p
className="text-[13px] mb-2.5 leading-snug"
style={{ color: 'var(--color-text)' }}
>
{action.description}
</p>
{/* Expandable payload */}
{hasPayload && (
<button
className="flex items-center gap-1 text-[11px] mb-2 cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
onClick={() =>
setExpanded(e => ({ ...e, [action.id]: !e[action.id] }))
}
>
{isExpanded ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
{isExpanded ? 'Hide details' : 'View details'}
</button>
)}
{isExpanded && (
<pre
className="text-[10px] rounded-lg p-2.5 mb-2.5 overflow-x-auto"
style={{
background: 'var(--color-bg-tertiary)',
color: 'var(--color-text-secondary)',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
lineHeight: '1.5',
}}
>
{JSON.stringify(action.payload, null, 2)}
</pre>
)}
{/* Approve / Deny */}
<div className="flex gap-2">
<button
onClick={() => handleApprove(action.id)}
disabled={isLoading}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
style={{
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
border: '1px solid color-mix(in srgb, var(--color-success) 22%, transparent)',
}}
>
<CheckCircle size={12} />
Approve
</button>
<button
onClick={() => handleDeny(action.id)}
disabled={isLoading}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
style={{
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
color: 'var(--color-error)',
border: '1px solid color-mix(in srgb, var(--color-error) 22%, transparent)',
}}
>
<XCircle size={12} />
Deny
</button>
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}
+2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Outlet, useNavigate } from 'react-router';
import { ApprovalBell } from './ApprovalBell';
import { Sidebar } from './Sidebar/Sidebar';
import { SystemPulse } from './SystemPulse';
import { useAppStore } from '../lib/store';
@@ -27,6 +28,7 @@ export function Layout() {
<div className="flex flex-col h-full w-full overflow-hidden relative" style={{ paddingTop: '3px' }}>
<div className="hud-backdrop" aria-hidden="true" />
<SystemPulse apiReachable={apiReachable} />
<ApprovalBell />
{/* Health check banner */}
{apiReachable === false && (
+33
View File
@@ -931,3 +931,36 @@ export async function getMemoryConfig(): Promise<MemoryConfig> {
if (!res.ok) throw new Error('Failed to fetch memory config');
return res.json();
}
// ---------------------------------------------------------------------------
// Approvals
// ---------------------------------------------------------------------------
export interface PendingApproval {
id: string;
action_type: string;
description: string;
payload: Record<string, unknown>;
permission_key: string;
tier: 'trivial' | 'low' | 'medium' | 'high';
status: string;
created_at: string;
expires_at: string;
}
export async function fetchPendingApprovals(): Promise<PendingApproval[]> {
const res = await fetch(`${getBase()}/v1/approvals/pending`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.actions || [];
}
export async function approveAction(actionId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/approve`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function denyAction(actionId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/deny`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
+5
View File
@@ -883,6 +883,11 @@ async def start_optimize_run(req: OptimizeRunRequest, request: Request):
def include_all_routes(app) -> None:
"""Include all extended API routers in a FastAPI app."""
from openjarvis.server.approval_routes import (
router as approval_router, # noqa: PLC0415
)
app.include_router(approval_router)
app.include_router(agents_router)
app.include_router(memory_router)
app.include_router(traces_router)
+79
View File
@@ -0,0 +1,79 @@
"""REST endpoints for the proactive-agent approval queue."""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from openjarvis.tools.approval_store import (
STATUS_APPROVED,
STATUS_DENIED,
ApprovalStore,
PendingAction,
)
try:
from fastapi import APIRouter, HTTPException
except ImportError:
raise ImportError("fastapi is required for approval routes")
logger = logging.getLogger(__name__)
router = APIRouter()
# Singleton that shares the same DB file as ProactiveAgent (WAL mode is safe)
_store: Optional[ApprovalStore] = None
def _get_store() -> ApprovalStore:
global _store
if _store is None:
_store = ApprovalStore()
return _store
def _serialize(action: PendingAction) -> Dict[str, Any]:
return {
"id": action.id,
"action_type": action.action_type,
"description": action.description,
"payload": action.payload,
"permission_key": action.permission_key,
"tier": action.tier,
"status": action.status,
"created_at": action.created_at,
"expires_at": action.expires_at,
}
@router.get("/v1/approvals/pending")
async def list_pending_approvals() -> Dict[str, Any]:
store = _get_store()
store.expire_stale()
actions = store.list_pending()
return {"actions": [_serialize(a) for a in actions], "count": len(actions)}
@router.post("/v1/approvals/{action_id}/approve")
async def approve_action(action_id: str) -> Dict[str, Any]:
store = _get_store()
action = store.get_action(action_id)
if action is None:
raise HTTPException(status_code=404, detail="Action not found")
store.update_status(action_id, STATUS_APPROVED)
logger.info("Action %s approved via UI", action_id)
return {"status": "approved", "id": action_id}
@router.post("/v1/approvals/{action_id}/deny")
async def deny_action(action_id: str) -> Dict[str, Any]:
store = _get_store()
action = store.get_action(action_id)
if action is None:
raise HTTPException(status_code=404, detail="Action not found")
store.update_status(action_id, STATUS_DENIED)
logger.info("Action %s denied via UI", action_id)
return {"status": "denied", "id": action_id}
__all__ = ["router"]
+357
View File
@@ -0,0 +1,357 @@
"""Tests for the proactive-agent approval queue API routes."""
from __future__ import annotations
import pytest
from openjarvis.tools.approval_store import (
STATUS_APPROVED,
STATUS_DENIED,
STATUS_PENDING,
TIER_HIGH,
TIER_LOW,
TIER_MEDIUM,
ApprovalStore,
)
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
HAS_FASTAPI = True
except ImportError:
HAS_FASTAPI = False
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def approval_store(tmp_path):
"""A fresh ApprovalStore backed by a temp DB, injected into the routes module."""
import openjarvis.server.approval_routes as ar
store = ApprovalStore(db_path=str(tmp_path / "approvals.db"))
original = ar._store
ar._store = store
yield store
ar._store = original
store.close()
@pytest.fixture
def client(approval_store): # noqa: ARG001 — triggers store injection as a side-effect
"""TestClient with the approval router mounted on a minimal FastAPI app."""
from openjarvis.server.approval_routes import router
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _queue(store: ApprovalStore, **kwargs) -> str:
"""Helper — queue an action and return its id."""
defaults = dict(
action_type="file_write",
description="Write a report to ~/Desktop/report.txt",
payload={"path": "~/Desktop/report.txt", "size_kb": 12},
permission_key="file_write:path:~/Desktop",
tier=TIER_MEDIUM,
)
defaults.update(kwargs)
action = store.queue_action(**defaults)
return action.id
def _expire(store: ApprovalStore, action_id: str) -> None:
"""Force an action's expires_at into the past so list_pending skips it."""
store._conn.execute(
"UPDATE pending_actions SET expires_at = ? WHERE id = ?",
("2000-01-01T00:00:00+00:00", action_id),
)
store._conn.commit()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
class TestListPendingApprovals:
def test_empty_queue(self, client):
resp = client.get("/v1/approvals/pending")
assert resp.status_code == 200
body = resp.json()
assert body["actions"] == []
assert body["count"] == 0
def test_returns_queued_items(self, client, approval_store):
_queue(approval_store)
resp = client.get("/v1/approvals/pending")
assert resp.status_code == 200
body = resp.json()
assert body["count"] == 1
assert len(body["actions"]) == 1
def test_returns_multiple_items(self, client, approval_store):
_queue(approval_store, action_type="email_send", description="Send digest")
_queue(approval_store, action_type="file_delete", description="Delete temps")
resp = client.get("/v1/approvals/pending")
assert resp.json()["count"] == 2
def test_response_shape(self, client, approval_store):
_queue(
approval_store,
action_type="sms_send",
description="Reply to Alice",
payload={"to": "+15550001234", "body": "On my way"},
tier=TIER_HIGH,
)
resp = client.get("/v1/approvals/pending")
action = resp.json()["actions"][0]
required_fields = {
"id",
"action_type",
"description",
"payload",
"permission_key",
"tier",
"status",
"created_at",
"expires_at",
}
assert required_fields.issubset(action.keys()), (
f"Missing fields: {required_fields - action.keys()}"
)
def test_payload_is_dict_not_string(self, client, approval_store):
"""The route must deserialize payload — not return the raw JSON string."""
_queue(approval_store, payload={"key": "value", "nested": {"x": 1}})
action = client.get("/v1/approvals/pending").json()["actions"][0]
assert isinstance(action["payload"], dict), (
f"Expected dict, got {type(action['payload'])}"
)
assert action["payload"]["key"] == "value"
def test_correct_field_values(self, client, approval_store):
_queue(
approval_store,
action_type="calendar_create",
description="Create meeting event",
tier=TIER_LOW,
)
action = client.get("/v1/approvals/pending").json()["actions"][0]
assert action["action_type"] == "calendar_create"
assert action["description"] == "Create meeting event"
assert action["tier"] == TIER_LOW
assert action["status"] == STATUS_PENDING
def test_does_not_return_approved_actions(self, client, approval_store):
action_id = _queue(approval_store)
approval_store.update_status(action_id, STATUS_APPROVED)
resp = client.get("/v1/approvals/pending")
assert resp.json()["count"] == 0
def test_does_not_return_denied_actions(self, client, approval_store):
action_id = _queue(approval_store)
approval_store.update_status(action_id, STATUS_DENIED)
resp = client.get("/v1/approvals/pending")
assert resp.json()["count"] == 0
def test_auto_expires_stale_actions(self, client, approval_store):
"""GET /pending must expire past-TTL items and exclude them."""
action_id = _queue(approval_store)
_expire(approval_store, action_id)
resp = client.get("/v1/approvals/pending")
assert resp.json()["count"] == 0
def test_stale_expired_mixes_with_live(self, client, approval_store):
"""Only expired items are excluded; live items still appear."""
stale_id = _queue(approval_store, description="Stale action")
_queue(approval_store, description="Live action")
_expire(approval_store, stale_id)
resp = client.get("/v1/approvals/pending")
body = resp.json()
assert body["count"] == 1
assert body["actions"][0]["description"] == "Live action"
def test_timestamps_are_strings(self, client, approval_store):
_queue(approval_store)
action = client.get("/v1/approvals/pending").json()["actions"][0]
assert isinstance(action["created_at"], str)
assert isinstance(action["expires_at"], str)
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
class TestApproveAction:
def test_approve_returns_200(self, client, approval_store):
action_id = _queue(approval_store)
resp = client.post(f"/v1/approvals/{action_id}/approve")
assert resp.status_code == 200
def test_approve_response_body(self, client, approval_store):
action_id = _queue(approval_store)
body = client.post(f"/v1/approvals/{action_id}/approve").json()
assert body["status"] == "approved"
assert body["id"] == action_id
def test_approve_updates_db_status(self, client, approval_store):
action_id = _queue(approval_store)
client.post(f"/v1/approvals/{action_id}/approve")
action = approval_store.get_action(action_id)
assert action is not None
assert action.status == STATUS_APPROVED
def test_approve_removes_from_pending_list(self, client, approval_store):
action_id = _queue(approval_store)
client.post(f"/v1/approvals/{action_id}/approve")
resp = client.get("/v1/approvals/pending")
ids = [a["id"] for a in resp.json()["actions"]]
assert action_id not in ids
def test_approve_nonexistent_returns_404(self, client):
resp = client.post("/v1/approvals/doesnotexist/approve")
assert resp.status_code == 404
def test_approve_only_removes_target(self, client, approval_store):
"""Approving one action must leave others untouched."""
id_a = _queue(approval_store, description="Action A")
id_b = _queue(approval_store, description="Action B")
client.post(f"/v1/approvals/{id_a}/approve")
resp = client.get("/v1/approvals/pending")
body = resp.json()
assert body["count"] == 1
assert body["actions"][0]["id"] == id_b
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
class TestDenyAction:
def test_deny_returns_200(self, client, approval_store):
action_id = _queue(approval_store)
resp = client.post(f"/v1/approvals/{action_id}/deny")
assert resp.status_code == 200
def test_deny_response_body(self, client, approval_store):
action_id = _queue(approval_store)
body = client.post(f"/v1/approvals/{action_id}/deny").json()
assert body["status"] == "denied"
assert body["id"] == action_id
def test_deny_updates_db_status(self, client, approval_store):
action_id = _queue(approval_store)
client.post(f"/v1/approvals/{action_id}/deny")
action = approval_store.get_action(action_id)
assert action is not None
assert action.status == STATUS_DENIED
def test_deny_removes_from_pending_list(self, client, approval_store):
action_id = _queue(approval_store)
client.post(f"/v1/approvals/{action_id}/deny")
resp = client.get("/v1/approvals/pending")
ids = [a["id"] for a in resp.json()["actions"]]
assert action_id not in ids
def test_deny_nonexistent_returns_404(self, client):
resp = client.post("/v1/approvals/doesnotexist/deny")
assert resp.status_code == 404
def test_deny_only_removes_target(self, client, approval_store):
id_a = _queue(approval_store, description="Action A")
id_b = _queue(approval_store, description="Action B")
client.post(f"/v1/approvals/{id_a}/deny")
resp = client.get("/v1/approvals/pending")
body = resp.json()
assert body["count"] == 1
assert body["actions"][0]["id"] == id_b
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
class TestApprovalStoreIntegration:
"""End-to-end scenarios: queue → inspect → decide."""
def test_full_approve_flow(self, client, approval_store):
"""Queue an action, verify it appears, approve it, verify it's gone."""
action_id = _queue(
approval_store,
action_type="email_delete",
description="Delete promotional email from newsletters@company.com",
payload={"message_id": "abc123", "subject": "Weekly promo"},
tier=TIER_HIGH,
)
# Should appear in pending
pending = client.get("/v1/approvals/pending").json()
assert pending["count"] == 1
assert pending["actions"][0]["id"] == action_id
# Approve it
resp = client.post(f"/v1/approvals/{action_id}/approve")
assert resp.status_code == 200
# Should be gone from pending
assert client.get("/v1/approvals/pending").json()["count"] == 0
# Status in DB should be approved
assert approval_store.get_action(action_id).status == STATUS_APPROVED
def test_full_deny_flow(self, client, approval_store):
action_id = _queue(
approval_store,
action_type="file_delete",
description="Delete ~/Documents/report.pdf",
tier=TIER_HIGH,
)
assert client.get("/v1/approvals/pending").json()["count"] == 1
resp = client.post(f"/v1/approvals/{action_id}/deny")
assert resp.status_code == 200
assert client.get("/v1/approvals/pending").json()["count"] == 0
assert approval_store.get_action(action_id).status == STATUS_DENIED
def test_mixed_queue_approve_one_deny_one(self, client, approval_store):
id_a = _queue(approval_store, description="Send email")
id_b = _queue(approval_store, description="Delete file")
id_c = _queue(approval_store, description="Create calendar event")
assert client.get("/v1/approvals/pending").json()["count"] == 3
client.post(f"/v1/approvals/{id_a}/approve")
client.post(f"/v1/approvals/{id_b}/deny")
pending = client.get("/v1/approvals/pending").json()
assert pending["count"] == 1
assert pending["actions"][0]["id"] == id_c
assert approval_store.get_action(id_a).status == STATUS_APPROVED
assert approval_store.get_action(id_b).status == STATUS_DENIED
assert approval_store.get_action(id_c).status == STATUS_PENDING
def test_empty_payload_handled(self, client, approval_store):
"""Actions with empty payloads should serialize cleanly."""
_queue(approval_store, payload={})
action = client.get("/v1/approvals/pending").json()["actions"][0]
assert isinstance(action["payload"], dict)
assert action["payload"] == {}
def test_nested_payload_preserved(self, client, approval_store):
payload = {
"email": {"to": "user@example.com", "subject": "Hello"},
"metadata": {"priority": 1, "tags": ["newsletter", "promo"]},
}
_queue(approval_store, payload=payload)
action = client.get("/v1/approvals/pending").json()["actions"][0]
assert action["payload"]["email"]["to"] == "user@example.com"
assert action["payload"]["metadata"]["tags"] == ["newsletter", "promo"]
def test_all_tiers_accepted(self, client, approval_store):
for tier in ["trivial", "low", "medium", "high"]:
_queue(approval_store, tier=tier, description=f"{tier} action")
resp = client.get("/v1/approvals/pending")
tiers = {a["tier"] for a in resp.json()["actions"]}
assert tiers == {"trivial", "low", "medium", "high"}