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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ duplicate comments.
| 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). |
| Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. |
| 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)). |
| 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. |
| 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)). |
| Hosted tier | Planned | See [Hosted vs self-hosted](docs/hosted-vs-self-hosted.md). |
| Familiar trust contract | Planned | See [Familiar Contract](FAMILIAR-CONTRACT.md). |
Expand Down
142 changes: 142 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ pub struct RepoRoutingOverride {
pub reviews: Option<bool>,
}

/// A serializable installation routing policy for the Cave dashboard (#18).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RoutingView {
pub installation_id: u64,
pub account: Option<String>,
/// Familiar ids this installation may route to (resolved allow-list, or all
/// configured familiars).
pub familiars: Vec<String>,
/// Installation-level trigger lanes.
pub triggers: TriggerPolicy,
/// Per-repo trigger overrides.
pub repos: std::collections::HashMap<String, RepoRoutingOverride>,
/// `true` when the installation is explicitly configured; `false` means
/// open routing (nothing configured) or fail-closed (unlisted) — read
/// `familiars` to tell which.
pub listed: bool,
}

/// The effective routing view for one delivery: which familiars are visible
/// and which trigger lanes are open (issue #7).
pub struct RoutingScope<'a> {
Expand Down Expand Up @@ -542,6 +561,60 @@ impl Config {
RoutingScope { familiars, triggers }
}

/// The routing policy view for one installation, for the Cave dashboard
/// (issue #18). Resolves familiars and triggers the same way [`scope_for`]
/// does, but at the installation level (not one delivery), and includes the
/// per-repo overrides so the dashboard can render them.
///
/// [`scope_for`]: Config::scope_for
pub fn routing_view(&self, installation_id: u64) -> RoutingView {
let all_familiars = || self.familiars.iter().map(|f| f.id.clone()).collect::<Vec<_>>();
// Open routing (no installations configured): every familiar, all lanes.
if self.installations.is_empty() {
return RoutingView {
installation_id,
account: None,
familiars: all_familiars(),
triggers: TriggerPolicy::default(),
repos: std::collections::HashMap::new(),
listed: false,
};
}
// Configured but unlisted: fail closed — routes nothing.
let Some(installation) = self.installations.iter().find(|i| i.id == installation_id) else {
return RoutingView {
installation_id,
account: None,
familiars: Vec::new(),
triggers: TriggerPolicy {
assignment: false,
labels: false,
commands: false,
reviews: false,
},
repos: std::collections::HashMap::new(),
listed: false,
};
};
let familiars = if installation.familiars.is_empty() {
all_familiars()
} else {
self.familiars
.iter()
.map(|f| f.id.clone())
.filter(|id| installation.familiars.contains(id))
.collect()
};
RoutingView {
installation_id,
account: installation.account.clone(),
familiars,
triggers: installation.triggers,
repos: installation.repos.clone(),
listed: true,
}
}

