Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions crates/openfang-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9487,25 +9487,28 @@ pub async fn serve_upload(Path(file_id): Path<String>) -> impl IntoResponse {
// Execution Approval System — backed by kernel.approval_manager
// ---------------------------------------------------------------------------

/// GET /api/approvals — List pending approval requests.
/// GET /api/approvals — List pending and recent approval requests.
///
/// Transforms field names to match the dashboard template expectations:
/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`.
pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pending = state.kernel.approval_manager.list_pending();
let total = pending.len();
let recent = state.kernel.approval_manager.list_recent(50);

// Resolve agent names for display
let registry_agents = state.kernel.registry.list();
let agent_name_for = |agent_id: &str| {
registry_agents
.iter()
.find(|ag| ag.id.to_string() == agent_id || ag.name == agent_id)
.map(|ag| ag.name.clone())
.unwrap_or_else(|| agent_id.to_string())
};

let approvals: Vec<serde_json::Value> = pending
let mut approvals: Vec<serde_json::Value> = pending
.into_iter()
.map(|a| {
let agent_name = registry_agents
.iter()
.find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id)
.map(|ag| ag.name.as_str())
.unwrap_or(&a.agent_id);
let agent_name = agent_name_for(&a.agent_id);
serde_json::json!({
"id": a.id,
"agent_id": a.agent_id,
Expand All @@ -9523,6 +9526,42 @@ pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResp
})
.collect();

approvals.extend(recent.into_iter().map(|record| {
let request = record.request;
let agent_name = agent_name_for(&request.agent_id);
let status = match record.decision {
openfang_types::approval::ApprovalDecision::Approved => "approved",
openfang_types::approval::ApprovalDecision::Denied => "rejected",
openfang_types::approval::ApprovalDecision::TimedOut => "expired",
};
serde_json::json!({
"id": request.id,
"agent_id": request.agent_id,
"agent_name": agent_name,
"tool_name": request.tool_name,
"description": request.description,
"action_summary": request.action_summary,
"action": request.action_summary,
"risk_level": request.risk_level,
"requested_at": request.requested_at,
"created_at": request.requested_at,
"timeout_secs": request.timeout_secs,
"status": status,
"decided_at": record.decided_at,
"decided_by": record.decided_by,
})
}));

approvals.sort_by(|a, b| {
let a_pending = a["status"].as_str() == Some("pending");
let b_pending = b["status"].as_str() == Some("pending");
b_pending
.cmp(&a_pending)
.then_with(|| b["created_at"].as_str().cmp(&a["created_at"].as_str()))
});

let total = approvals.len();

Json(serde_json::json!({"approvals": approvals, "total": total}))
}

Expand Down
4 changes: 3 additions & 1 deletion crates/openfang-api/static/index_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ <h1>OPENFANG</h1>
<a class="nav-item" :class="{ active: page === 'approvals' }" @click="navigate('approvals')" :aria-current="page === 'approvals' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
<span class="nav-label">Approvals</span>
<span class="badge badge-warn" x-show="$store.app.pendingApprovalCount > 0" x-text="$store.app.pendingApprovalCount"></span>
</a>
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
Expand Down Expand Up @@ -1276,7 +1277,7 @@ <h3>Create Agent</h3>

<!-- Page: Approvals -->
<template x-if="page === 'approvals'">
<div x-data="approvalsPage" x-init="loadData()">
<div x-data="approvalsPage()" x-init="init()">
<div class="page-header">
<h2>Execution Approvals</h2>
<div class="flex items-center gap-2">
Expand All @@ -1298,6 +1299,7 @@ <h2>Execution Approvals</h2>
<button class="filter-pill" :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'">Pending</button>
<button class="filter-pill" :class="{ active: filterStatus === 'approved' }" @click="filterStatus = 'approved'">Approved</button>
<button class="filter-pill" :class="{ active: filterStatus === 'rejected' }" @click="filterStatus = 'rejected'">Rejected</button>
<button class="filter-pill" :class="{ active: filterStatus === 'expired' }" @click="filterStatus = 'expired'">Expired</button>
</div>
<div x-show="filtered.length === 0" class="empty-state">
<h4>No approvals</h4>
Expand Down
25 changes: 24 additions & 1 deletion crates/openfang-api/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ document.addEventListener('alpine:init', function() {
lastError: '',
version: '0.1.0',
agentCount: 0,
pendingApprovalCount: 0,
lastPendingApprovalSignature: '',
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
Expand All @@ -174,6 +176,23 @@ document.addEventListener('alpine:init', function() {
} catch(e) { /* silent */ }
},

async refreshApprovals() {
try {
var data = await OpenFangAPI.get('/api/approvals');
var approvals = Array.isArray(data) ? data : (data.approvals || []);
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
var signature = pending
.map(function(a) { return a.id; })
.sort()
.join(',');
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
}
this.pendingApprovalCount = pending.length;
this.lastPendingApprovalSignature = signature;
} catch(e) { /* silent */ }
},

async checkStatus() {
try {
var s = await OpenFangAPI.get('/api/status');
Expand Down Expand Up @@ -370,9 +389,13 @@ function app() {

// Initial data load
this.pollStatus();
Alpine.store('app').refreshApprovals();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
setInterval(function() {
self.pollStatus();
Alpine.store('app').refreshApprovals();
}, 5000);
},

navigate(p) {
Expand Down
16 changes: 16 additions & 0 deletions crates/openfang-api/static/js/pages/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ function approvalsPage() {
filterStatus: 'all',
loading: true,
loadError: '',
refreshTimer: null,

init() {
var self = this;
this.loadData();
this.refreshTimer = setInterval(function() {
self.loadData();
}, 5000);
},

destroy() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
},

get filtered() {
var f = this.filterStatus;
Expand Down
64 changes: 63 additions & 1 deletion crates/openfang-kernel/src/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ use dashmap::DashMap;
use openfang_types::approval::{
ApprovalDecision, ApprovalPolicy, ApprovalRequest, ApprovalResponse, RiskLevel,
};
use std::collections::VecDeque;
use tracing::{debug, info, warn};
use uuid::Uuid;

/// Max pending requests per agent.
const MAX_PENDING_PER_AGENT: usize = 5;
/// Max recent approval records to retain for history and UI visibility.
const MAX_RECENT_APPROVALS: usize = 100;

/// Manages approval requests with oneshot channels for blocking resolution.
pub struct ApprovalManager {
pending: DashMap<Uuid, PendingRequest>,
recent: std::sync::Mutex<VecDeque<ApprovalRecord>>,
policy: std::sync::RwLock<ApprovalPolicy>,
}

Expand All @@ -22,10 +26,19 @@ struct PendingRequest {
sender: tokio::sync::oneshot::Sender<ApprovalDecision>,
}

#[derive(Debug, Clone)]
pub struct ApprovalRecord {
pub request: ApprovalRequest,
pub decision: ApprovalDecision,
pub decided_at: chrono::DateTime<Utc>,
pub decided_by: Option<String>,
}

impl ApprovalManager {
pub fn new(policy: ApprovalPolicy) -> Self {
Self {
pending: DashMap::new(),
recent: std::sync::Mutex::new(VecDeque::new()),
policy: std::sync::RwLock::new(policy),
}
}
Expand All @@ -51,6 +64,7 @@ impl ApprovalManager {

let timeout = std::time::Duration::from_secs(req.timeout_secs);
let id = req.id;
let req_for_timeout = req.clone();

let (tx, rx) = tokio::sync::oneshot::channel();
self.pending.insert(
Expand All @@ -69,7 +83,12 @@ impl ApprovalManager {
decision
}
_ => {
self.pending.remove(&id);
let request = self
.pending
.remove(&id)
.map(|(_, pending)| pending.request)
.unwrap_or(req_for_timeout);
self.push_recent(request, ApprovalDecision::TimedOut, None, Utc::now());
warn!(request_id = %id, "Approval request timed out");
ApprovalDecision::TimedOut
}
Expand All @@ -91,6 +110,12 @@ impl ApprovalManager {
decided_at: Utc::now(),
decided_by,
};
self.push_recent(
pending.request.clone(),
decision,
response.decided_by.clone(),
response.decided_at,
);
// Send decision to waiting agent (ignore error if receiver dropped)
let _ = pending.sender.send(decision);
info!(request_id = %request_id, ?decision, "Approval request resolved");
Expand All @@ -108,6 +133,12 @@ impl ApprovalManager {
.collect()
}

/// List recent non-pending approvals, newest first.
pub fn list_recent(&self, limit: usize) -> Vec<ApprovalRecord> {
let recent = self.recent.lock().unwrap_or_else(|e| e.into_inner());
recent.iter().take(limit).cloned().collect()
}

/// Number of pending requests.
pub fn pending_count(&self) -> usize {
self.pending.len()
Expand Down Expand Up @@ -135,6 +166,25 @@ impl ApprovalManager {
_ => RiskLevel::Low,
}
}

fn push_recent(
&self,
request: ApprovalRequest,
decision: ApprovalDecision,
decided_by: Option<String>,
decided_at: chrono::DateTime<Utc>,
) {
let mut recent = self.recent.lock().unwrap_or_else(|e| e.into_inner());
recent.push_front(ApprovalRecord {
request,
decision,
decided_at,
decided_by,
});
while recent.len() > MAX_RECENT_APPROVALS {
recent.pop_back();
}
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -243,6 +293,7 @@ mod tests {
fn test_list_pending_empty() {
let mgr = default_manager();
assert!(mgr.list_pending().is_empty());
assert!(mgr.list_recent(10).is_empty());
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -293,6 +344,10 @@ mod tests {
assert_eq!(decision, ApprovalDecision::TimedOut);
// After timeout, pending map should be cleaned up
assert_eq!(mgr.pending_count(), 0);
let recent = mgr.list_recent(10);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].decision, ApprovalDecision::TimedOut);
assert_eq!(recent[0].request.tool_name, "shell_exec");
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -322,6 +377,10 @@ mod tests {

let decision = mgr.request_approval(req).await;
assert_eq!(decision, ApprovalDecision::Approved);
let recent = mgr.list_recent(10);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].decision, ApprovalDecision::Approved);
assert_eq!(recent[0].decided_by.as_deref(), Some("admin"));
}

// -----------------------------------------------------------------------
Expand All @@ -343,6 +402,9 @@ mod tests {

let decision = mgr.request_approval(req).await;
assert_eq!(decision, ApprovalDecision::Denied);
let recent = mgr.list_recent(10);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].decision, ApprovalDecision::Denied);
}

// -----------------------------------------------------------------------
Expand Down