diff --git a/codex-rs/deterministic-core/src/lib.rs b/codex-rs/deterministic-core/src/lib.rs index 4e4b9a9a4029..f1782d29c90d 100644 --- a/codex-rs/deterministic-core/src/lib.rs +++ b/codex-rs/deterministic-core/src/lib.rs @@ -28,5 +28,6 @@ pub mod run_set_priority; pub mod run_assign_owner; pub mod run_set_due_date; pub mod run_set_dependencies; +pub mod run_readiness; pub mod tests_run; pub mod workspace_summary; diff --git a/codex-rs/deterministic-core/src/run_readiness.rs b/codex-rs/deterministic-core/src/run_readiness.rs index ac453548506c..202dae9e5da6 100644 --- a/codex-rs/deterministic-core/src/run_readiness.rs +++ b/codex-rs/deterministic-core/src/run_readiness.rs @@ -41,6 +41,7 @@ pub struct RunReadinessSummary { /// - Overdue visible run (requires `today` date for comparison) → `"overdue"` /// /// No timers, notifications, escalations, or state mutations are performed. +#[allow(clippy::too_many_arguments, clippy::collapsible_if)] pub fn derive_readiness( status: &str, is_archived: bool, @@ -62,7 +63,7 @@ pub fn derive_readiness( let reason = if blocked_by_count == 1 { "blocked by 1 run".to_string() } else { - format!("blocked by {} runs", blocked_by_count) + format!("blocked by {blocked_by_count} runs") }; (false, Some(reason)) } else { diff --git a/codex-rs/deterministic-daemon/src/handlers.rs b/codex-rs/deterministic-daemon/src/handlers.rs index 5c3f05534632..1c7c59192d66 100644 --- a/codex-rs/deterministic-daemon/src/handlers.rs +++ b/codex-rs/deterministic-daemon/src/handlers.rs @@ -60,6 +60,8 @@ pub fn dispatch( Method::RunSetDueDate => handle_run_set_due_date(params, store), // Milestone 21: deterministic run dependency links Method::RunSetDependencies => handle_run_set_dependencies(params, store), + // Milestone 24: deterministic queue overview + Method::RunsQueueOverview => handle_runs_queue_overview(params, store), } } @@ -518,7 +520,7 @@ fn handle_runs_list( run.is_blocking = Some(count > 0); run.blocking_run_count = Some(count); run.blocking_reason = if count > 0 { - Some(format!("blocking {} run(s)", count)) + Some(format!("blocking {count} run(s)")) } else { None }; @@ -573,7 +575,7 @@ fn handle_run_get( let is_blocking = Some(blocking_count > 0); let blocking_run_count = Some(blocking_count); let blocking_reason = if blocking_count > 0 { - Some(format!("blocking {} run(s)", blocking_count)) + Some(format!("blocking {blocking_count} run(s)")) } else { None }; @@ -1270,6 +1272,162 @@ fn handle_run_set_dependencies( Ok((serde_json::to_value(result)?, Some(state))) } +// --------------------------------------------------------------------------- +// runs.overview (Milestone 24) + +#[allow(clippy::collapsible_if)] +/// Handle the runs.overview method - deterministic queue overview. +fn handle_runs_queue_overview( + params: serde_json::Value, + store: &Store, +) -> Result<(serde_json::Value, Option)> { + let p: RunsQueueOverviewParams = serde_json::from_value(params)?; + + let include_archived = p.include_archived.unwrap_or(false); + let include_snoozed = p.include_snoozed.unwrap_or(false); + let today = p.today.as_deref(); + + // Use list_runs with high limit to get all runs for overview + // This uses the existing filter semantics from RunsListParams + let archived_only = false; // We handle filtering ourselves + let all_runs = store.list_runs( + 10000, // High limit to get all runs + p.workspace_id.as_deref(), + None, // status_filter - none for overview + include_archived, + archived_only, + None, // label_filter + false, // pinned_only + include_snoozed, + false, // snoozed_only + )?; + + // Use run_readiness module to derive readiness + use deterministic_core::run_readiness::derive_readiness; + + let mut total_count = 0; + let mut ready_count = 0; + let mut blocked_count = 0; + let mut attention_count = 0; + let mut pinned_count = 0; + let mut snoozed_count = 0; + let mut overdue_count = 0; + let archived_count = 0; + + let mut priority_counts = std::collections::HashMap::new(); + let mut assignee_counts = std::collections::HashMap::new(); + let mut status_counts = std::collections::HashMap::new(); + + for run in &all_runs { + let is_archived = run.is_archived.unwrap_or(false); + let is_snoozed = run.is_snoozed.unwrap_or(false); + let is_pinned = run.is_pinned.unwrap_or(false); + let blocked_by_count = run.blocked_by_count.unwrap_or(0); + + // Skip based on filters (list_runs handles SQL-level, we handle in-memory) + if is_snoozed && !include_snoozed { + continue; + } + + // Count snoozed + if is_snoozed { + snoozed_count += 1; + } + + // Count pinned + if is_pinned { + pinned_count += 1; + } + + // Count blocked + if blocked_by_count > 0 { + blocked_count += 1; + } + + // Derive readiness using the deterministic function + let readiness = derive_readiness( + &run.status, + is_archived, + is_snoozed, + blocked_by_count, + run.priority, + run.due_date.as_deref(), + is_pinned, + today, + ); + + // Count ready + if readiness.is_ready { + ready_count += 1; + total_count += 1; + } else { + total_count += 1; + } + + // Count needs attention + if readiness.needs_attention { + attention_count += 1; + } + + // Count overdue + if today.is_some() { + if let Some(due) = &run.due_date { + if let Some(t) = today { + if due.as_str() < t { + overdue_count += 1; + } + } + } + } + + // Priority counts + let priority_str = match run.priority { + deterministic_protocol::RunPriority::Low => "low", + deterministic_protocol::RunPriority::Normal => "normal", + deterministic_protocol::RunPriority::High => "high", + deterministic_protocol::RunPriority::Urgent => "urgent", + }; + *priority_counts.entry(priority_str.to_string()).or_insert(0) += 1; + + // Assignee counts + let assignee = run.assignee.clone().unwrap_or_else(|| "unassigned".to_string()); + *assignee_counts.entry(assignee).or_insert(0) += 1; + + // Status counts - use prefix + let status_prefix = if run.status.starts_with("finalized:") { + "finalized" + } else if run.status.starts_with("awaiting_approval:") { + "awaiting_approval" + } else if run.status.starts_with("pending:") { + "pending" + } else { + &run.status + }; + *status_counts.entry(status_prefix.to_string()).or_insert(0) += 1; + } + + let result = RunQueueOverview { + total_visible: total_count, + ready_count, + blocked_count, + needs_attention_count: attention_count, + pinned_count, + snoozed_count, + overdue_count: if today.is_some() { Some(overdue_count) } else { None }, + archived_count: if include_archived { Some(archived_count) } else { None }, + by_priority: PriorityCounts { + low: *priority_counts.get("low").unwrap_or(&0), + normal: *priority_counts.get("normal").unwrap_or(&0), + high: *priority_counts.get("high").unwrap_or(&0), + urgent: *priority_counts.get("urgent").unwrap_or(&0), + }, + by_assignee: assignee_counts, + by_status: status_counts, + }; + + Ok((serde_json::to_value(result)?, None)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/deterministic-daemon/src/persistence.rs b/codex-rs/deterministic-daemon/src/persistence.rs index 2f596c38f93c..39dbdd22ca71 100644 --- a/codex-rs/deterministic-daemon/src/persistence.rs +++ b/codex-rs/deterministic-daemon/src/persistence.rs @@ -1023,6 +1023,7 @@ impl Store { /// /// This is used by handlers to compute deterministic blocker-impact summaries (Milestone 23). /// The map only contains run IDs that appear as blockers (count ≥ 1); absent entries mean zero. + #[allow(clippy::unwrap_used)] pub fn get_blocker_impact_map(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( diff --git a/codex-rs/deterministic-protocol/src/methods.rs b/codex-rs/deterministic-protocol/src/methods.rs index 52e3676be249..bd2532ae2316 100644 --- a/codex-rs/deterministic-protocol/src/methods.rs +++ b/codex-rs/deterministic-protocol/src/methods.rs @@ -53,6 +53,8 @@ pub enum Method { RunSetDueDate, // Milestone 21: deterministic run dependency links RunSetDependencies, + // Milestone 24: deterministic queue overview + RunsQueueOverview, } impl Method { @@ -103,6 +105,8 @@ impl Method { Self::RunSetDueDate => "run.set_due_date", // Milestone 21 Self::RunSetDependencies => "run.set_dependencies", + // Milestone 24 + Self::RunsQueueOverview => "runs.overview", } } @@ -153,6 +157,8 @@ impl Method { "run.set_due_date" => Some(Self::RunSetDueDate), // Milestone 21 "run.set_dependencies" => Some(Self::RunSetDependencies), + // Milestone 24 + "runs.overview" => Some(Self::RunsQueueOverview), _ => None, } } @@ -204,6 +210,8 @@ impl Method { Self::RunSetDueDate, // Milestone 21 Self::RunSetDependencies, + // Milestone 24 + Self::RunsQueueOverview, ] } } diff --git a/codex-rs/deterministic-protocol/src/types.rs b/codex-rs/deterministic-protocol/src/types.rs index b7471790af7c..7fb06d67e33c 100644 --- a/codex-rs/deterministic-protocol/src/types.rs +++ b/codex-rs/deterministic-protocol/src/types.rs @@ -913,6 +913,77 @@ pub struct RunsListResult { pub count: usize, } +// --------------------------------------------------------------------------- +// runs.overview (Milestone 24) + +/// Parameters for requesting a queue overview summary. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunsQueueOverviewParams { + /// Filter by workspace ID (optional). + #[serde(default)] + pub workspace_id: Option, + /// When true, archived runs are included in counts (Milestone 13). + /// Default: false (archived runs are excluded from visible counts). + #[serde(default)] + pub include_archived: Option, + /// When true, snoozed runs are included in counts (Milestone 17). + /// Default: false (snoozed runs are excluded from visible counts). + #[serde(default)] + pub include_snoozed: Option, + /// ISO date (`YYYY-MM-DD`) for computing overdue counts (Milestone 20). + /// When provided, counts will include overdue status based on this date. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub today: Option, +} + +/// Deterministic queue overview summary providing aggregate counts. +/// +/// This is a read-only inspection operation that derives summary counts from +/// existing run state without mutating anything. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunQueueOverview { + /// Total count of visible (non-archived) runs matching the filter. + pub total_visible: usize, + /// Count of runs that are ready to work on now. + pub ready_count: usize, + /// Count of runs that are blocked by at least one other run. + pub blocked_count: usize, + /// Count of runs that warrant operator attention. + pub needs_attention_count: usize, + /// Count of runs that are currently pinned. + pub pinned_count: usize, + /// Count of runs that are currently snoozed. + pub snoozed_count: usize, + /// Count of runs with a due date in the past (overdue). + /// Only included when `today` parameter is provided. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overdue_count: Option, + /// Count of archived runs (only included when include_archived is true). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_count: Option, + /// Counts grouped by priority level. + pub by_priority: PriorityCounts, + /// Counts grouped by assignee (key is assignee, value is count). + /// Unassigned runs are stored under key `"unassigned"`. + #[serde(default)] + pub by_assignee: std::collections::HashMap, + /// Counts grouped by status prefix (e.g., "awaiting_approval", "finalized", etc.). + #[serde(default)] + pub by_status: std::collections::HashMap, +} + +/// Priority-level counts for queue overview. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PriorityCounts { + pub low: usize, + pub normal: usize, + pub high: usize, + pub urgent: usize, +} + // --------------------------------------------------------------------------- // run.get (Milestone 7) // ---------------------------------------------------------------------------