diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3b551bec0e..8ce8d390a0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6154,7 +6154,9 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "chrono-tz", "core_types", + "croner", "database", "git", "hex", diff --git a/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs b/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs index 3ff0ab0e28..29956288ae 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs @@ -15,6 +15,7 @@ use chrono::{DateTime, Utc}; use tracing::{info, warn}; use project_management::projects::io; +use project_management::projects::routine_schedule::{due_times, next_occurrence}; use project_management::projects::types::{ RoutineCatchUpPolicy, RoutineDefinition, RoutineTrigger, }; @@ -66,8 +67,7 @@ async fn tick(app: &tauri::AppHandle, now: DateTime) -> Result<(), String> } /// Evaluate the portable `pm_routines` schedule activations (design -/// §10.4). Cron is evaluated in UTC for now — the declared timezone is -/// carried in the spec and honored once tz-aware evaluation lands. +/// §10.4). Cron is evaluated in the timezone declared by the portable spec. /// Catch-up: both portable policies (`none`, `fire_once`) reduce to /// "fire the latest missed tick once", matching the legacy collapse. async fn portable_tick(now: DateTime) -> Result<(), String> { @@ -84,6 +84,7 @@ async fn portable_tick(now: DateTime) -> Result<(), String> { .unwrap_or_else(|| now - chrono::Duration::seconds(POLL_INTERVAL_SECS as i64)); let trigger = RoutineTrigger::Cron { cron: candidate.cron.clone(), + timezone: candidate.timezone.clone(), }; let due = match due_times(&trigger, &window_start, &now) { Ok(due) => due, @@ -136,6 +137,7 @@ async fn portable_tick(now: DateTime) -> Result<(), String> { let next = next_occurrence( &RoutineTrigger::Cron { cron: candidate.cron.clone(), + timezone: candidate.timezone.clone(), }, &now, ) @@ -243,48 +245,6 @@ fn watermark(routine: &RoutineDefinition, now: DateTime) -> DateTime { .unwrap_or_else(|| now - chrono::Duration::seconds(POLL_INTERVAL_SECS as i64)) } -/// All trigger times in `(window_start, now]`. -fn due_times( - trigger: &RoutineTrigger, - window_start: &DateTime, - now: &DateTime, -) -> Result>, String> { - match trigger { - RoutineTrigger::OneTime { at } => { - let at_time = parse_trigger_time(at)?; - if at_time > *window_start && at_time <= *now { - Ok(vec![at_time]) - } else if at_time <= *window_start { - // Missed while the app was closed — still due exactly once; - // catch-up policy decides whether it actually runs. - Ok(vec![at_time]) - } else { - Ok(Vec::new()) - } - } - RoutineTrigger::Cron { cron } => { - let parsed = croner::Cron::new(cron) - .parse() - .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; - let mut due = Vec::new(); - let mut cursor = *window_start; - // Bounded to avoid unbounded loops on pathological expressions - // after long downtime. - const MAX_DUE: usize = 1000; - while due.len() < MAX_DUE { - match parsed.find_next_occurrence(&cursor, false) { - Ok(next) if next <= *now => { - due.push(next); - cursor = next; - } - _ => break, - } - } - Ok(due) - } - } -} - /// Reduce the due list according to the catch-up policy. The latest due time /// always fires; earlier (missed) ones are policy-dependent. fn apply_catch_up_policy( @@ -315,34 +275,6 @@ fn apply_catch_up_policy( } } -fn next_occurrence( - trigger: &RoutineTrigger, - now: &DateTime, -) -> Result>, String> { - match trigger { - RoutineTrigger::OneTime { at } => { - let at_time = parse_trigger_time(at)?; - Ok((at_time > *now).then_some(at_time)) - } - RoutineTrigger::Cron { cron } => { - let parsed = croner::Cron::new(cron) - .parse() - .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; - Ok(parsed.find_next_occurrence(now, false).ok()) - } - } -} - -fn parse_trigger_time(raw: &str) -> Result, String> { - if let Ok(parsed) = DateTime::parse_from_rfc3339(raw) { - return Ok(parsed.with_timezone(&Utc)); - } - if let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") { - return Ok(parsed.and_utc()); - } - Err(format!("invalid one-time trigger timestamp: {raw}")) -} - #[cfg(test)] mod tests { use super::*; @@ -360,6 +292,7 @@ mod tests { fn cron_no_tick_in_window_returns_empty() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; let window_start = at(2026, 6, 10, 10, 0); let now = at(2026, 6, 10, 10, 5); @@ -370,6 +303,7 @@ mod tests { fn cron_single_tick_in_window() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; let window_start = at(2026, 6, 10, 8, 0); let now = at(2026, 6, 10, 10, 0); @@ -381,6 +315,7 @@ mod tests { fn cron_multiple_missed_ticks_accumulate() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; // Three days of downtime → three missed 09:00 ticks. let window_start = at(2026, 6, 7, 12, 0); @@ -400,6 +335,7 @@ mod tests { fn cron_invalid_expression_is_error() { let trigger = RoutineTrigger::Cron { cron: "not a cron".to_string(), + timezone: "UTC".to_string(), }; let now = Utc::now(); assert!(due_times(&trigger, &now, &now).is_err()); @@ -492,6 +428,7 @@ mod tests { fn next_occurrence_cron() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; let now = at(2026, 6, 10, 10, 0); let next = next_occurrence(&trigger, &now).unwrap().unwrap(); @@ -529,6 +466,7 @@ mod tests { enabled: true, trigger: RoutineTrigger::Cron { cron: "* * * * *".into(), + timezone: "UTC".into(), }, run_template: project_management::projects::types::RoutineRunTemplate { prompt: String::new(), @@ -548,6 +486,11 @@ mod tests { output_policy: Default::default(), last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), }; diff --git a/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs b/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs index a1f3b5360f..c7ade8af35 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs @@ -297,7 +297,10 @@ pub fn migrate_cron_schedules() -> Result { name: format!("Recurring: {}", fm.title), description: format!("Migrated from work item {} recurring schedule", fm.short_id), enabled: true, - trigger: RoutineTrigger::Cron { cron }, + trigger: RoutineTrigger::Cron { + cron, + timezone: "UTC".to_string(), + }, run_template: RoutineRunTemplate { prompt: fm.title.clone(), target: RoutineRunTarget::AgentDefinition { @@ -321,6 +324,11 @@ pub fn migrate_cron_schedules() -> Result { }, last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), }; @@ -511,7 +519,7 @@ mod tests { labels: vec![], milestone: None, parent: None, - stage: None, + stage: None, start_date: start_date.map(|s| s.to_string()), target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/Cargo.toml b/src-tauri/crates/project-management/Cargo.toml index d5a3887871..1012a6ccbb 100644 --- a/src-tauri/crates/project-management/Cargo.toml +++ b/src-tauri/crates/project-management/Cargo.toml @@ -30,6 +30,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" chrono = { workspace = true } +chrono-tz = "0.10.4" +croner = "2" tokio = { workspace = true } tokio-util = { workspace = true } tracing = "0.1.37" diff --git a/src-tauri/crates/project-management/src/projects/io/routines.rs b/src-tauri/crates/project-management/src/projects/io/routines.rs index b030953918..c53856d491 100644 --- a/src-tauri/crates/project-management/src/projects/io/routines.rs +++ b/src-tauri/crates/project-management/src/projects/io/routines.rs @@ -2,6 +2,7 @@ use rusqlite::{params, OptionalExtension}; +use super::super::routine_schedule; use super::helpers::{conn, from_iso8601, map_db, now_ms, to_iso8601}; use crate::projects::types::{ RoutineConcurrencyPolicy, RoutineDefinition, RoutineFire, RoutineFireStatus, @@ -45,14 +46,33 @@ fn row_to_routine(row: &rusqlite::Row<'_>) -> rusqlite::Result>(9)?.map(to_iso8601), next_fire_at: row.get::<_, Option>(10)?.map(to_iso8601), + last_fire_at: row.get::<_, Option>(11)?.map(to_iso8601), + last_fire_status: row + .get::<_, Option>(12)? + .map(|status| parse_fire_status(&status, 12)) + .transpose()?, + last_fire_error: row.get(13)?, + last_fire_session_id: row.get(14)?, + last_fire_work_item_id: row.get(15)?, created_at: to_iso8601(row.get(7)?), updated_at: to_iso8601(row.get(8)?), }) } const ROUTINE_SELECT_COLUMNS: &str = - "id, name, description, enabled, trigger_json, run_template_json, - output_policy_json, created_at, updated_at, last_evaluated_at, next_fire_at"; + "routine.id, routine.name, routine.description, routine.enabled, + routine.trigger_json, routine.run_template_json, routine.output_policy_json, + routine.created_at, routine.updated_at, routine.last_evaluated_at, + routine.next_fire_at, + latest_fire.fired_at, latest_fire.status, latest_fire.error, + latest_fire.session_id, latest_fire.work_item_id"; + +const ROUTINE_FROM: &str = "routine_definitions AS routine + LEFT JOIN routine_fires AS latest_fire ON latest_fire.id = ( + SELECT fire.id FROM routine_fires AS fire + WHERE fire.routine_id = routine.id + ORDER BY fire.fired_at DESC, fire.id DESC LIMIT 1 + )"; const FIRE_SELECT_COLUMNS: &str = "id, routine_id, fired_at, status, session_id, agent_org_run_id, work_item_id, coalesced_into_fire_id, idempotency_key, started_at, @@ -69,22 +89,7 @@ fn decode_output_policy(raw: &str) -> rusqlite::Result { fn row_to_fire(row: &rusqlite::Row<'_>) -> rusqlite::Result { let status_raw: String = row.get(3)?; - let status = match status_raw.as_str() { - "pending" => RoutineFireStatus::Pending, - "started" => RoutineFireStatus::Started, - "succeeded" => RoutineFireStatus::Succeeded, - "failed" => RoutineFireStatus::Failed, - "skipped" => RoutineFireStatus::Skipped, - "coalesced" => RoutineFireStatus::Coalesced, - "queued" => RoutineFireStatus::Queued, - other => { - return Err(rusqlite::Error::FromSqlConversionFailure( - 3, - rusqlite::types::Type::Text, - format!("unknown routine fire status: {other}").into(), - )); - } - }; + let status = parse_fire_status(&status_raw, 3)?; Ok(RoutineFire { id: row.get(0)?, @@ -102,6 +107,25 @@ fn row_to_fire(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } +fn parse_fire_status(raw: &str, column: usize) -> rusqlite::Result { + Ok(match raw { + "pending" => RoutineFireStatus::Pending, + "started" => RoutineFireStatus::Started, + "succeeded" => RoutineFireStatus::Succeeded, + "failed" => RoutineFireStatus::Failed, + "skipped" => RoutineFireStatus::Skipped, + "coalesced" => RoutineFireStatus::Coalesced, + "queued" => RoutineFireStatus::Queued, + other => { + return Err(rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + format!("unknown routine fire status: {other}").into(), + )); + } + }) +} + fn status_to_str(status: &RoutineFireStatus) -> &'static str { match status { RoutineFireStatus::Pending => "pending", @@ -118,8 +142,8 @@ pub fn list_routines() -> Result, String> { let connection = conn()?; let mut stmt = map_db(connection.prepare(&format!( "SELECT {ROUTINE_SELECT_COLUMNS} - FROM routine_definitions - ORDER BY updated_at DESC, created_at DESC", + FROM {ROUTINE_FROM} + ORDER BY routine.updated_at DESC, routine.created_at DESC", )))?; let rows = map_db(stmt.query_map([], row_to_routine))?; let mut routines = Vec::new(); @@ -149,9 +173,9 @@ pub fn list_enabled_routines() -> Result, String> { let connection = conn()?; let mut stmt = map_db(connection.prepare(&format!( "SELECT {ROUTINE_SELECT_COLUMNS} - FROM routine_definitions - WHERE enabled = 1 - ORDER BY created_at ASC", + FROM {ROUTINE_FROM} + WHERE routine.enabled = 1 + ORDER BY routine.created_at ASC", )))?; let rows = map_db(stmt.query_map([], row_to_routine))?; let mut routines = Vec::new(); @@ -168,8 +192,8 @@ pub fn read_routine(id: &str) -> Result { .query_row( &format!( "SELECT {ROUTINE_SELECT_COLUMNS} - FROM routine_definitions - WHERE id = ?1", + FROM {ROUTINE_FROM} + WHERE routine.id = ?1", ), params![id], row_to_routine, @@ -194,12 +218,20 @@ pub fn upsert_routine(mut routine: RoutineDefinition) -> Result Result Result Result, String> completed_at, error FROM routine_fires WHERE routine_id = ?1 - ORDER BY fired_at DESC", + ORDER BY fired_at DESC + LIMIT 100", ))?; let rows = map_db(stmt.query_map([routine_id], row_to_fire))?; let mut fires = Vec::new(); @@ -718,6 +758,11 @@ mod tests { output_policy: policy, last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), } @@ -760,6 +805,46 @@ mod tests { assert_eq!(read.output_policy, saved.output_policy); } + #[test] + fn upsert_computes_next_fire_immediately_in_declared_timezone() { + use chrono::Timelike; + + let _sandbox = test_env::sandbox(); + let mut routine = routine_fixture( + "routine-next-fire", + policy(RoutineConcurrencyPolicy::AlwaysCreate), + ); + routine.trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "America/Vancouver".to_string(), + }; + + let saved = upsert_routine(routine).expect("upsert routine"); + let next = saved.next_fire_at.expect("next fire projected on save"); + let next = chrono::DateTime::parse_from_rfc3339(&next) + .expect("next fire is RFC3339") + .with_timezone(&"America/Vancouver".parse::().unwrap()); + assert_eq!(next.hour(), 9); + assert_eq!(next.minute(), 0); + } + + #[test] + fn routine_projects_the_latest_fire_result() { + let _sandbox = test_env::sandbox(); + upsert_routine(routine_fixture( + "routine-latest-result", + policy(RoutineConcurrencyPolicy::AlwaysCreate), + )) + .expect("upsert routine"); + let fire = create_routine_fire("routine-latest-result").expect("create fire"); + mark_routine_fire_work_item_created(&fire.id, "ABC-0001").expect("link work item"); + + let routine = read_routine("routine-latest-result").expect("read routine"); + assert_eq!(routine.last_fire_status, Some(RoutineFireStatus::Succeeded)); + assert_eq!(routine.last_fire_work_item_id.as_deref(), Some("ABC-0001")); + assert!(routine.last_fire_at.is_some()); + } + #[test] fn empty_output_policy_json_decodes_to_default_policy() { assert_eq!( diff --git a/src-tauri/crates/project-management/src/projects/mod.rs b/src-tauri/crates/project-management/src/projects/mod.rs index d83be5e4fd..c184df0f40 100644 --- a/src-tauri/crates/project-management/src/projects/mod.rs +++ b/src-tauri/crates/project-management/src/projects/mod.rs @@ -28,6 +28,7 @@ pub mod commands; pub mod events; pub mod io; pub mod paths; +pub mod routine_schedule; pub mod schema; pub mod sync_export; pub mod types; diff --git a/src-tauri/crates/project-management/src/projects/routine_schedule.rs b/src-tauri/crates/project-management/src/projects/routine_schedule.rs new file mode 100644 index 0000000000..fa9dbb1d7f --- /dev/null +++ b/src-tauri/crates/project-management/src/projects/routine_schedule.rs @@ -0,0 +1,153 @@ +//! Timezone-aware schedule math shared by Routine persistence and dispatch. + +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; + +use super::types::RoutineTrigger; + +fn parse_timezone(raw: &str) -> Result { + if raw.trim().is_empty() || raw.eq_ignore_ascii_case("utc") { + return Ok(chrono_tz::UTC); + } + raw.parse::() + .map_err(|err| format!("invalid routine timezone '{raw}': {err}")) +} + +fn parse_trigger_time(raw: &str) -> Result, String> { + if let Ok(parsed) = DateTime::parse_from_rfc3339(raw) { + return Ok(parsed.with_timezone(&Utc)); + } + if let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") { + return Ok(parsed.and_utc()); + } + Err(format!("invalid one-time trigger timestamp: {raw}")) +} + +/// Return every trigger instant in `(window_start, now]`. +pub fn due_times( + trigger: &RoutineTrigger, + window_start: &DateTime, + now: &DateTime, +) -> Result>, String> { + match trigger { + RoutineTrigger::OneTime { at } => { + let at_time = parse_trigger_time(at)?; + if at_time > *window_start && at_time <= *now { + Ok(vec![at_time]) + } else if at_time <= *window_start { + // A missed one-time trigger remains due exactly once. The + // caller's catch-up policy decides whether it executes. + Ok(vec![at_time]) + } else { + Ok(Vec::new()) + } + } + RoutineTrigger::Cron { cron, timezone } => { + let parsed = croner::Cron::new(cron) + .parse() + .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; + let timezone = parse_timezone(timezone)?; + let now_local = now.with_timezone(&timezone); + let mut cursor = window_start.with_timezone(&timezone); + let mut due = Vec::new(); + // Bound replay after long downtime or pathological expressions. + const MAX_DUE: usize = 1000; + while due.len() < MAX_DUE { + match parsed.find_next_occurrence(&cursor, false) { + Ok(next) if next <= now_local => { + due.push(next.with_timezone(&Utc)); + cursor = next; + } + Ok(_) => break, + Err(err) => { + return Err(format!( + "compute next occurrence for '{cron}' in '{timezone}': {err}" + )); + } + } + } + Ok(due) + } + } +} + +/// Compute the next trigger instant after `now`. +pub fn next_occurrence( + trigger: &RoutineTrigger, + now: &DateTime, +) -> Result>, String> { + match trigger { + RoutineTrigger::OneTime { at } => { + let at_time = parse_trigger_time(at)?; + Ok((at_time > *now).then_some(at_time)) + } + RoutineTrigger::Cron { cron, timezone } => { + let parsed = croner::Cron::new(cron) + .parse() + .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; + let timezone = parse_timezone(timezone)?; + let local_now = now.with_timezone(&timezone); + let next = parsed + .find_next_occurrence(&local_now, false) + .map_err(|err| { + format!("compute next occurrence for '{cron}' in '{timezone}': {err}") + })?; + Ok(Some(next.with_timezone(&Utc))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime { + Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap() + } + + #[test] + fn cron_uses_the_declared_timezone() { + let trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "America/Vancouver".to_string(), + }; + let now = at(2026, 8, 8, 17, 0); + let next = next_occurrence(&trigger, &now).unwrap().unwrap(); + assert_eq!(next, at(2026, 8, 9, 16, 0)); + } + + #[test] + fn cron_due_window_is_compared_in_the_declared_timezone() { + let trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "Asia/Shanghai".to_string(), + }; + let due = due_times(&trigger, &at(2026, 8, 8, 0, 0), &at(2026, 8, 8, 2, 0)).unwrap(); + assert_eq!(due, vec![at(2026, 8, 8, 1, 0)]); + } + + #[test] + fn legacy_cron_without_timezone_defaults_to_utc() { + let trigger: RoutineTrigger = + serde_json::from_str(r#"{"kind":"cron","cron":"0 9 * * *"}"#).unwrap(); + assert_eq!( + trigger, + RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), + } + ); + } + + #[test] + fn invalid_timezone_is_rejected() { + let trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "Mars/Olympus".to_string(), + }; + assert!(next_occurrence(&trigger, &Utc::now()) + .unwrap_err() + .contains("invalid routine timezone")); + } +} diff --git a/src-tauri/crates/project-management/src/projects/types/routines.rs b/src-tauri/crates/project-management/src/projects/types/routines.rs index be13aa347d..bb3cafed51 100644 --- a/src-tauri/crates/project-management/src/projects/types/routines.rs +++ b/src-tauri/crates/project-management/src/projects/types/routines.rs @@ -5,8 +5,18 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RoutineTrigger { - OneTime { at: String }, - Cron { cron: String }, + OneTime { + at: String, + }, + Cron { + cron: String, + #[serde(default = "default_routine_timezone")] + timezone: String, + }, +} + +pub fn default_routine_timezone() -> String { + "UTC".to_string() } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -171,6 +181,17 @@ pub struct RoutineDefinition { /// Next computed fire time (ISO 8601), for display only. #[serde(default, skip_serializing_if = "Option::is_none")] pub next_fire_at: Option, + /// Latest occurrence summary, projected from `routine_fires` for list UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_work_item_id: Option, pub created_at: String, pub updated_at: String, } diff --git a/src-tauri/crates/project-management/src/routine_service/convert.rs b/src-tauri/crates/project-management/src/routine_service/convert.rs index 9a869efb13..b147f8284a 100644 --- a/src-tauri/crates/project-management/src/routine_service/convert.rs +++ b/src-tauri/crates/project-management/src/routine_service/convert.rs @@ -142,7 +142,10 @@ pub fn convert_definition( .to_string(), ); } - if !matches!(definition.run_template.workspace, RoutineWorkspaceTarget::None) { + if !matches!( + definition.run_template.workspace, + RoutineWorkspaceTarget::None + ) { warnings.push( "workspace/worktree target dropped from the portable spec; re-express as an execution binding" .to_string(), @@ -161,9 +164,9 @@ pub fn convert_definition( } let activation = match &definition.trigger { - RoutineTrigger::Cron { cron } => Activation::Schedule { + RoutineTrigger::Cron { cron, timezone } => Activation::Schedule { cron: cron.clone(), - timezone: "UTC".to_string(), + timezone: timezone.clone(), policies, }, RoutineTrigger::OneTime { at } => { diff --git a/src-tauri/crates/project-management/src/routine_service/spec.rs b/src-tauri/crates/project-management/src/routine_service/spec.rs index 42c7676fbb..4f2debd26d 100644 --- a/src-tauri/crates/project-management/src/routine_service/spec.rs +++ b/src-tauri/crates/project-management/src/routine_service/spec.rs @@ -233,7 +233,11 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { ); } if !step_ids.insert(step.id.clone()) { - push(&mut violations, &path, format!("duplicate step id '{}'", step.id)); + push( + &mut violations, + &path, + format!("duplicate step id '{}'", step.id), + ); } } @@ -249,15 +253,14 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { continue; } if !step_ids.contains(need) { - push( - &mut violations, - &path, - format!("unknown step '{need}'"), - ); + push(&mut violations, &path, format!("unknown step '{need}'")); continue; } *in_degree.entry(step.id.as_str()).or_insert(0) += 1; - dependents.entry(need.as_str()).or_default().push(step.id.as_str()); + dependents + .entry(need.as_str()) + .or_default() + .push(step.id.as_str()); } } let mut queue: Vec<&str> = in_degree @@ -304,7 +307,11 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { }; if let Some(name) = inner.strip_prefix("inputs.") { if !file.spec.inputs.contains_key(name) { - push(&mut violations, &path, format!("unknown routine input '{name}'")); + push( + &mut violations, + &path, + format!("unknown routine input '{name}'"), + ); } continue; } @@ -331,7 +338,9 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { push( &mut violations, &path, - format!("step '{source_id}' declares no output '{output_name}'"), + format!( + "step '{source_id}' declares no output '{output_name}'" + ), ); } } @@ -366,6 +375,12 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { } if timezone.trim().is_empty() { push(&mut violations, &path, "timezone is required".into()); + } else if timezone.parse::().is_err() { + push( + &mut violations, + &path, + format!("timezone '{timezone}' must be a valid IANA timezone"), + ); } } } @@ -416,7 +431,9 @@ mod tests { file.spec.steps[1].needs = vec!["missing-step".to_string()]; let violations = validate(&file); assert!( - violations.iter().any(|v| v.message.contains("unknown step")), + violations + .iter() + .any(|v| v.message.contains("unknown step")), "{violations:?}" ); } diff --git a/src-tauri/crates/project-management/src/routine_service/tests.rs b/src-tauri/crates/project-management/src/routine_service/tests.rs index 611b3ed143..412e280774 100644 --- a/src-tauri/crates/project-management/src/routine_service/tests.rs +++ b/src-tauri/crates/project-management/src/routine_service/tests.rs @@ -41,6 +41,29 @@ fn apply_bumps_revision_when_the_body_changes() { assert_ne!(first.spec_hash, second.spec_hash); } +#[test] +fn schedule_activation_rejects_an_invalid_timezone() { + let mut file = fixture(); + let activation = file + .spec + .activations + .iter_mut() + .find_map(|activation| match activation { + spec::Activation::Schedule { timezone, .. } => Some(timezone), + _ => None, + }) + .expect("fixture has a schedule activation"); + *activation = "Mars/Olympus".to_string(); + + let violations = spec::validate(&file); + assert!( + violations + .iter() + .any(|violation| violation.message.contains("valid IANA timezone")), + "{violations:?}" + ); +} + #[test] fn invoke_materializes_the_work_graph_with_durable_edges() { let _sandbox = test_env::sandbox(); @@ -54,23 +77,39 @@ fn invoke_materializes_the_work_graph_with_durable_edges() { // Root carries the substituted template. let root = crate::projects::io::read_work_item("demo", &run.root_short_id).expect("root"); - assert!(root.frontmatter.title.contains("REQ-001"), "{}", root.frontmatter.title); + assert!( + root.frontmatter.title.contains("REQ-001"), + "{}", + root.frontmatter.title + ); // One generated child per step, parented to the root. assert_eq!(run.steps.len(), 3); for (_, child_id) in &run.steps { let child = crate::projects::io::read_work_item("demo", child_id).expect("child"); - assert_eq!(child.frontmatter.parent.as_deref(), Some(run.root_short_id.as_str())); + assert_eq!( + child.frontmatter.parent.as_deref(), + Some(run.root_short_id.as_str()) + ); } // Dependency edges are durable relations: review-impact depends_on // collect-deliverables; every child is generated_by the run. - let review_child = &run.steps.iter().find(|(id, _)| id == "review-impact").unwrap().1; - let collect_child = &run.steps.iter().find(|(id, _)| id == "collect-deliverables").unwrap().1; + let review_child = &run + .steps + .iter() + .find(|(id, _)| id == "review-impact") + .unwrap() + .1; + let collect_child = &run + .steps + .iter() + .find(|(id, _)| id == "collect-deliverables") + .unwrap() + .1; let relations = crate::work_service::list_work_item_relations(review_child).expect("relations"); let has_dep = relations.iter().any(|r| { - r["kind"] == "depends_on" - && r["targetRef"] == format!("work://demo/{}", collect_child) + r["kind"] == "depends_on" && r["targetRef"] == format!("work://demo/{}", collect_child) }); assert!(has_dep, "{relations:?}"); let has_run = relations @@ -106,8 +145,8 @@ fn invoke_validates_inputs_against_the_snapshot_contract() { let mut inputs = std::collections::BTreeMap::new(); inputs.insert("requirement_id".to_string(), "REQ-001".to_string()); inputs.insert("nonsense".to_string(), "x".to_string()); - let unknown = - invoke(&file.metadata.name, "demo", &inputs, None, None).expect_err("unknown input rejected"); + let unknown = invoke(&file.metadata.name, "demo", &inputs, None, None) + .expect_err("unknown input rejected"); assert!(unknown.starts_with(error::INPUTS_INVALID), "{unknown}"); } @@ -127,6 +166,7 @@ fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { enabled: true, trigger: RoutineTrigger::Cron { cron: "0 9 * * 1-5".to_string(), + timezone: "America/Vancouver".to_string(), }, run_template: RoutineRunTemplate { prompt: "Do the thing".to_string(), @@ -151,6 +191,11 @@ fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { }, last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), }; @@ -235,7 +280,8 @@ fn has_active_run_writes_back_failed_and_cancelled_outcomes() { assert!(!has_active_run(&file.metadata.name).expect("failed run is not active")); assert_eq!(stored_run_status(&failed_run.run_id), "failed"); - let cancelled_run = invoke(&file.metadata.name, "demo", &inputs, None, None).expect("invoke again"); + let cancelled_run = + invoke(&file.metadata.name, "demo", &inputs, None, None).expect("invoke again"); for (index, (_, child_id)) in cancelled_run.steps.iter().enumerate() { let status = if index == 0 { "cancelled" } else { "done" }; set_child_status("demo", child_id, status); @@ -261,6 +307,7 @@ fn convert_all_keeps_the_legacy_row_enabled_without_a_scope_binding() { enabled: true, trigger: RoutineTrigger::Cron { cron: "0 9 * * 1-5".to_string(), + timezone: "UTC".to_string(), }, run_template: RoutineRunTemplate { prompt: "Do the thing".to_string(), @@ -286,6 +333,11 @@ fn convert_all_keeps_the_legacy_row_enabled_without_a_scope_binding() { }, last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), }; @@ -302,7 +354,10 @@ fn convert_all_keeps_the_legacy_row_enabled_without_a_scope_binding() { "scope-less conversion must keep its legacy driver" ); let bound_after = crate::projects::io::read_routine(&bound.id).expect("read"); - assert!(!bound_after.enabled, "scope-bound conversion hands over to the portable pass"); + assert!( + !bound_after.enabled, + "scope-bound conversion hands over to the portable pass" + ); } #[test] @@ -316,7 +371,10 @@ fn apply_rejects_invalid_specs_with_structured_violations() { err.starts_with(error::SPEC_INVALID), "typed sentinel expected: {err}" ); - assert!(err.contains("cycle"), "violation payload rides along: {err}"); + assert!( + err.contains("cycle"), + "violation payload rides along: {err}" + ); } #[test] @@ -344,8 +402,14 @@ fn invoke_with_key_replays_instead_of_reinvoking() { let mut other_inputs = inputs.clone(); other_inputs.insert("requirement_id".to_string(), "REQ-002".to_string()); - let conflict = invoke(&file.metadata.name, "demo", &other_inputs, None, Some("fire-1")) - .expect_err("different request on the same key"); + let conflict = invoke( + &file.metadata.name, + "demo", + &other_inputs, + None, + Some("fire-1"), + ) + .expect_err("different request on the same key"); assert!( conflict.starts_with(crate::work_service::error::IDEMPOTENCY_CONFLICT), "{conflict}" diff --git a/src/api/http/project/types/routines.ts b/src/api/http/project/types/routines.ts index bef3b0ad01..bbb15f8fc6 100644 --- a/src/api/http/project/types/routines.ts +++ b/src/api/http/project/types/routines.ts @@ -10,7 +10,7 @@ export interface WorkItemSchedule { export type RoutineTrigger = | { kind: "one_time"; at: string } - | { kind: "cron"; cron: string }; + | { kind: "cron"; cron: string; timezone: string }; export const ROUTINE_FIRE_STATUS = { PENDING: "pending", @@ -120,6 +120,12 @@ export interface RoutineDefinition { lastEvaluatedAt?: string; /** Next computed fire time (ISO 8601), backend-managed, display only. */ nextFireAt?: string; + /** Latest durable occurrence summary, projected by the backend. */ + lastFireAt?: string; + lastFireStatus?: RoutineFireStatus; + lastFireError?: string; + lastFireSessionId?: string; + lastFireWorkItemId?: string; createdAt: string; updatedAt: string; } diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index c92906763f..7af62a0947 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -221,6 +221,7 @@ export function TableBody({ setHoverSuppressedRowKey(rowKey); toggleRowExpand(rowKey); }} + aria-label={isExpanded ? "Collapse row" : "Expand row"} aria-expanded={isExpanded} > {isExpanded ? ( diff --git a/src/hooks/geo/useTimezoneSelect.ts b/src/hooks/geo/useTimezoneSelect.ts index 36e601cead..2a00f83ae2 100644 --- a/src/hooks/geo/useTimezoneSelect.ts +++ b/src/hooks/geo/useTimezoneSelect.ts @@ -147,9 +147,32 @@ export function useTimezoneSelect({ null ); - const baseOptions = excludeAuto - ? TIMEZONE_OPTIONS.filter((opt) => opt.value !== "auto") - : TIMEZONE_OPTIONS; + const baseOptions = useMemo(() => { + const configuredOptions = excludeAuto + ? TIMEZONE_OPTIONS.filter((option) => option.value !== "auto") + : TIMEZONE_OPTIONS; + if ( + value === "auto" || + value === "utc" || + configuredOptions.some((option) => option.value === value) + ) { + return configuredOptions; + } + + const city = value.split("/").at(-1)?.replace(/_/g, " ") || value; + const { offset, offsetMinutes } = getTimezoneOffset(value); + return [ + ...configuredOptions, + { + value, + label: city, + labelKey: value.toLowerCase().replace(/\//g, "_"), + offset, + offsetMinutes, + region: value.split("/")[0], + }, + ]; + }, [excludeAuto, value]); /** Resolve localized label for a timezone item */ const getLocalizedLabel = useCallback( diff --git a/src/hooks/navigation/index.ts b/src/hooks/navigation/index.ts index 98ed3b6949..45f54324cd 100644 --- a/src/hooks/navigation/index.ts +++ b/src/hooks/navigation/index.ts @@ -8,3 +8,5 @@ export type { } from "./useAppNavigation"; export { useWizardParam } from "./useWizardParam"; export type { UseWizardParamReturn } from "./useWizardParam"; +export { useRoutineResultNavigation } from "./useRoutineResultNavigation"; +export type { RoutineResultTarget } from "./useRoutineResultNavigation"; diff --git a/src/hooks/navigation/useRoutineResultNavigation.test.ts b/src/hooks/navigation/useRoutineResultNavigation.test.ts new file mode 100644 index 0000000000..eb8528ea0a --- /dev/null +++ b/src/hooks/navigation/useRoutineResultNavigation.test.ts @@ -0,0 +1,83 @@ +import { Provider } from "jotai"; +import React from "react"; +import { renderToString } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROUTES } from "@src/config/routes"; +import { chatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { stationModeAtom } from "@src/store/ui/simulatorAtom"; +import { createInstrumentedStore } from "@src/util/core/state/instrumentedStore"; + +import { useRoutineResultNavigation } from "./useRoutineResultNavigation"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + readStandaloneWorkItem: vi.fn(), + workItemDataToUI: vi.fn(), +})); + +vi.mock("react-router-dom", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => mocks.navigate, +})); + +vi.mock("@src/api/http/project", () => ({ + projectApi: { + readStandaloneWorkItem: mocks.readStandaloneWorkItem, + }, + workItemDataToUI: mocks.workItemDataToUI, +})); + +describe("useRoutineResultNavigation", () => { + beforeEach(() => { + mocks.navigate.mockReset(); + mocks.readStandaloneWorkItem.mockReset(); + mocks.workItemDataToUI.mockReset(); + }); + + it("opens a standalone Work Item in My Station and navigates there", async () => { + const storedWorkItem = { + frontmatter: { + id: "work-item-1", + short_id: "WI-0001", + title: "Routine result", + }, + body: "", + filename: "WI-0001.md", + }; + const uiWorkItem = { + session_id: "WI-0001", + name: "Routine result", + }; + mocks.readStandaloneWorkItem.mockResolvedValue(storedWorkItem); + mocks.workItemDataToUI.mockReturnValue(uiWorkItem); + + const store = createInstrumentedStore(); + let openResult: ReturnType | undefined; + + function HookProbe(): null { + // Test probe: capture the hook API synchronously from server rendering. + // eslint-disable-next-line react-hooks/globals + openResult = useRoutineResultNavigation(); + return null; + } + + renderToString( + React.createElement(Provider, { store }, React.createElement(HookProbe)) + ); + + await openResult?.({ workItemId: "WI-0001" }); + + expect(mocks.readStandaloneWorkItem).toHaveBeenCalledWith("WI-0001"); + expect(store.get(stationModeAtom)).toBe("my-station"); + expect(store.get(chatPanelTabsAtom)).toMatchObject({ + tabs: expect.arrayContaining([ + expect.objectContaining({ + type: "work-item", + workItem: expect.objectContaining({ shortId: "WI-0001" }), + }), + ]), + }); + expect(mocks.navigate).toHaveBeenCalledWith(ROUTES.workStation.base.path); + }); +}); diff --git a/src/hooks/navigation/useRoutineResultNavigation.ts b/src/hooks/navigation/useRoutineResultNavigation.ts new file mode 100644 index 0000000000..59327e6be1 --- /dev/null +++ b/src/hooks/navigation/useRoutineResultNavigation.ts @@ -0,0 +1,112 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { projectApi, workItemDataToUI } from "@src/api/http/project"; +import { ROUTES } from "@src/config/routes"; +import { createLogger } from "@src/hooks/logger"; +import { + openOrFocusSessionInChatPanelTabAtom, + openWorkItemInChatPanelTabAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; +import { sessionsAtom } from "@src/store/session"; +import { activeStationChatVisibleAtom } from "@src/store/ui/chatPanelAtom"; +import { stationModeAtom } from "@src/store/ui/simulatorAtom"; + +const log = createLogger("RoutineResultNavigation"); +const EMPTY_RELATION_MAPS = { + labelMap: new Map(), + memberMap: new Map(), +}; + +export interface RoutineResultTarget { + sessionId?: string; + workItemId?: string; + projectSlug?: string; +} + +/** Open a durable Routine result in My Station. */ +export function useRoutineResultNavigation(): ( + target: RoutineResultTarget +) => Promise { + const navigate = useNavigate(); + const sessions = useAtomValue(sessionsAtom); + const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom); + const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); + const setStationMode = useSetAtom(stationModeAtom); + const setStationChatVisible = useSetAtom(activeStationChatVisibleAtom); + + return useCallback( + async (target: RoutineResultTarget) => { + setStationMode("my-station"); + setStationChatVisible("my-station", true); + + if (target.sessionId) { + const session = sessions.find( + (candidate) => candidate.session_id === target.sessionId + ); + openSession({ + sessionId: target.sessionId, + sessionName: session?.name, + repoPath: session?.repoPath, + }); + navigate(ROUTES.workStation.base.path); + return; + } + + if (!target.workItemId) return; + + try { + if (target.projectSlug) { + const [projectResult, workItemResult] = await Promise.allSettled([ + projectApi.readProject(target.projectSlug), + projectApi.readWorkItem(target.projectSlug, target.workItemId), + ]); + if (workItemResult.status === "rejected") { + throw workItemResult.reason; + } + const project = + projectResult.status === "fulfilled" + ? projectResult.value + : undefined; + openWorkItem({ + workItem: workItemDataToUI( + workItemResult.value, + EMPTY_RELATION_MAPS + ), + shortId: workItemResult.value.frontmatter.short_id, + projectId: project?.meta.id ?? target.projectSlug, + projectSlug: project?.slug ?? target.projectSlug, + projectName: project?.meta.name ?? target.projectSlug, + orgId: project?.meta.org_id, + }); + navigate(ROUTES.workStation.base.path); + return; + } + + const workItem = await projectApi.readStandaloneWorkItem( + target.workItemId + ); + openWorkItem({ + workItem: workItemDataToUI(workItem, EMPTY_RELATION_MAPS), + shortId: workItem.frontmatter.short_id, + projectId: "", + projectSlug: "", + projectName: "Standalone Work Items", + }); + navigate(ROUTES.workStation.base.path); + } catch (error) { + log.warn("Failed to open Routine result", error); + throw error; + } + }, + [ + navigate, + openSession, + openWorkItem, + sessions, + setStationChatVisible, + setStationMode, + ] + ); +} diff --git a/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx b/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx index 1a62881588..3d90b71561 100644 --- a/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx +++ b/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx @@ -1,8 +1,10 @@ +import { ExternalLink } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { RoutineDefinition, RoutineFire } from "@src/api/http/project"; import { projectApi } from "@src/api/http/project"; +import Message from "@src/components/Message"; import SettingsTable, { SETTINGS_TABLE_CELL, SETTINGS_TABLE_COL, @@ -10,6 +12,7 @@ import SettingsTable, { } from "@src/components/SettingsTable"; import Switch from "@src/components/Switch"; import TabPill from "@src/components/TabPill"; +import { useRoutineResultNavigation } from "@src/hooks/navigation"; import { DETAIL_PANEL_TOKENS, DetailPanelContainer, @@ -55,20 +58,38 @@ const FIRE_STATUS_COLOR: Record = { queued: "bg-warning-6", }; +function getRoutineProjectSlug(routine: RoutineDefinition): string | undefined { + if (routine.outputPolicy.mode === "create_work_item") { + return routine.outputPolicy.createWorkItemProjectSlug; + } + if (routine.outputPolicy.mode === "update_existing_work_item") { + return routine.outputPolicy.updateWorkItemProjectSlug; + } + return undefined; +} + /** Expanded-row fire history list, lazily fetched per routine. */ -const RoutineFireHistory: React.FC<{ routineId: string }> = ({ routineId }) => { +const RoutineFireHistory: React.FC<{ routine: RoutineDefinition }> = ({ + routine, +}) => { const { t } = useTranslation("integrations"); const [fires, setFires] = useState(null); + const openResult = useRoutineResultNavigation(); useEffect(() => { let cancelled = false; - projectApi.listRoutineFires(routineId).then((result) => { - if (!cancelled) setFires(result); - }); + projectApi + .listRoutineFires(routine.id) + .then((result) => { + if (!cancelled) setFires(result); + }) + .catch(() => { + if (!cancelled) setFires([]); + }); return () => { cancelled = true; }; - }, [routineId]); + }, [routine.id, routine.lastFireAt]); if (fires === null) return null; if (fires.length === 0) { @@ -82,12 +103,12 @@ const RoutineFireHistory: React.FC<{ routineId: string }> = ({ routineId }) => { return (
{fires.slice(0, 20).map((fire) => (
= ({ routineId }) => { {new Date(fire.firedAt).toLocaleString()} {fire.sessionId && ( - {fire.sessionId} + )} {fire.workItemId && ( - {fire.workItemId} + + )} + {fire.error && ( + + {fire.error} + )}
))} @@ -111,7 +172,7 @@ const RoutineFireHistory: React.FC<{ routineId: string }> = ({ routineId }) => { function getTriggerLabel(routine: RoutineDefinition): string { if (routine.trigger.kind === "one_time") return `One-time: ${routine.trigger.at}`; - return `Cron: ${routine.trigger.cron}`; + return `Cron: ${routine.trigger.cron} · ${routine.trigger.timezone}`; } function getNextFireLabel(routine: RoutineDefinition): string | null { @@ -193,16 +254,38 @@ export const RoutinesTable: React.FC = ({ ), }, { - key: "target", - label: t("routineFields.target"), + key: "lastRun", + label: t("common:schedule.lastRun"), width: SETTINGS_TABLE_COL.valueLg, sorter: (rowA, rowB) => - getRoutineTargetLabel(rowA).localeCompare( - getRoutineTargetLabel(rowB) - ), + (rowA.lastFireAt ?? "").localeCompare(rowB.lastFireAt ?? ""), + renderCell: (routine) => ( +
+ {routine.lastFireStatus ? ( + + ) : ( + + )} + {routine.lastFireAt && ( + + {new Date(routine.lastFireAt).toLocaleString()} + + )} +
+ ), + }, + { + key: "nextRun", + label: t("routineFields.nextFire"), + width: SETTINGS_TABLE_COL.valueLg, + sorter: (rowA, rowB) => + (rowA.nextFireAt ?? "").localeCompare(rowB.nextFireAt ?? ""), renderCell: (routine) => ( - {getRoutineTargetLabel(routine)} + {getNextFireLabel(routine) ?? "—"} ), }, @@ -267,11 +350,11 @@ export const RoutinesTable: React.FC = ({ columns={routinesColumns} rows={filteredRoutines} getRowKey={(routine) => routine.id} - onRowClick={(routine) => - onSelectRoutine( - selectedRowId === routine.id ? null : routine.id - ) - } + onRowClick={(routine) => { + const isExpanded = expandedKeys.includes(routine.id); + setExpandedKeys(isExpanded ? [] : [routine.id]); + onSelectRoutine(isExpanded ? null : routine.id); + }} rowClassName={selectedRowClassName( (routine: RoutineDefinition) => routine.id, selectedRowId @@ -356,7 +439,7 @@ export const RoutinesTable: React.FC = ({ label={t("routineFields.fireHistory")} layout="vertical" > - + } diff --git a/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts b/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts index 8ba590cc00..6a2852f635 100644 --- a/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts +++ b/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts @@ -1,12 +1,14 @@ import { listen } from "@tauri-apps/api/event"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { type RoutineDefinition, invalidateProjectCache, projectApi, } from "@src/api/http/project"; +import Message from "@src/components/Message"; import { WIZARD_IDS } from "@src/config/mainAppPaths"; import { useWizardParam } from "@src/hooks/navigation"; import { @@ -34,6 +36,7 @@ export function useRoutinesState( category: IntegrationCategory, setDetailMode: (mode: DetailMode) => void ): UseRoutinesStateReturn { + const { t } = useTranslation("integrations"); const routinesActive = category === "routines"; const [routines, setRoutines] = useState([]); const [routinesLoading, setRoutinesLoading] = useState(false); @@ -160,9 +163,36 @@ export function useRoutinesState( const handleFire = useCallback(async () => { if (!selectedRoutine) return; - await projectApi.fireRoutine(selectedRoutine.id); - await refreshRoutines(); - }, [selectedRoutine, refreshRoutines]); + try { + const result = await projectApi.fireRoutine(selectedRoutine.id); + await refreshRoutines(); + if ( + result.fire.status === "queued" || + result.fire.status === "coalesced" || + result.fire.status === "skipped" + ) { + Message.info( + t("routineFields.fireAccepted", { + defaultValue: `Run ${result.fire.status}`, + }) + ); + } else { + Message.success( + t("routineFields.fireStarted", { + defaultValue: "Routine run started", + }) + ); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + Message.error( + t("routineFields.fireError", { + defaultValue: `Could not start the Routine: ${detail}`, + }), + 5000 + ); + } + }, [selectedRoutine, refreshRoutines, t]); const openNewRoutineWizard = useCallback(() => { openWizard(WIZARD_IDS.ROUTINE_ADD); diff --git a/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx b/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx index 3b66f67fcb..821de0551b 100644 --- a/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx +++ b/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx @@ -18,6 +18,8 @@ import { projectApi, } from "@src/api/http/project"; import Button from "@src/components/Button"; +import Message from "@src/components/Message"; +import { useRoutineResultNavigation } from "@src/hooks/navigation"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; const STATUS_TONE: Record = { @@ -41,9 +43,27 @@ interface RunRowProps { } const RunRow: React.FC = ({ run }) => { + const { t } = useTranslation("sessions"); const [expanded, setExpanded] = useState(false); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState(null); + const openResult = useRoutineResultNavigation(); + + const openWorkItem = useCallback( + (workItemId: string) => { + void openResult({ + workItemId, + projectSlug: run.scopeId, + }).catch(() => + Message.error( + t("kanban.openRoutineWorkItemError", { + defaultValue: "Could not open the Work Item", + }) + ) + ); + }, + [openResult, run.scopeId, t] + ); const toggle = useCallback(() => { setExpanded((previous) => !previous); @@ -112,19 +132,23 @@ const RunRow: React.FC = ({ run }) => { ) : (
    {detail.workItems.map((item) => ( -
  • - - {item.shortId} - - - {item.title} - - - {item.portableState ?? item.status} - +
  • +
  • ))}
diff --git a/src/scaffold/WizardSystem/variants/Policy/RoutineWizard/RoutineBasicsSection.tsx b/src/scaffold/WizardSystem/variants/Policy/RoutineWizard/RoutineBasicsSection.tsx index 400b38c351..297133be70 100644 --- a/src/scaffold/WizardSystem/variants/Policy/RoutineWizard/RoutineBasicsSection.tsx +++ b/src/scaffold/WizardSystem/variants/Policy/RoutineWizard/RoutineBasicsSection.tsx @@ -7,6 +7,7 @@ import Select from "@src/components/Select"; import Textarea from "@src/components/Textarea"; import TimePicker from "@src/components/TimePicker"; import { resolveAgentIcon } from "@src/config/agentIcons"; +import { useTimezoneSelect } from "@src/hooks/geo/useTimezoneSelect"; import { type CronParts, type ScheduleFrequency, @@ -38,6 +39,13 @@ const RoutineBasicsSection: React.FC = ({ onOpenAgentPalette, }) => { const { t } = useTranslation("integrations"); + const timezoneSelectProps = useTimezoneSelect({ + value: draft.timezone, + onChange: (value) => updateDraft("timezone", value), + excludeAuto: true, + offsetPrefix: "UTC", + style: SECTION_CONTROL_STYLE, + }); const triggerOptions = useMemo( () => [ @@ -239,26 +247,40 @@ const RoutineBasicsSection: React.FC = ({ variant="ghost" /> + + updateDraft("cron", value)} - placeholder="0 9 * * 1" - size="default" - style={SECTION_CONTROL_STYLE} - autoComplete="off" - autoCorrect="off" - spellCheck={false} - data-testid="routine-wizard-cron-input" - /> - + <> + + updateDraft("cron", value)} + placeholder="0 9 * * 1" + size="default" + style={SECTION_CONTROL_STYLE} + autoComplete="off" + autoCorrect="off" + spellCheck={false} + data-testid="routine-wizard-cron-input" + /> + + +