Skip to content

Commit e14230d

Browse files
authored
feat: Cave oversight dashboard endpoints — routing + audit (#18) (#63)
* feat(config,store): routing view and audit-events query for the Cave dashboard (#18) Config::routing_view resolves an installation's effective familiars, trigger lanes, and per-repo overrides (open / listed / fail-closed) as a serializable view. Store::audit_events assembles a tenant-scoped, newest-first task-lifecycle stream from ignored deliveries, task attempts (execution/retries/timeout), and opened PRs — the data behind the dashboard's routing and audit views. Signed-off-by: Val Alexander <bunsthedev@gmail.com> * feat(webhook,server): Cave oversight dashboard endpoints (#18) Adds the two data endpoints Cave still needed, tenant-scoped and audited like the rest: GET /api/github/routing (effective familiars + trigger lanes + repo overrides per installation) and GET /api/github/audit (task-lifecycle stream — acceptance, execution/retries/timeout, PR creation). With the existing task and usage endpoints, Cave's four oversight views are all backed. README updated. Closes #18. Signed-off-by: Val Alexander <bunsthedev@gmail.com> --------- Signed-off-by: Val Alexander <bunsthedev@gmail.com>
1 parent c02394a commit e14230d

5 files changed

Lines changed: 502 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ duplicate comments.
170170
| Worker container isolation | Implemented | `worker.backend = "container"` runs each attempt in a fresh hardened container (read-only rootfs, cap-drop ALL, cpu/memory/pids/tmpfs/network limits, env-only token injection, kill-by-name on timeout); hosted posture refuses host execution without explicit opt-in — see [docs/container-isolation.md](docs/container-isolation.md). |
171171
| Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. |
172172
| CovenCave task polling | Implemented | Task API served from the durable store, survives restarts, and is gated by the tenant boundary — `token` mode fails closed, tenant tokens are installation-scoped, and every read is audited (see [docs/security.md](docs/security.md)). |
173+
| Cave oversight dashboard | Implemented | Tenant-scoped data behind the four Cave views: task history (`/api/github/tasks`), usage (`/api/github/usage`), familiar routing (`/api/github/routing`), and a task-lifecycle audit stream (`/api/github/audit`) — acceptance, execution/retries/timeout, and PR creation. |
173174
| Durable queue / task store | Implemented | Deliveries deduplicated by `X-GitHub-Delivery` before GitHub hears success; the SQLite `tasks` table is the queue (atomic claims, no drop path) and interrupted work is requeued at startup ([design](docs/durable-task-store.md)). |
174175
| Hosted tier | Planned | See [Hosted vs self-hosted](docs/hosted-vs-self-hosted.md). |
175176
| Familiar trust contract | Planned | See [Familiar Contract](FAMILIAR-CONTRACT.md). |

crates/config/src/lib.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,25 @@ pub struct RepoRoutingOverride {
100100
pub reviews: Option<bool>,
101101
}
102102

103+
/// A serializable installation routing policy for the Cave dashboard (#18).
104+
#[derive(Debug, Clone, Serialize)]
105+
#[serde(rename_all = "camelCase")]
106+
pub struct RoutingView {
107+
pub installation_id: u64,
108+
pub account: Option<String>,
109+
/// Familiar ids this installation may route to (resolved allow-list, or all
110+
/// configured familiars).
111+
pub familiars: Vec<String>,
112+
/// Installation-level trigger lanes.
113+
pub triggers: TriggerPolicy,
114+
/// Per-repo trigger overrides.
115+
pub repos: std::collections::HashMap<String, RepoRoutingOverride>,
116+
/// `true` when the installation is explicitly configured; `false` means
117+
/// open routing (nothing configured) or fail-closed (unlisted) — read
118+
/// `familiars` to tell which.
119+
pub listed: bool,
120+
}
121+
103122
/// The effective routing view for one delivery: which familiars are visible
104123
/// and which trigger lanes are open (issue #7).
105124
pub struct RoutingScope<'a> {
@@ -542,6 +561,60 @@ impl Config {
542561
RoutingScope { familiars, triggers }
543562
}
544563

564+
/// The routing policy view for one installation, for the Cave dashboard
565+
/// (issue #18). Resolves familiars and triggers the same way [`scope_for`]
566+
/// does, but at the installation level (not one delivery), and includes the
567+
/// per-repo overrides so the dashboard can render them.
568+
///
569+
/// [`scope_for`]: Config::scope_for
570+
pub fn routing_view(&self, installation_id: u64) -> RoutingView {
571+
let all_familiars = || self.familiars.iter().map(|f| f.id.clone()).collect::<Vec<_>>();
572+
// Open routing (no installations configured): every familiar, all lanes.
573+
if self.installations.is_empty() {
574+
return RoutingView {
575+
installation_id,
576+
account: None,
577+
familiars: all_familiars(),
578+
triggers: TriggerPolicy::default(),
579+
repos: std::collections::HashMap::new(),
580+
listed: false,
581+
};
582+
}
583+
// Configured but unlisted: fail closed — routes nothing.
584+
let Some(installation) = self.installations.iter().find(|i| i.id == installation_id) else {
585+
return RoutingView {
586+
installation_id,
587+
account: None,
588+
familiars: Vec::new(),
589+
triggers: TriggerPolicy {
590+
assignment: false,
591+
labels: false,
592+
commands: false,
593+
reviews: false,
594+
},
595+
repos: std::collections::HashMap::new(),
596+
listed: false,
597+
};
598+
};
599+
let familiars = if installation.familiars.is_empty() {
600+
all_familiars()
601+
} else {
602+
self.familiars
603+
.iter()
604+
.map(|f| f.id.clone())
605+
.filter(|id| installation.familiars.contains(id))
606+
.collect()
607+
};
608+
RoutingView {
609+
installation_id,
610+
account: installation.account.clone(),
611+
familiars,
612+
triggers: installation.triggers,
613+
repos: installation.repos.clone(),
614+
listed: true,
615+
}
616+
}
617+
545618
/// Usage limits for one installation (issue #15). Unlimited when the
546619
/// installation is not configured.
547620
pub fn limits_for(&self, installation_id: u64) -> InstallationLimits {
@@ -1174,6 +1247,75 @@ mod tests {
11741247
.collect()
11751248
}
11761249

1250+
fn routing_config(familiars: Vec<FamiliarConfig>) -> Config {
1251+
let dir = tmpdir();
1252+
config_with(
1253+
GitHubAppConfig {
1254+
app_id: 1,
1255+
private_key_path: write_pem(&dir),
1256+
webhook_secret: "a-long-random-webhook-secret".into(),
1257+
api_base_url: None,
1258+
},
1259+
WorkerConfig {
1260+
concurrency: 1,
1261+
coven_code_bin: write_bin(&dir),
1262+
workspace_root: dir.clone(),
1263+
timeout_secs: 600,
1264+
max_retries: 2,
1265+
backend: WorkerBackendKind::Host,
1266+
container: ContainerConfig::default(),
1267+
allow_host_backend: true,
1268+
},
1269+
familiars,
1270+
)
1271+
}
1272+
1273+
fn fam(id: &str) -> FamiliarConfig {
1274+
FamiliarConfig {
1275+
id: id.into(),
1276+
display_name: id.into(),
1277+
bot_username: format!("coven-{id}[bot]"),
1278+
model: None,
1279+
skills: vec![],
1280+
trigger_labels: vec![],
1281+
}
1282+
}
1283+
1284+
#[test]
1285+
fn routing_view_reflects_open_listed_and_fail_closed() {
1286+
let mut cfg = routing_config(vec![fam("cody"), fam("nova")]);
1287+
1288+
// Open routing (no installations): every familiar, all lanes, unlisted.
1289+
let open = cfg.routing_view(123);
1290+
assert_eq!(open.familiars, vec!["cody", "nova"]);
1291+
assert!(open.triggers.reviews);
1292+
assert!(!open.listed);
1293+
1294+
// Listed with an allow-list and a disabled lane.
1295+
cfg.installations = vec![InstallationConfig {
1296+
id: 123,
1297+
account: Some("acme".into()),
1298+
familiars: vec!["cody".into()],
1299+
triggers: TriggerPolicy {
1300+
reviews: false,
1301+
..Default::default()
1302+
},
1303+
limits: InstallationLimits::default(),
1304+
repos: std::collections::HashMap::new(),
1305+
}];
1306+
let listed = cfg.routing_view(123);
1307+
assert_eq!(listed.familiars, vec!["cody"]);
1308+
assert_eq!(listed.account.as_deref(), Some("acme"));
1309+
assert!(!listed.triggers.reviews);
1310+
assert!(listed.listed);
1311+
1312+
// Configured but unlisted → fail closed.
1313+
let closed = cfg.routing_view(999);
1314+
assert!(closed.familiars.is_empty());
1315+
assert!(!closed.triggers.assignment);
1316+
assert!(!closed.listed);
1317+
}
1318+
11771319
#[test]
11781320
fn review_policy_resolves_repo_overrides() {
11791321
let policy = ReviewConfig {

crates/server/src/main.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter;
1212

1313
use coven_github_config::Config;
1414
use coven_github_webhook::routes::{
15-
handle_webhook, list_memory, list_tasks, revoke_memory, usage, AppState,
15+
audit, handle_webhook, list_memory, list_tasks, revoke_memory, routing, usage, AppState,
1616
};
1717
use coven_github_worker as worker;
1818

@@ -165,6 +165,8 @@ async fn main() -> Result<()> {
165165
.route("/api/github/memory", get(list_memory))
166166
.route("/api/github/memory/revoke", post(revoke_memory))
167167
.route("/api/github/usage", get(usage))
168+
.route("/api/github/audit", get(audit))
169+
.route("/api/github/routing", get(routing))
168170
.with_state(state)
169171
.layer(TraceLayer::new_for_http());
170172

crates/store/src/lib.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,19 @@ pub struct UsageRow {
480480
pub runtime_secs: u64,
481481
}
482482

483+
/// One task-lifecycle audit event for the Cave dashboard (issue #18).
484+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
485+
#[serde(rename_all = "camelCase")]
486+
pub struct AuditEvent {
487+
/// RFC 3339 timestamp.
488+
pub at: String,
489+
pub task_id: Option<String>,
490+
pub repo: Option<String>,
491+
/// `ignored:<reason>` | `attempt:<outcome>` | `pr_opened`.
492+
pub kind: String,
493+
pub detail: Option<String>,
494+
}
495+
483496
impl Store {
484497
/// Appends a task-API read to the audit trail (issue #3).
485498
pub async fn record_api_read(
@@ -509,6 +522,92 @@ impl Store {
509522
.expect("store task panicked")
510523
}
511524

525+
/// Assembles a task-lifecycle audit stream for the Cave dashboard (issue
526+
/// #18), scoped to the caller's installation and capped at `limit`, newest
527+
/// first. Draws from three durable sources: ignored deliveries (acceptance
528+
/// rejections), task attempts (execution / retries / timeout / failure),
529+
/// and opened PRs (publication).
530+
pub async fn audit_events(&self, scope: Option<ApiScope>, limit: u32) -> Result<Vec<AuditEvent>> {
531+
let conn = self.conn.clone();
532+
tokio::task::spawn_blocking(move || {
533+
let conn = conn.lock().expect("store mutex poisoned");
534+
let inst = scope.as_ref().map(|s| s.installation_id);
535+
let mut events: Vec<AuditEvent> = Vec::new();
536+
537+
// Ignored deliveries — the adapter received but did not act.
538+
let mut stmt = conn.prepare(
539+
"SELECT received_at, repo, routing FROM webhook_deliveries
540+
WHERE routing LIKE 'ignored:%' AND (?1 IS NULL OR installation_id = ?1)",
541+
)?;
542+
for row in stmt.query_map(params![inst], |r| {
543+
Ok(AuditEvent {
544+
at: r.get(0)?,
545+
task_id: None,
546+
repo: r.get::<_, Option<String>>(1)?,
547+
kind: r.get::<_, String>(2)?,
548+
detail: None,
549+
})
550+
})? {
551+
events.push(row?);
552+
}
553+
554+
// Task attempts — one per execution, carrying retries and outcomes.
555+
let mut stmt = conn.prepare(
556+
"SELECT COALESCE(a.ended_at, a.started_at), a.task_id, t.repo, a.attempt,
557+
a.outcome, a.detail
558+
FROM task_attempts a JOIN tasks t ON a.task_id = t.id
559+
WHERE (?1 IS NULL OR t.installation_id = ?1)",
560+
)?;
561+
for row in stmt.query_map(params![inst], |r| {
562+
let attempt: i64 = r.get(3)?;
563+
let outcome: Option<String> = r.get(4)?;
564+
let detail: Option<String> = r.get(5)?;
565+
Ok(AuditEvent {
566+
at: r.get(0)?,
567+
task_id: Some(r.get::<_, String>(1)?),
568+
repo: Some(r.get::<_, String>(2)?),
569+
kind: format!("attempt:{}", outcome.as_deref().unwrap_or("running")),
570+
detail: detail.map(|d| format!("attempt {attempt}: {d}")),
571+
})
572+
})? {
573+
events.push(row?);
574+
}
575+
576+
// Opened pull requests — the publication events.
577+
let mut stmt = conn.prepare(
578+
"SELECT updated_at, id, repo, pr_number FROM tasks
579+
WHERE pr_number IS NOT NULL AND (?1 IS NULL OR installation_id = ?1)",
580+
)?;
581+
for row in stmt.query_map(params![inst], |r| {
582+
Ok(AuditEvent {
583+
at: r.get(0)?,
584+
task_id: Some(r.get::<_, String>(1)?),
585+
repo: Some(r.get::<_, String>(2)?),
586+
kind: "pr_opened".to_string(),
587+
detail: Some(format!("PR #{}", r.get::<_, u64>(3)?)),
588+
})
589+
})? {
590+
events.push(row?);
591+
}
592+
593+
// Newest first, honor an optional repo narrowing, then cap.
594+
events.sort_by(|a, b| b.at.cmp(&a.at));
595+
let repos = scope.as_ref().and_then(|s| s.repos.as_ref());
596+
let filtered = events
597+
.into_iter()
598+
.filter(|e| match (repos, &e.repo) {
599+
(Some(allowed), Some(repo)) => allowed.contains(repo),
600+
(Some(_), None) => false,
601+
(None, _) => true,
602+
})
603+
.take(limit as usize)
604+
.collect();
605+
Ok(filtered)
606+
})
607+
.await
608+
.expect("store task panicked")
609+
}
610+
512611
/// `(caller, scope, action, result)` audit rows, oldest first.
513612
pub async fn api_audit_entries(&self) -> Result<Vec<(String, String, String, String)>> {
514613
let conn = self.conn.clone();
@@ -1613,6 +1712,49 @@ mod queue_tests {
16131712
assert!(text.iter().any(|t| t.starts_with("task:")));
16141713
}
16151714

1715+
#[tokio::test]
1716+
async fn audit_events_assemble_lifecycle_scoped_to_installation() {
1717+
let store = Store::open_in_memory().expect("open");
1718+
// A finished task that opened a PR (attempt + pr_opened events).
1719+
enqueue(&store, "d1", &fix_task("t1")).await;
1720+
store.claim_next(&Default::default()).await.unwrap();
1721+
store
1722+
.finish(
1723+
"t1",
1724+
Terminal {
1725+
state: TerminalState::Completed,
1726+
result_status: Some("success".to_string()),
1727+
branch: Some("cody/fix".to_string()),
1728+
pr_number: Some(9),
1729+
summary: Some("done".to_string()),
1730+
detail: Some("attempt ok".to_string()),
1731+
},
1732+
)
1733+
.await
1734+
.expect("finish");
1735+
// An ignored delivery (acceptance rejection).
1736+
store
1737+
.record_delivery(delivery("d-ig"), Routing::Ignored("unsupported"))
1738+
.await
1739+
.unwrap();
1740+
1741+
let events = store
1742+
.audit_events(Some(ApiScope { installation_id: 1, repos: None }), 100)
1743+
.await
1744+
.unwrap();
1745+
let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1746+
assert!(kinds.contains(&"pr_opened"), "kinds: {kinds:?}");
1747+
assert!(kinds.iter().any(|k| k.starts_with("attempt:")), "kinds: {kinds:?}");
1748+
assert!(kinds.contains(&"ignored:unsupported"), "kinds: {kinds:?}");
1749+
1750+
// A different installation sees none of it.
1751+
let other = store
1752+
.audit_events(Some(ApiScope { installation_id: 999, repos: None }), 100)
1753+
.await
1754+
.unwrap();
1755+
assert!(other.is_empty());
1756+
}
1757+
16161758
#[tokio::test]
16171759
async fn finish_reaches_terminal_state_and_closes_the_attempt() {
16181760
let store = Store::open_in_memory().expect("open");

0 commit comments

Comments
 (0)