diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs
index d27adca2..fc56b187 100644
--- a/src/channels/web/handlers/routines.rs
+++ b/src/channels/web/handlers/routines.rs
@@ -114,7 +114,7 @@ pub async fn routines_detail_handler(
trigger_type: run.trigger_type.clone(),
started_at: run.started_at.to_rfc3339(),
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
- status: format!("{:?}", run.status),
+ status: run.status.to_string(),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
@@ -324,7 +324,7 @@ pub async fn routines_runs_handler(
trigger_type: run.trigger_type.clone(),
started_at: run.started_at.to_rfc3339(),
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
- status: format!("{:?}", run.status),
+ status: run.status.to_string(),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index aaa479fa..fa29040e 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -2572,7 +2572,7 @@ async fn routines_runs_handler(
trigger_type: run.trigger_type.clone(),
started_at: run.started_at.to_rfc3339(),
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
- status: format!("{:?}", run.status),
+ status: run.status.to_string(),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js
index ddcfc828..6b366482 100644
--- a/src/channels/web/static/app.js
+++ b/src/channels/web/static/app.js
@@ -4265,9 +4265,9 @@ function renderRoutineDetail(routine) {
+ '
Trigger | Started | Completed | Status | Summary | Tokens | '
+ '';
for (const run of routine.recent_runs) {
- const runStatusClass = run.status === 'Ok' ? 'completed'
- : run.status === 'Failed' ? 'failed'
- : run.status === 'Attention' ? 'stuck'
+ const runStatusClass = run.status === 'ok' ? 'completed'
+ : run.status === 'failed' ? 'failed'
+ : run.status === 'attention' ? 'stuck'
: 'in_progress';
html += ''
+ '| ' + escapeHtml(run.trigger_type) + ' | '
diff --git a/src/cli/routines.rs b/src/cli/routines.rs
index ebef8839..287663f6 100644
--- a/src/cli/routines.rs
+++ b/src/cli/routines.rs
@@ -10,7 +10,7 @@ use clap::Subcommand;
use uuid::Uuid;
use crate::agent::routine::{
- NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
+ NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RunStatus, Trigger, next_cron_fire,
};
use crate::db::Database;
@@ -251,15 +251,26 @@ async fn list(
);
println!("{}", "-".repeat(130));
+ // Fetch last-run status for all routines in a single batch query
+ let routine_ids: Vec = filtered.iter().map(|r| r.id).collect();
+ let last_run_results = db
+ .batch_get_last_run_status(&routine_ids)
+ .await
+ .unwrap_or_default();
+
for r in &filtered {
- let status = if r.enabled {
- if r.consecutive_failures > 0 {
- format!("err({})", r.consecutive_failures)
- } else {
- "active".to_string()
- }
- } else {
+ let last_run_status = last_run_results.get(&r.id).copied();
+
+ let status = if !r.enabled {
"disabled".to_string()
+ } else if last_run_status == Some(RunStatus::Running) {
+ "running".to_string()
+ } else if r.consecutive_failures > 0 {
+ format!("err({})", r.consecutive_failures)
+ } else if last_run_status == Some(RunStatus::Attention) {
+ "attention".to_string()
+ } else {
+ "active".to_string()
};
let next_fire = r
diff --git a/src/db/libsql/routines.rs b/src/db/libsql/routines.rs
index 6702cc1b..69c9f5c0 100644
--- a/src/db/libsql/routines.rs
+++ b/src/db/libsql/routines.rs
@@ -462,6 +462,56 @@ impl RoutineStore for LibSqlBackend {
Ok(counts)
}
+ async fn batch_get_last_run_status(
+ &self,
+ routine_ids: &[Uuid],
+ ) -> Result, DatabaseError> {
+ if routine_ids.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let conn = self.connect().await?;
+
+ // SQLite doesn't support ANY($1), so we query all latest runs and filter in memory.
+ // Uses a subquery to pick only the most recent run per routine.
+ let mut rows = conn
+ .query(
+ "SELECT routine_id, status FROM routine_runs r1
+ WHERE started_at = (
+ SELECT MAX(started_at) FROM routine_runs r2
+ WHERE r2.routine_id = r1.routine_id
+ )
+ GROUP BY routine_id",
+ params![],
+ )
+ .await
+ .map_err(|e| {
+ DatabaseError::Query(format!("Failed to batch get last run status: {}", e))
+ })?;
+
+ let routine_id_set: HashSet = routine_ids.iter().copied().collect();
+ let mut statuses = HashMap::new();
+
+ while let Some(row) = rows
+ .next()
+ .await
+ .map_err(|e| DatabaseError::Query(e.to_string()))?
+ {
+ let id_str: String = get_text(&row, 0);
+ let id = Uuid::parse_str(&id_str)
+ .map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
+
+ if routine_id_set.contains(&id) {
+ let status_str: String = get_text(&row, 1);
+ if let std::result::Result::Ok(status) = status_str.parse::() {
+ statuses.insert(id, status);
+ }
+ }
+ }
+
+ Ok(statuses)
+ }
+
async fn link_routine_run_to_job(
&self,
run_id: Uuid,
diff --git a/src/db/mod.rs b/src/db/mod.rs
index c0594bda..6d984fed 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -528,6 +528,15 @@ pub trait RoutineStore: Send + Sync {
&self,
routine_ids: &[Uuid],
) -> Result, DatabaseError>;
+
+ /// Fetch the last run status for multiple routines in a single query.
+ /// Returns a map from routine_id to its most recent RunStatus.
+ /// Routines with no runs are omitted from the result.
+ async fn batch_get_last_run_status(
+ &self,
+ routine_ids: &[Uuid],
+ ) -> Result, DatabaseError>;
+
async fn link_routine_run_to_job(
&self,
run_id: Uuid,
diff --git a/src/db/postgres.rs b/src/db/postgres.rs
index a2c686d3..7bf76001 100644
--- a/src/db/postgres.rs
+++ b/src/db/postgres.rs
@@ -510,6 +510,14 @@ impl RoutineStore for PgBackend {
.await
}
+ async fn batch_get_last_run_status(
+ &self,
+ routine_ids: &[Uuid],
+ ) -> Result, DatabaseError>
+ {
+ self.store.batch_get_last_run_status(routine_ids).await
+ }
+
async fn link_routine_run_to_job(
&self,
run_id: Uuid,
diff --git a/src/history/store.rs b/src/history/store.rs
index d6570b3c..1e4cdd82 100644
--- a/src/history/store.rs
+++ b/src/history/store.rs
@@ -1403,6 +1403,40 @@ impl Store {
Ok(counts)
}
+ /// Batch-load the most recent run status for multiple routines in a single query.
+ /// Uses a window function to pick only the latest run per routine.
+ #[cfg(feature = "postgres")]
+ pub async fn batch_get_last_run_status(
+ &self,
+ routine_ids: &[Uuid],
+ ) -> Result, DatabaseError> {
+ if routine_ids.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let conn = self.conn().await?;
+ let rows = conn
+ .query(
+ "SELECT DISTINCT ON (routine_id) routine_id, status
+ FROM routine_runs
+ WHERE routine_id = ANY($1)
+ ORDER BY routine_id, started_at DESC",
+ &[&routine_ids],
+ )
+ .await?;
+
+ let mut statuses = HashMap::new();
+ for row in rows {
+ let id: Uuid = row.get("routine_id");
+ let status_str: String = row.get("status");
+ if let std::result::Result::Ok(status) = status_str.parse::() {
+ statuses.insert(id, status);
+ }
+ }
+
+ Ok(statuses)
+ }
+
/// Link a routine run to a dispatched job.
pub async fn link_routine_run_to_job(
&self,
diff --git a/tests/batch_last_run_status_tests.rs b/tests/batch_last_run_status_tests.rs
new file mode 100644
index 00000000..4bd476ec
--- /dev/null
+++ b/tests/batch_last_run_status_tests.rs
@@ -0,0 +1,191 @@
+//! Tests for batch_get_last_run_status (#1469 N+1 fix).
+//!
+//! Verifies:
+//! 1. Empty input returns empty map
+//! 2. Returns the most recent run status per routine
+//! 3. Routines with no runs are omitted from result
+//! 4. Multiple routines with different statuses are correctly returned
+
+#[cfg(feature = "libsql")]
+mod tests {
+ use std::sync::Arc;
+
+ use chrono::{Duration, Utc};
+ use uuid::Uuid;
+
+ use ironclaw::agent::routine::{
+ Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
+ };
+ use ironclaw::db::Database;
+
+ async fn create_test_db() -> (Arc, tempfile::TempDir) {
+ use ironclaw::db::libsql::LibSqlBackend;
+
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ let db_path = temp_dir.path().join("test.db");
+ let backend = LibSqlBackend::new_local(&db_path)
+ .await
+ .expect("LibSqlBackend");
+ backend.run_migrations().await.expect("migrations");
+ let db: Arc = Arc::new(backend);
+ (db, temp_dir)
+ }
+
+ fn make_routine(id: Uuid) -> Routine {
+ Routine {
+ id,
+ name: format!("test-routine-{}", id),
+ description: "Test routine".to_string(),
+ user_id: "default".to_string(),
+ enabled: true,
+ trigger: Trigger::Manual,
+ action: RoutineAction::FullJob {
+ title: "Test job".to_string(),
+ description: "Test description".to_string(),
+ max_iterations: 5,
+ },
+ guardrails: RoutineGuardrails {
+ cooldown: std::time::Duration::from_secs(0),
+ max_concurrent: 1,
+ dedup_window: None,
+ },
+ notify: Default::default(),
+ last_run_at: None,
+ next_fire_at: None,
+ run_count: 0,
+ consecutive_failures: 0,
+ state: serde_json::json!({}),
+ created_at: Utc::now(),
+ updated_at: Utc::now(),
+ }
+ }
+
+ fn make_run(
+ routine_id: Uuid,
+ status: RunStatus,
+ started_at: chrono::DateTime,
+ ) -> RoutineRun {
+ RoutineRun {
+ id: Uuid::new_v4(),
+ routine_id,
+ trigger_type: "manual".to_string(),
+ trigger_detail: None,
+ started_at,
+ completed_at: if status == RunStatus::Running {
+ None
+ } else {
+ Some(Utc::now())
+ },
+ status,
+ result_summary: None,
+ tokens_used: None,
+ job_id: None,
+ created_at: Utc::now(),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_batch_get_last_run_status_empty_input() {
+ let (db, _tmp) = create_test_db().await;
+ let result = db
+ .batch_get_last_run_status(&[])
+ .await
+ .expect("batch query");
+ assert!(result.is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_batch_get_last_run_status_returns_latest() {
+ let (db, _tmp) = create_test_db().await;
+
+ let routine_id = Uuid::new_v4();
+ db.create_routine(&make_routine(routine_id))
+ .await
+ .expect("create routine");
+
+ // Create an older run with Ok status
+ let older_run = make_run(routine_id, RunStatus::Ok, Utc::now() - Duration::hours(2));
+ db.create_routine_run(&older_run)
+ .await
+ .expect("create older run");
+ db.complete_routine_run(older_run.id, RunStatus::Ok, None, None)
+ .await
+ .expect("complete older run");
+
+ // Create a newer run with Attention status
+ let newer_run = make_run(
+ routine_id,
+ RunStatus::Attention,
+ Utc::now() - Duration::hours(1),
+ );
+ db.create_routine_run(&newer_run)
+ .await
+ .expect("create newer run");
+ db.complete_routine_run(newer_run.id, RunStatus::Attention, None, None)
+ .await
+ .expect("complete newer run");
+
+ let result = db
+ .batch_get_last_run_status(&[routine_id])
+ .await
+ .expect("batch query");
+ assert_eq!(result.get(&routine_id), Some(&RunStatus::Attention));
+ }
+
+ #[tokio::test]
+ async fn test_batch_get_last_run_status_omits_routines_without_runs() {
+ let (db, _tmp) = create_test_db().await;
+
+ let with_runs = Uuid::new_v4();
+ let without_runs = Uuid::new_v4();
+ db.create_routine(&make_routine(with_runs))
+ .await
+ .expect("create routine");
+ db.create_routine(&make_routine(without_runs))
+ .await
+ .expect("create routine");
+
+ let run = make_run(with_runs, RunStatus::Ok, Utc::now());
+ db.create_routine_run(&run).await.expect("create run");
+ db.complete_routine_run(run.id, RunStatus::Ok, None, None)
+ .await
+ .expect("complete run");
+
+ let result = db
+ .batch_get_last_run_status(&[with_runs, without_runs])
+ .await
+ .expect("batch query");
+ assert_eq!(result.get(&with_runs), Some(&RunStatus::Ok));
+ assert_eq!(result.get(&without_runs), None);
+ }
+
+ #[tokio::test]
+ async fn test_batch_get_last_run_status_multiple_routines() {
+ let (db, _tmp) = create_test_db().await;
+
+ let r1 = Uuid::new_v4();
+ let r2 = Uuid::new_v4();
+ db.create_routine(&make_routine(r1))
+ .await
+ .expect("create r1");
+ db.create_routine(&make_routine(r2))
+ .await
+ .expect("create r2");
+
+ let run1 = make_run(r1, RunStatus::Running, Utc::now());
+ db.create_routine_run(&run1).await.expect("create run1");
+
+ let run2 = make_run(r2, RunStatus::Failed, Utc::now());
+ db.create_routine_run(&run2).await.expect("create run2");
+ db.complete_routine_run(run2.id, RunStatus::Failed, None, None)
+ .await
+ .expect("complete run2");
+
+ let result = db
+ .batch_get_last_run_status(&[r1, r2])
+ .await
+ .expect("batch query");
+ assert_eq!(result.get(&r1), Some(&RunStatus::Running));
+ assert_eq!(result.get(&r2), Some(&RunStatus::Failed));
+ }
+}