/// Usage limits for one installation (issue #15). Unlimited when the
/// installation is not configured.
pub fn limits_for(&self, installation_id: u64) -> InstallationLimits {
Expand Down Expand Up @@ -1174,6 +1247,75 @@ mod tests {
.collect()
}

fn routing_config(familiars: Vec<FamiliarConfig>) -> Config {
let dir = tmpdir();
config_with(
GitHubAppConfig {
app_id: 1,
private_key_path: write_pem(&dir),
webhook_secret: "a-long-random-webhook-secret".into(),
api_base_url: None,
},
WorkerConfig {
concurrency: 1,
coven_code_bin: write_bin(&dir),
workspace_root: dir.clone(),
timeout_secs: 600,
max_retries: 2,
backend: WorkerBackendKind::Host,
container: ContainerConfig::default(),
allow_host_backend: true,
},
familiars,
)
}

fn fam(id: &str) -> FamiliarConfig {
FamiliarConfig {
id: id.into(),
display_name: id.into(),
bot_username: format!("coven-{id}[bot]"),
model: None,
skills: vec![],
trigger_labels: vec![],
}
}

#[test]
fn routing_view_reflects_open_listed_and_fail_closed() {
let mut cfg = routing_config(vec![fam("cody"), fam("nova")]);

// Open routing (no installations): every familiar, all lanes, unlisted.
let open = cfg.routing_view(123);
assert_eq!(open.familiars, vec!["cody", "nova"]);
assert!(open.triggers.reviews);
assert!(!open.listed);

// Listed with an allow-list and a disabled lane.
cfg.installations = vec![InstallationConfig {
id: 123,
account: Some("acme".into()),
familiars: vec!["cody".into()],
triggers: TriggerPolicy {
reviews: false,
..Default::default()
},
limits: InstallationLimits::default(),
repos: std::collections::HashMap::new(),
}];
let listed = cfg.routing_view(123);
assert_eq!(listed.familiars, vec!["cody"]);
assert_eq!(listed.account.as_deref(), Some("acme"));
assert!(!listed.triggers.reviews);
assert!(listed.listed);

// Configured but unlisted → fail closed.
let closed = cfg.routing_view(999);
assert!(closed.familiars.is_empty());
assert!(!closed.triggers.assignment);
assert!(!closed.listed);
}

#[test]
fn review_policy_resolves_repo_overrides() {
let policy = ReviewConfig {
Expand Down
4 changes: 3 additions & 1 deletion crates/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter;

use coven_github_config::Config;
use coven_github_webhook::routes::{
handle_webhook, list_memory, list_tasks, revoke_memory, usage, AppState,
audit, handle_webhook, list_memory, list_tasks, revoke_memory, routing, usage, AppState,
};
use coven_github_worker as worker;

Expand Down Expand Up @@ -165,6 +165,8 @@ async fn main() -> Result<()> {
.route("/api/github/memory", get(list_memory))
.route("/api/github/memory/revoke", post(revoke_memory))
.route("/api/github/usage", get(usage))
.route("/api/github/audit", get(audit))
.route("/api/github/routing", get(routing))
.with_state(state)
.layer(TraceLayer::new_for_http());

Expand Down
142 changes: 142 additions & 0 deletions crates/store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,19 @@ pub struct UsageRow {
pub runtime_secs: u64,
}

/// One task-lifecycle audit event for the Cave dashboard (issue #18).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuditEvent {
/// RFC 3339 timestamp.
pub at: String,
pub task_id: Option<String>,
pub repo: Option<String>,
/// `ignored:<reason>` | `attempt:<outcome>` | `pr_opened`.
pub kind: String,
pub detail: Option<String>,
}

impl Store {
/// Appends a task-API read to the audit trail (issue #3).
pub async fn record_api_read(
Expand Down Expand Up @@ -509,6 +522,92 @@ impl Store {
.expect("store task panicked")
}

/// Assembles a task-lifecycle audit stream for the Cave dashboard (issue
/// #18), scoped to the caller's installation and capped at `limit`, newest
/// first. Draws from three durable sources: ignored deliveries (acceptance
/// rejections), task attempts (execution / retries / timeout / failure),
/// and opened PRs (publication).
pub async fn audit_events(&self, scope: Option<ApiScope>, limit: u32) -> Result<Vec<AuditEvent>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let conn = conn.lock().expect("store mutex poisoned");
let inst = scope.as_ref().map(|s| s.installation_id);
let mut events: Vec<AuditEvent> = Vec::new();

Comment on lines +530 to +536
// Ignored deliveries — the adapter received but did not act.
let mut stmt = conn.prepare(
"SELECT received_at, repo, routing FROM webhook_deliveries
WHERE routing LIKE 'ignored:%' AND (?1 IS NULL OR installation_id = ?1)",
)?;
for row in stmt.query_map(params![inst], |r| {
Ok(AuditEvent {
at: r.get(0)?,
task_id: None,
repo: r.get::<_, Option<String>>(1)?,
kind: r.get::<_, String>(2)?,
detail: None,
})
})? {
events.push(row?);
}

// Task attempts — one per execution, carrying retries and outcomes.
let mut stmt = conn.prepare(
"SELECT COALESCE(a.ended_at, a.started_at), a.task_id, t.repo, a.attempt,
a.outcome, a.detail
FROM task_attempts a JOIN tasks t ON a.task_id = t.id
WHERE (?1 IS NULL OR t.installation_id = ?1)",
)?;
for row in stmt.query_map(params![inst], |r| {
let attempt: i64 = r.get(3)?;
let outcome: Option<String> = r.get(4)?;
let detail: Option<String> = r.get(5)?;
Ok(AuditEvent {
at: r.get(0)?,
task_id: Some(r.get::<_, String>(1)?),
repo: Some(r.get::<_, String>(2)?),
kind: format!("attempt:{}", outcome.as_deref().unwrap_or("running")),
detail: detail.map(|d| format!("attempt {attempt}: {d}")),
})
})? {
events.push(row?);
}

// Opened pull requests — the publication events.
let mut stmt = conn.prepare(
"SELECT updated_at, id, repo, pr_number FROM tasks
WHERE pr_number IS NOT NULL AND (?1 IS NULL OR installation_id = ?1)",
)?;
for row in stmt.query_map(params![inst], |r| {
Ok(AuditEvent {
at: r.get(0)?,
task_id: Some(r.get::<_, String>(1)?),
repo: Some(r.get::<_, String>(2)?),
kind: "pr_opened".to_string(),
detail: Some(format!("PR #{}", r.get::<_, u64>(3)?)),
})
})? {
events.push(row?);
}

// Newest first, honor an optional repo narrowing, then cap.
events.sort_by(|a, b| b.at.cmp(&a.at));
let repos = scope.as_ref().and_then(|s| s.repos.as_ref());
let filtered = events
.into_iter()
.filter(|e| match (repos, &e.repo) {
(Some(allowed), Some(repo)) => allowed.contains(repo),
(Some(_), None) => false,
(None, _) => true,
})
.take(limit as usize)
.collect();
Ok(filtered)
})
.await
.expect("store task panicked")
}

/// `(caller, scope, action, result)` audit rows, oldest first.
pub async fn api_audit_entries(&self) -> Result<Vec<(String, String, String, String)>> {
let conn = self.conn.clone();
Expand Down Expand Up @@ -1613,6 +1712,49 @@ mod queue_tests {
assert!(text.iter().any(|t| t.starts_with("task:")));
}

#[tokio::test]
async fn audit_events_assemble_lifecycle_scoped_to_installation() {
let store = Store::open_in_memory().expect("open");
// A finished task that opened a PR (attempt + pr_opened events).
enqueue(&store, "d1", &fix_task("t1")).await;
store.claim_next(&Default::default()).await.unwrap();
store
.finish(
"t1",
Terminal {
state: TerminalState::Completed,
result_status: Some("success".to_string()),
branch: Some("cody/fix".to_string()),
pr_number: Some(9),
summary: Some("done".to_string()),
detail: Some("attempt ok".to_string()),
},
)
.await
.expect("finish");
// An ignored delivery (acceptance rejection).
store
.record_delivery(delivery("d-ig"), Routing::Ignored("unsupported"))
.await
.unwrap();

let events = store
.audit_events(Some(ApiScope { installation_id: 1, repos: None }), 100)
.await
.unwrap();
let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
assert!(kinds.contains(&"pr_opened"), "kinds: {kinds:?}");
assert!(kinds.iter().any(|k| k.starts_with("attempt:")), "kinds: {kinds:?}");
assert!(kinds.contains(&"ignored:unsupported"), "kinds: {kinds:?}");

// A different installation sees none of it.
let other = store
.audit_events(Some(ApiScope { installation_id: 999, repos: None }), 100)
.await
.unwrap();
assert!(other.is_empty());
}

#[tokio::test]
async fn finish_reaches_terminal_state_and_closes_the_attempt() {
let store = Store::open_in_memory().expect("open");
Expand Down
Loading
Loading