From d105df490428e6ba04bf53e26ff5adb67c205ac5 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 14:48:41 -0700 Subject: [PATCH 01/13] feat: add automation run history UI --- .../src/first_party_tools/schemas.rs | 6 + .../first_party_tools/trigger_management.rs | 53 +- .../tests/first_party_builtin_tools.rs | 171 +++- crates/ironclaw_product_workflow/src/lib.rs | 13 +- .../src/reborn_services.rs | 28 +- .../src/reborn_services/types.rs | 30 +- .../src/webui_inbound.rs | 2 + .../tests/reborn_services_contract.rs | 131 ++- .../src/automation.rs | 170 +++- .../src/trigger_poller.rs | 9 +- crates/ironclaw_triggers/src/lib.rs | 338 +++++++- crates/ironclaw_triggers/src/libsql.rs | 816 ++++++++++++++---- crates/ironclaw_triggers/src/postgres.rs | 354 +++++++- .../src/worker/active_cleanup.rs | 26 +- crates/ironclaw_triggers/src/worker/ports.rs | 4 +- crates/ironclaw_triggers/src/worker/tests.rs | 100 ++- .../tests/repository_contract.rs | 254 ++++++ crates/ironclaw_webui_v2/CLAUDE.md | 2 +- crates/ironclaw_webui_v2/src/handlers.rs | 13 +- .../tests/webui_v2_handlers_contract.rs | 54 +- .../static/js/i18n/en.js | 28 +- .../static/js/lib/api.js | 3 +- .../static/js/lib/api.test.mjs | 4 +- .../js/pages/automations/automations-page.js | 16 + .../components/automations-list.js | 318 +++++-- .../components/automations-summary-strip.js | 19 +- .../pages/automations/hooks/useAutomations.js | 7 +- .../automations/lib/automations-presenters.js | 127 ++- .../lib/automations-presenters.test.mjs | 75 +- docs/reborn/contracts/triggers.md | 24 +- 30 files changed, 2831 insertions(+), 364 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs index c5c47f2412f..fe33d81418e 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs @@ -322,6 +322,12 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option "minimum": 0, "maximum": 100, "description": "Maximum triggers to return. Defaults to 100." + }, + "run_limit": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Maximum recent runs to embed per trigger. Defaults to 25." } }, "additionalProperties": false diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs b/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs index aa12894db86..df5ba105abf 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs @@ -9,7 +9,7 @@ use ironclaw_host_api::{ }; use ironclaw_triggers::{ TriggerCompletionPolicy, TriggerError, TriggerId, TriggerRecord, TriggerRepository, - TriggerSchedule, TriggerSourceKind, TriggerState, + TriggerRunHistoryStatus, TriggerRunRecord, TriggerSchedule, TriggerSourceKind, TriggerState, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -25,6 +25,8 @@ use super::{ }; const TRIGGER_LIST_LIMIT: usize = 100; +const TRIGGER_RUN_HISTORY_DEFAULT_LIMIT: usize = 25; +const TRIGGER_RUN_HISTORY_MAX_LIMIT: usize = 100; pub const TRIGGER_CREATE_CAPABILITY_ID: &str = "builtin.trigger_create"; pub const TRIGGER_LIST_CAPABILITY_ID: &str = "builtin.trigger_list"; @@ -205,6 +207,7 @@ struct TriggerRemoveInput { #[derive(Deserialize)] struct TriggerListInput { limit: Option, + run_limit: Option, } async fn create_trigger( @@ -256,7 +259,7 @@ async fn create_trigger( return Err(hook_error); } Ok(json!({ - "trigger": trigger_output(&record), + "trigger": trigger_output(&record, &[]), })) } @@ -270,6 +273,10 @@ async fn list_triggers( .limit .unwrap_or(TRIGGER_LIST_LIMIT) .min(TRIGGER_LIST_LIMIT); + let run_limit = input + .run_limit + .unwrap_or(TRIGGER_RUN_HISTORY_DEFAULT_LIMIT) + .min(TRIGGER_RUN_HISTORY_MAX_LIMIT); let records = repository .list_scoped_triggers( scope.tenant_id.clone(), @@ -279,11 +286,25 @@ async fn list_triggers( limit, ) .await - .map_err(|error| trigger_repository_error("list_scoped_triggers", error))? + .map_err(|error| trigger_repository_error("list_scoped_triggers", error))?; + let trigger_ids = records + .iter() + .map(|record| record.trigger_id) + .collect::>(); + let mut runs_by_trigger = repository + .list_trigger_run_history_batch(scope.tenant_id.clone(), &trigger_ids, run_limit) + .await + .map_err(|error| trigger_repository_error("list_trigger_run_history_batch", error))?; + let output = records .into_iter() - .map(|record| trigger_output(&record)) + .map(|record| { + let runs = runs_by_trigger + .remove(&record.trigger_id) + .unwrap_or_default(); + trigger_output(&record, &runs) + }) .collect::>(); - Ok(json!({ "triggers": records })) + Ok(json!({ "triggers": output })) } async fn remove_trigger( @@ -309,7 +330,7 @@ async fn remove_trigger( })) } -fn trigger_output(record: &TriggerRecord) -> Value { +fn trigger_output(record: &TriggerRecord, recent_runs: &[TriggerRunRecord]) -> Value { json!({ "trigger_id": record.trigger_id.to_string(), "agent_id": record.agent_id.as_ref().map(|id| id.as_str()), @@ -322,11 +343,31 @@ fn trigger_output(record: &TriggerRecord) -> Value { "next_run_at": record.next_run_at, "last_run_at": record.last_run_at, "last_status": record.last_status, + "recent_runs": recent_runs.iter().map(trigger_run_output).collect::>(), "is_active": record.has_active_fire(), "created_at": record.created_at, }) } +fn trigger_run_output(run: &TriggerRunRecord) -> Value { + json!({ + "fire_slot": run.fire_slot, + "run_id": run.run_id.as_ref().map(ToString::to_string), + "thread_id": run.thread_id.as_str(), + "status": trigger_run_history_status_text(run.status), + "submitted_at": run.submitted_at, + "completed_at": run.completed_at, + }) +} + +fn trigger_run_history_status_text(status: TriggerRunHistoryStatus) -> &'static str { + match status { + TriggerRunHistoryStatus::Running => "running", + TriggerRunHistoryStatus::Ok => "ok", + TriggerRunHistoryStatus::Error => "error", + } +} + fn trigger_remove_output(record: &TriggerRecord) -> Value { json!({ "trigger_id": record.trigger_id.to_string(), diff --git a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs index db1b8050cc6..0b5694f2df4 100644 --- a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs +++ b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs @@ -50,8 +50,9 @@ use ironclaw_network::{ use ironclaw_resources::{InMemoryResourceGovernor, ResourceAccount}; use ironclaw_secrets::InMemorySecretStore; use ironclaw_triggers::{ - InMemoryTriggerRepository, MAX_TRIGGER_NAME_BYTES, MAX_TRIGGER_PROMPT_BYTES, TriggerError, - TriggerRecord, TriggerRepository, + ClaimDueFireRequest, ClearActiveFireRequest, FireAcceptedRequest, InMemoryTriggerRepository, + MAX_TRIGGER_NAME_BYTES, MAX_TRIGGER_PROMPT_BYTES, TriggerError, TriggerRecord, + TriggerRepository, TriggerRunHistoryStatus, }; use ironclaw_trust::{ AdminConfig, AdminEntry, AuthorityCeiling, EffectiveTrustClass, HostTrustAssignment, @@ -951,6 +952,147 @@ async fn builtin_trigger_list_applies_user_surface_limit_boundaries() { assert_eq!(clamped["triggers"].as_array().unwrap().len(), 100); } +#[tokio::test] +async fn builtin_trigger_list_embeds_recent_run_history_with_run_limit() { + let repository = Arc::new(InMemoryTriggerRepository::default()); + let runtime = runtime_with_trigger_repository(repository.clone()); + let context = execution_context([TRIGGER_CREATE_CAPABILITY_ID, TRIGGER_LIST_CAPABILITY_ID]); + + invoke_with_context( + &runtime, + TRIGGER_CREATE_CAPABILITY_ID, + json!({ + "name": "Historical trigger", + "prompt": "Create history rows", + "cron": "0 8 * * *" + }), + context.clone(), + ) + .await + .unwrap(); + + let record = repository + .list_triggers(context.resource_scope.tenant_id.clone()) + .await + .unwrap() + .pop() + .expect("persisted trigger"); + let first_fire_slot = record.next_run_at; + let first_run_id = TurnRunId::new(); + repository + .claim_due_fire(ClaimDueFireRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot: first_fire_slot, + now: first_fire_slot, + }) + .await + .unwrap(); + repository + .mark_fire_accepted(FireAcceptedRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot: first_fire_slot, + run_id: first_run_id, + submitted_at: first_fire_slot + chrono::Duration::seconds(1), + next_run_at: first_fire_slot + chrono::Duration::minutes(1), + }) + .await + .unwrap(); + repository + .clear_active_fire(ClearActiveFireRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot: first_fire_slot, + run_id: first_run_id, + status: TriggerRunHistoryStatus::Ok, + }) + .await + .unwrap(); + + let second_fire_slot = first_fire_slot + chrono::Duration::minutes(1); + let second_run_id = TurnRunId::new(); + repository + .claim_due_fire(ClaimDueFireRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot: second_fire_slot, + now: second_fire_slot, + }) + .await + .unwrap(); + repository + .mark_fire_accepted(FireAcceptedRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot: second_fire_slot, + run_id: second_run_id, + submitted_at: second_fire_slot + chrono::Duration::seconds(1), + next_run_at: second_fire_slot + chrono::Duration::minutes(1), + }) + .await + .unwrap(); + repository + .clear_active_fire(ClearActiveFireRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot: second_fire_slot, + run_id: second_run_id, + status: TriggerRunHistoryStatus::Error, + }) + .await + .unwrap(); + + let third_fire_slot = second_fire_slot + chrono::Duration::minutes(1); + let third_run_id = TurnRunId::new(); + repository + .claim_due_fire(ClaimDueFireRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot: third_fire_slot, + now: third_fire_slot, + }) + .await + .unwrap(); + repository + .mark_fire_accepted(FireAcceptedRequest { + tenant_id: record.tenant_id, + trigger_id: record.trigger_id, + fire_slot: third_fire_slot, + run_id: third_run_id, + submitted_at: third_fire_slot + chrono::Duration::seconds(1), + next_run_at: third_fire_slot + chrono::Duration::minutes(1), + }) + .await + .unwrap(); + + let listed = invoke_with_context( + &runtime, + TRIGGER_LIST_CAPABILITY_ID, + json!({ "run_limit": 3 }), + context, + ) + .await + .unwrap(); + + let runs = listed["triggers"][0]["recent_runs"].as_array().unwrap(); + assert_eq!(runs.len(), 3); + assert_eq!(runs[0]["run_id"], json!(third_run_id.to_string())); + assert_eq!(runs[0]["status"], json!("running")); + assert_eq!(runs[0]["completed_at"], Value::Null); + assert_eq!(runs[1]["run_id"], json!(second_run_id.to_string())); + assert_eq!(runs[1]["status"], json!("error")); + assert_ne!(runs[1]["completed_at"], Value::Null); + assert_eq!(runs[2]["run_id"], json!(first_run_id.to_string())); + assert_eq!(runs[2]["status"], json!("ok")); + assert_ne!(runs[2]["completed_at"], Value::Null); + assert_eq!( + runs[0]["thread_id"].as_str().unwrap().len(), + 64, + "trigger route thread ids are deterministic hex identifiers" + ); +} + #[tokio::test] async fn builtin_trigger_remove_rejects_invalid_trigger_id() { let repository = Arc::new(InMemoryTriggerRepository::default()); @@ -994,6 +1136,31 @@ async fn builtin_trigger_list_rejects_non_integer_limit() { ); } +#[tokio::test] +async fn builtin_trigger_list_rejects_non_integer_run_limit() { + let repository = Arc::new(InMemoryTriggerRepository::default()); + let runtime = runtime_with_trigger_repository(repository.clone()); + let context = execution_context([TRIGGER_LIST_CAPABILITY_ID]); + + let error = invoke_with_context( + &runtime, + TRIGGER_LIST_CAPABILITY_ID, + json!({ "run_limit": "many" }), + context.clone(), + ) + .await + .unwrap_err(); + + assert_eq!(error, RuntimeFailureKind::InvalidInput); + assert!( + repository + .list_triggers(context.resource_scope.tenant_id) + .await + .unwrap() + .is_empty() + ); +} + #[tokio::test] async fn builtin_trigger_remove_rejects_malformed_input() { let repository = Arc::new(InMemoryTriggerRepository::default()); diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index 4949b8be7b0..b2a67aef41e 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -135,15 +135,18 @@ pub use ironclaw_product_adapters::{ ProgressKind, ProgressUpdateView, ProjectionCursor, }; pub use reborn_services::{ - AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, AutomationProductFacade, - CodexLoginStart, ConnectableChannelsProductFacade, ExtensionCredentialSetupService, + AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, + AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE, + AutomationListRequest, AutomationProductFacade, CodexLoginStart, + ConnectableChannelsProductFacade, ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, LlmActiveSelection, LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult, OutboundPreferencesProductFacade, - ProductAgentBoundCaller, RebornAutomationInfo, RebornAutomationRunStatus, - RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, - RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, + ProductAgentBoundCaller, RebornAutomationInfo, RebornAutomationRecentRunInfo, + RebornAutomationRecentRunStatus, RebornAutomationRunStatus, RebornAutomationSource, + RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, + RebornChannelConnectStrategy, RebornConnectableChannelInfo, RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest, RebornDeleteThreadResponse, RebornExtensionActionResponse, RebornExtensionCredentialSetup, RebornExtensionInfo, RebornExtensionListResponse, RebornExtensionOnboardingPayload, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index e688bbf3156..cc70055f026 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -69,7 +69,8 @@ pub use llm_config::{ NearAiWalletLoginResult, SetActiveLlmRequest, UpsertLlmProviderRequest, }; pub use types::{ - RebornAutomationInfo, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, + RebornAutomationInfo, RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, + RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest, RebornDeleteThreadResponse, RebornExtensionActionResponse, @@ -327,12 +328,18 @@ impl ProductAgentBoundCaller { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutomationListRequest { + pub limit: usize, + pub run_limit: usize, +} + #[async_trait] pub trait AutomationProductFacade: Send + Sync { async fn list_automations( &self, caller: ProductAgentBoundCaller, - limit: usize, + request: AutomationListRequest, ) -> Result, RebornServicesError>; } @@ -350,7 +357,7 @@ impl AutomationProductFacade for UnsupportedAutomationProductFacade { async fn list_automations( &self, _caller: ProductAgentBoundCaller, - _limit: usize, + _request: AutomationListRequest, ) -> Result, RebornServicesError> { Err(automation_unavailable()) } @@ -1343,9 +1350,10 @@ impl RebornServicesApi for RebornServices { )); }; let limit = clamp_automation_list_limit(request.limit); + let run_limit = clamp_automation_run_limit(request.run_limit); let automations = self .automation_facade - .list_automations(caller, limit) + .list_automations(caller, AutomationListRequest { limit, run_limit }) .await?; Ok(RebornListAutomationsResponse { automations }) } @@ -2370,6 +2378,12 @@ pub const AUTOMATION_LIST_DEFAULT_PAGE_SIZE: u32 = 50; /// opaque cursor contract. pub const AUTOMATION_LIST_MAX_PAGE_SIZE: u32 = 100; +/// Default number of recent runs returned per automation row. +pub const AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE: u32 = 25; + +/// Hard ceiling for recent runs embedded in each automation row. +pub const AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE: u32 = 100; + /// Hard ceiling on summary artifacts returned per response. Summary /// artifacts are typically much smaller than the message transcript so /// this cap is generous; it exists to bound the worst case where a @@ -2388,6 +2402,12 @@ fn clamp_automation_list_limit(requested: Option) -> usize { clamped as usize } +fn clamp_automation_run_limit(requested: Option) -> usize { + let raw = requested.unwrap_or(AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE); + let clamped = raw.clamp(1, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE); + clamped as usize +} + /// Wire shape of the opaque timeline cursor. The browser does not need /// to interpret this; it just echoes the previous response's /// `next_cursor` back as the next request's `cursor`. diff --git a/crates/ironclaw_product_workflow/src/reborn_services/types.rs b/crates/ironclaw_product_workflow/src/reborn_services/types.rs index 7d1795bfb02..8974ec3450b 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/types.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/types.rs @@ -640,6 +640,29 @@ pub enum RebornAutomationRunStatus { Error, } +/// Browser-visible status for an individual automation run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RebornAutomationRecentRunStatus { + Running, + Ok, + Error, +} + +/// Browser-safe automation run projection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornAutomationRecentRunInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fire_slot: Option>, + pub status: RebornAutomationRecentRunStatus, + pub submitted_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, +} + /// Allowlisted browser-visible state for automation list projections. /// /// Unknown runtime states are collapsed to `unknown` so the browser DTO stays @@ -700,8 +723,9 @@ impl<'de> Deserialize<'de> for RebornAutomationState { /// Browser-safe automation row returned by the WebUI facade. /// -/// This deliberately exposes source, state, run timestamps, and sanitized -/// status only; trigger repository internals remain behind the product facade. +/// This deliberately exposes source, state, run timestamps, sanitized status, +/// and bounded recent-run history; trigger repository internals remain behind +/// the product facade. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RebornAutomationInfo { pub automation_id: String, @@ -715,6 +739,8 @@ pub struct RebornAutomationInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_status: Option, #[serde(default)] + pub recent_runs: Vec, + #[serde(default)] pub is_active: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub created_at: Option>, diff --git a/crates/ironclaw_product_workflow/src/webui_inbound.rs b/crates/ironclaw_product_workflow/src/webui_inbound.rs index 7244fbf0b36..1f9972d6cbe 100644 --- a/crates/ironclaw_product_workflow/src/webui_inbound.rs +++ b/crates/ironclaw_product_workflow/src/webui_inbound.rs @@ -108,6 +108,8 @@ pub struct WebUiListThreadsRequest { pub struct WebUiListAutomationsRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_limit: Option, } /// Browser body for WebUI extension-setup interaction. diff --git a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs index d73f3100562..fcfc9acfb36 100644 --- a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs @@ -17,9 +17,11 @@ use ironclaw_product_adapters::{ ProjectionStream, ProjectionSubscriptionRequest, ProtocolAuthFailure, RedactedString, }; use ironclaw_product_workflow::{ - AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, ApprovalInteractionDecision, - ApprovalInteractionService, AuthInteractionDecision, AuthInteractionService, - AutomationProductFacade, ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, + AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, + AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE, + ApprovalInteractionDecision, ApprovalInteractionService, AuthInteractionDecision, + AuthInteractionService, AutomationListRequest, AutomationProductFacade, + ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, LifecycleExtensionCredentialRequirement, LifecycleExtensionCredentialSetup, LifecycleExtensionOnboarding, LifecycleExtensionRuntimeKind, LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleInstalledExtensionSummary, @@ -28,7 +30,8 @@ use ironclaw_product_workflow::{ LifecycleProductResponse, LifecycleReadinessBlocker, ListPendingApprovalsRequest, ListPendingApprovalsResponse, ListPendingAuthInteractionsRequest, ListPendingAuthInteractionsResponse, OutboundPreferencesProductFacade, ProductAgentBoundCaller, - ProductWorkflowError, RebornAutomationInfo, RebornAutomationRunStatus, RebornAutomationSource, + ProductWorkflowError, RebornAutomationInfo, RebornAutomationRecentRunInfo, + RebornAutomationRecentRunStatus, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, RebornDeleteThreadRequest, RebornExtensionOnboardingState, RebornGetRunStateRequest, RebornOutboundDeliveryModality, @@ -746,6 +749,7 @@ impl LifecycleProductFacade for ListingLifecycleFacade { struct ListAutomationCall { caller: ProductAgentBoundCaller, limit: usize, + run_limit: usize, } #[derive(Default)] @@ -764,12 +768,16 @@ impl AutomationProductFacade for RecordingAutomationFacade { async fn list_automations( &self, caller: ProductAgentBoundCaller, - limit: usize, + request: AutomationListRequest, ) -> Result, RebornServicesError> { self.list_calls .lock() .expect("lock") - .push(ListAutomationCall { caller, limit }); + .push(ListAutomationCall { + caller, + limit: request.limit, + run_limit: request.run_limit, + }); Ok(vec![automation_info( "trigger-listed", "Daily status", @@ -789,7 +797,7 @@ impl AutomationProductFacade for StaticAutomationFacade { async fn list_automations( &self, _caller: ProductAgentBoundCaller, - _limit: usize, + _request: AutomationListRequest, ) -> Result, RebornServicesError> { Ok(self.output.clone()) } @@ -897,6 +905,14 @@ fn automation_info( next_run_at: Some("2026-06-03T09:00:00Z".parse().expect("next run")), last_run_at: None, last_status, + recent_runs: vec![RebornAutomationRecentRunInfo { + run_id: Some("run-listed".to_string()), + thread_id: "thread-listed".to_string(), + fire_slot: Some("2026-06-03T09:00:00Z".parse().expect("fire slot")), + status: RebornAutomationRecentRunStatus::Ok, + submitted_at: "2026-06-03T09:00:01Z".parse().expect("submitted at"), + completed_at: Some("2026-06-03T09:00:42Z".parse().expect("completed at")), + }], is_active: true, created_at: Some("2026-06-02T18:00:00Z".parse().expect("created at")), } @@ -3959,7 +3975,13 @@ async fn list_automation_dispatches_through_product_facade() { .with_automation_product_facade(automation_facade.clone()); let listed = services - .list_automations(caller(), WebUiListAutomationsRequest { limit: Some(10) }) + .list_automations( + caller(), + WebUiListAutomationsRequest { + limit: Some(10), + run_limit: None, + }, + ) .await .expect("list automations"); assert_eq!(listed.automations.len(), 1); @@ -3975,6 +3997,15 @@ async fn list_automation_dispatches_through_product_facade() { listed.automations[0].last_status, Some(RebornAutomationRunStatus::Ok) ); + assert_eq!(listed.automations[0].recent_runs.len(), 1); + assert_eq!( + listed.automations[0].recent_runs[0].status, + RebornAutomationRecentRunStatus::Ok + ); + assert_eq!( + listed.automations[0].recent_runs[0].thread_id, + "thread-listed" + ); let list_calls = automation_facade.list_calls(); assert_eq!(list_calls.len(), 1); @@ -3989,6 +4020,11 @@ async fn list_automation_dispatches_through_product_facade() { Some("project-alpha") ); assert_eq!(list_calls[0].limit, 10); + assert_eq!( + list_calls[0].run_limit, AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE as usize, + "omitted automation run history limit must use AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE ({})", + AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE + ); } #[tokio::test] @@ -4613,7 +4649,10 @@ async fn list_automations_rejects_missing_agent_id() { let err = services .list_automations( caller_without_agent(), - WebUiListAutomationsRequest { limit: Some(10) }, + WebUiListAutomationsRequest { + limit: Some(10), + run_limit: None, + }, ) .await .expect_err("missing agent id should fail closed"); @@ -4637,6 +4676,7 @@ async fn list_automations_clamps_oversize_limit_before_product_facade() { caller(), WebUiListAutomationsRequest { limit: Some(u32::MAX), + run_limit: None, }, ) .await @@ -4661,7 +4701,13 @@ async fn list_automations_clamps_zero_limit_before_product_facade() { .with_automation_product_facade(automation_facade.clone()); services - .list_automations(caller(), WebUiListAutomationsRequest { limit: Some(0) }) + .list_automations( + caller(), + WebUiListAutomationsRequest { + limit: Some(0), + run_limit: None, + }, + ) .await .expect("list automations"); @@ -4683,7 +4729,13 @@ async fn list_automations_uses_default_limit_when_omitted() { .with_automation_product_facade(automation_facade.clone()); services - .list_automations(caller(), WebUiListAutomationsRequest { limit: None }) + .list_automations( + caller(), + WebUiListAutomationsRequest { + limit: None, + run_limit: None, + }, + ) .await .expect("list automations"); @@ -4696,6 +4748,63 @@ async fn list_automations_uses_default_limit_when_omitted() { ); } +#[tokio::test] +async fn list_automations_clamps_oversize_run_limit_before_product_facade() { + let automation_facade = Arc::new(RecordingAutomationFacade::default()); + let services = RebornServices::new( + Arc::new(InMemorySessionThreadService::default()), + Arc::new(FakeTurnCoordinator::default()), + ) + .with_automation_product_facade(automation_facade.clone()); + + services + .list_automations( + caller(), + WebUiListAutomationsRequest { + limit: None, + run_limit: Some(u32::MAX), + }, + ) + .await + .expect("list automations"); + + let list_calls = automation_facade.list_calls(); + assert_eq!(list_calls.len(), 1); + assert_eq!( + list_calls[0].run_limit, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE as usize, + "automation run history limit must be clamped to AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE ({}) before the product facade", + AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE + ); +} + +#[tokio::test] +async fn list_automations_clamps_zero_run_limit_before_product_facade() { + let automation_facade = Arc::new(RecordingAutomationFacade::default()); + let services = RebornServices::new( + Arc::new(InMemorySessionThreadService::default()), + Arc::new(FakeTurnCoordinator::default()), + ) + .with_automation_product_facade(automation_facade.clone()); + + services + .list_automations( + caller(), + WebUiListAutomationsRequest { + limit: None, + run_limit: Some(0), + }, + ) + .await + .expect("list automations"); + + let list_calls = automation_facade.list_calls(); + assert_eq!(list_calls.len(), 1); + assert_eq!( + list_calls[0].run_limit, 1, + "automation run history limit must be clamped to at least one run" + ); +} + #[test] fn reborn_automation_state_round_trips_serde_for_every_variant() { let cases = [ diff --git a/crates/ironclaw_reborn_composition/src/automation.rs b/crates/ironclaw_reborn_composition/src/automation.rs index 285f22f0a6e..c9ee9ff6107 100644 --- a/crates/ironclaw_reborn_composition/src/automation.rs +++ b/crates/ironclaw_reborn_composition/src/automation.rs @@ -11,12 +11,13 @@ use ironclaw_host_runtime::{ RuntimeCapabilityRequest, RuntimeFailureKind, TRIGGER_LIST_CAPABILITY_ID, }; use ironclaw_product_workflow::{ - AutomationProductFacade, ProductAgentBoundCaller, RebornAutomationInfo, - RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornServicesError, - RebornServicesErrorCode, RebornServicesErrorKind, + AutomationListRequest, AutomationProductFacade, ProductAgentBoundCaller, RebornAutomationInfo, + RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationRunStatus, + RebornAutomationSource, RebornAutomationState, RebornServicesError, RebornServicesErrorCode, + RebornServicesErrorKind, }; use ironclaw_trust::{AuthorityCeiling, EffectiveTrustClass, TrustDecision, TrustProvenance}; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use serde_json::{Value, json}; const AUTOMATION_BACKEND_TIMEOUT: Duration = Duration::from_secs(30); @@ -121,14 +122,15 @@ impl AutomationProductFacade for RebornWebuiAutomationFacade { async fn list_automations( &self, caller: ProductAgentBoundCaller, - limit: usize, + request: AutomationListRequest, ) -> Result, RebornServicesError> { let output = self .invoke_trigger( caller, TRIGGER_LIST_CAPABILITY_ID, json!({ - "limit": limit, + "limit": request.limit, + "run_limit": request.run_limit, }), ) .await?; @@ -154,11 +156,30 @@ struct RawAutomationRecord { #[serde(default)] last_status: Option, #[serde(default)] + recent_runs: Vec, + #[serde(default)] is_active: bool, #[serde(default)] created_at: Option>, } +#[derive(Debug, Deserialize)] +struct RawAutomationRecentRunRecord { + #[serde(default)] + run_id: Option, + thread_id: String, + #[serde(default)] + fire_slot: Option>, + #[serde( + default = "default_recent_run_error_status", + deserialize_with = "deserialize_recent_run_status" + )] + status: RebornAutomationRecentRunStatus, + submitted_at: DateTime, + #[serde(default)] + completed_at: Option>, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind")] enum RawAutomationSchedule { @@ -196,11 +217,29 @@ fn automation_info(record: RawAutomationRecord) -> Option next_run_at: record.next_run_at, last_run_at: record.last_run_at, last_status: record.last_status, + recent_runs: record + .recent_runs + .into_iter() + .map(automation_recent_run_info) + .collect(), is_active: record.is_active, created_at: record.created_at, }) } +fn automation_recent_run_info( + record: RawAutomationRecentRunRecord, +) -> RebornAutomationRecentRunInfo { + RebornAutomationRecentRunInfo { + run_id: record.run_id, + thread_id: record.thread_id, + fire_slot: record.fire_slot, + status: record.status, + submitted_at: record.submitted_at, + completed_at: record.completed_at, + } +} + fn automation_source(schedule: RawAutomationSchedule) -> Option { match schedule { RawAutomationSchedule::Cron { expression } => { @@ -232,6 +271,25 @@ fn sanitize_automation_list_output(output: &mut Value) { } } +fn default_recent_run_error_status() -> RebornAutomationRecentRunStatus { + RebornAutomationRecentRunStatus::Error +} + +fn deserialize_recent_run_status<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + Ok(match value.as_str() { + Some("running") => RebornAutomationRecentRunStatus::Running, + Some("ok") => RebornAutomationRecentRunStatus::Ok, + Some("error") => RebornAutomationRecentRunStatus::Error, + _ => RebornAutomationRecentRunStatus::Error, + }) +} + fn trigger_execution_context( caller: &ProductAgentBoundCaller, capability_id: &str, @@ -408,9 +466,9 @@ mod tests { RuntimeProcessHandle, RuntimeResourceGate, TRIGGER_LIST_CAPABILITY_ID, }; use ironclaw_product_workflow::{ - AutomationProductFacade, ProductAgentBoundCaller, RebornAutomationRunStatus, - RebornAutomationSource, RebornAutomationState, RebornServicesErrorCode, - RebornServicesErrorKind, + AutomationListRequest, AutomationProductFacade, ProductAgentBoundCaller, + RebornAutomationRecentRunStatus, RebornAutomationRunStatus, RebornAutomationSource, + RebornAutomationState, RebornServicesErrorCode, RebornServicesErrorKind, }; use serde_json::{Value, json}; use tokio::sync::Mutex; @@ -424,7 +482,7 @@ mod tests { let caller = caller(); let automations = facade - .list_automations(caller.clone(), 25) + .list_automations(caller.clone(), automation_list_request(25, 10)) .await .expect("trigger list output"); @@ -440,6 +498,16 @@ mod tests { automations[0].last_status, Some(RebornAutomationRunStatus::Ok) ); + assert_eq!(automations[0].recent_runs.len(), 1); + assert_eq!( + automations[0].recent_runs[0].run_id.as_deref(), + Some("run-listed") + ); + assert_eq!(automations[0].recent_runs[0].thread_id, "thread-listed"); + assert_eq!( + automations[0].recent_runs[0].status, + RebornAutomationRecentRunStatus::Running + ); let request = runtime .requests .lock() @@ -460,6 +528,7 @@ mod tests { assert_eq!(request.context.resource_scope.project_id, caller.project_id); assert_eq!(request.context.trust, TrustClass::UserTrusted); assert_eq!(request.input["limit"], 25); + assert_eq!(request.input["run_limit"], 10); } #[tokio::test] @@ -469,7 +538,7 @@ mod tests { })))); let error = facade - .list_automations(caller(), 50) + .list_automations(caller(), automation_list_request(50, 10)) .await .expect_err("malformed automation output should fail closed"); @@ -486,7 +555,7 @@ mod tests { })))); let error = facade - .list_automations(caller(), 50) + .list_automations(caller(), automation_list_request(50, 10)) .await .expect_err("malformed automation output should fail closed"); @@ -510,7 +579,7 @@ mod tests { })))); let automations = facade - .list_automations(caller(), 50) + .list_automations(caller(), automation_list_request(50, 10)) .await .expect("list automations"); @@ -518,6 +587,51 @@ mod tests { assert_eq!(automations[0].last_status, None); } + #[tokio::test] + async fn automation_facade_sanitizes_malformed_recent_run_status_payloads() { + let mut unknown_status = raw_automation( + "trigger-unknown-status", + "Unknown run status", + "0 9 * * *", + Some("ok"), + ) + .as_object() + .cloned() + .expect("object trigger"); + unknown_status["recent_runs"][0]["status"] = json!("future_state"); + let mut non_string_status = raw_automation( + "trigger-non-string-status", + "Non-string run status", + "0 10 * * *", + Some("ok"), + ) + .as_object() + .cloned() + .expect("object trigger"); + non_string_status["recent_runs"][0]["status"] = json!({"raw": "backend-only"}); + let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + "triggers": [ + Value::Object(unknown_status), + Value::Object(non_string_status) + ] + })))); + + let automations = facade + .list_automations(caller(), automation_list_request(50, 10)) + .await + .expect("list automations"); + + assert_eq!(automations.len(), 2); + assert_eq!( + automations[0].recent_runs[0].status, + RebornAutomationRecentRunStatus::Error + ); + assert_eq!( + automations[1].recent_runs[0].status, + RebornAutomationRecentRunStatus::Error + ); + } + #[tokio::test] async fn automation_facade_parses_known_and_unknown_states() { let mut paused = raw_automation("trigger-paused", "Paused status", "0 9 * * *", Some("ok")) @@ -577,7 +691,7 @@ mod tests { })))); let automations = facade - .list_automations(caller(), 50) + .list_automations(caller(), automation_list_request(50, 10)) .await .expect("list automations"); @@ -606,7 +720,7 @@ mod tests { })))); let automations = facade - .list_automations(caller(), 50) + .list_automations(caller(), automation_list_request(50, 10)) .await .expect("list automations"); @@ -627,7 +741,7 @@ mod tests { })))); let error = facade - .list_automations(caller(), 50) + .list_automations(caller(), automation_list_request(50, 10)) .await .expect_err("malformed record should fail closed"); @@ -643,7 +757,7 @@ mod tests { let caller = caller(); let error = facade - .list_automations(caller, 10) + .list_automations(caller, automation_list_request(10, 5)) .await .expect_err("runtime failure should map to services error"); @@ -663,7 +777,7 @@ mod tests { let error = tokio::time::timeout( std::time::Duration::from_secs(2), - facade.list_automations(caller(), 10), + facade.list_automations(caller(), automation_list_request(10, 5)), ) .await .expect("facade timeout should complete promptly") @@ -744,7 +858,7 @@ mod tests { RebornWebuiAutomationFacade::new(Arc::new(OutcomeHostRuntime::new(outcome))); let error = facade - .list_automations(caller(), 50) + .list_automations(caller(), automation_list_request(50, 10)) .await .expect_err("outcome should map to services error"); @@ -793,7 +907,7 @@ mod tests { RebornWebuiAutomationFacade::new(Arc::new(FailingHostRuntime::new(failure_kind))); let error = facade - .list_automations(caller(), 10) + .list_automations(caller(), automation_list_request(10, 5)) .await .expect_err("runtime failure should map to services error"); @@ -828,7 +942,7 @@ mod tests { RebornWebuiAutomationFacade::new(Arc::new(ErrorHostRuntime::new(host_error))); let error = facade - .list_automations(caller(), 10) + .list_automations(caller(), automation_list_request(10, 5)) .await .expect_err("host runtime error should map to services error"); @@ -848,6 +962,10 @@ mod tests { } } + fn automation_list_request(limit: usize, run_limit: usize) -> AutomationListRequest { + AutomationListRequest { limit, run_limit } + } + fn raw_automation( trigger_id: &str, name: impl Into, @@ -866,7 +984,15 @@ mod tests { "last_run_at": null, "last_status": last_status, "is_active": true, - "created_at": "2026-06-02T18:00:00Z" + "created_at": "2026-06-02T18:00:00Z", + "recent_runs": [{ + "run_id": "run-listed", + "thread_id": "thread-listed", + "fire_slot": "2026-06-03T09:00:00Z", + "status": "running", + "submitted_at": "2026-06-03T09:00:01Z", + "completed_at": null + }] }) } diff --git a/crates/ironclaw_reborn_composition/src/trigger_poller.rs b/crates/ironclaw_reborn_composition/src/trigger_poller.rs index 729f594080e..5339be26eb0 100644 --- a/crates/ironclaw_reborn_composition/src/trigger_poller.rs +++ b/crates/ironclaw_reborn_composition/src/trigger_poller.rs @@ -198,7 +198,7 @@ fn active_run_index( .iter() .map(|run| { let state = if run.status.is_terminal() { - TriggerActiveRunState::Terminal + TriggerActiveRunState::Terminal { status: run.status } } else { TriggerActiveRunState::Nonterminal }; @@ -453,7 +453,12 @@ mod tests { .await; assert!(matches!(results[0], Ok(TriggerActiveRunState::Nonterminal))); - assert!(matches!(results[1], Ok(TriggerActiveRunState::Terminal))); + assert!(matches!( + results[1], + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed + }) + )); assert!(matches!(results[2], Ok(TriggerActiveRunState::Missing))); } diff --git a/crates/ironclaw_triggers/src/lib.rs b/crates/ironclaw_triggers/src/lib.rs index ccd68e49711..b8c28379365 100644 --- a/crates/ironclaw_triggers/src/lib.rs +++ b/crates/ironclaw_triggers/src/lib.rs @@ -38,6 +38,8 @@ pub use trusted_submit::{ const MIN_FIRE_CADENCE: Duration = Duration::from_secs(60); const MAX_DUE_TRIGGER_POLL_LIMIT: usize = 128; const MAX_TRIGGER_LIST_LIMIT: usize = 100; +const MAX_TRIGGER_RUN_HISTORY_LIMIT: usize = 500; +const MAX_TRIGGER_RUN_HISTORY_RETAINED: usize = 500; pub const MAX_TRIGGER_NAME_BYTES: usize = 256; pub const MAX_TRIGGER_PROMPT_BYTES: usize = 32 * 1024; const IDENTITY_VERSION_LABEL: &str = "ironclaw.trigger-fire.v1"; @@ -393,6 +395,48 @@ pub enum TriggerRunStatus { Error, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TriggerRunHistoryStatus { + Running, + Ok, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TriggerRunRecord { + pub tenant_id: TenantId, + pub trigger_id: TriggerId, + pub fire_slot: Timestamp, + pub run_id: Option, + pub thread_id: TriggerRouteThreadId, + pub status: TriggerRunHistoryStatus, + pub submitted_at: Timestamp, + pub completed_at: Option, +} + +impl TriggerRunRecord { + fn running( + tenant_id: TenantId, + trigger_id: TriggerId, + fire_slot: Timestamp, + run_id: Option, + submitted_at: Timestamp, + ) -> Self { + let identity = TriggerFireIdentity::new(tenant_id.clone(), trigger_id, fire_slot); + Self { + tenant_id, + trigger_id, + fire_slot, + run_id, + thread_id: identity.route_thread_id, + status: TriggerRunHistoryStatus::Running, + submitted_at, + completed_at: None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TriggerFireIdentity { pub tenant_id: TenantId, @@ -538,6 +582,7 @@ pub struct ClearActiveFireRequest { pub trigger_id: TriggerId, pub fire_slot: Timestamp, pub run_id: TurnRunId, + pub status: TriggerRunHistoryStatus, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -732,6 +777,45 @@ pub trait TriggerRepository: Send + Sync { &self, request: ClearActiveFireRequest, ) -> Result, TriggerError>; + + /// Returns recent run-history rows for one tenant-scoped trigger. + /// + /// Rows are ordered newest first by fire slot. Implementations must clamp + /// the caller-provided limit to the repository maximum, and a limit of zero + /// must return an empty list without touching storage. + async fn list_trigger_run_history( + &self, + _tenant_id: TenantId, + _trigger_id: TriggerId, + _limit: usize, + ) -> Result, TriggerError> { + Ok(Vec::new()) + } + + /// Returns recent run-history rows for several tenant-scoped triggers. + /// + /// Each entry is ordered newest first by fire slot and truncated to `limit`. + /// Implementations should override this when they can fetch all trigger + /// histories in one query. + async fn list_trigger_run_history_batch( + &self, + tenant_id: TenantId, + trigger_ids: &[TriggerId], + limit: usize, + ) -> Result>, TriggerError> { + let mut runs_by_trigger = HashMap::with_capacity(trigger_ids.len()); + if limit == 0 { + return Ok(runs_by_trigger); + } + for trigger_id in trigger_ids { + runs_by_trigger.insert( + *trigger_id, + self.list_trigger_run_history(tenant_id.clone(), *trigger_id, limit) + .await?, + ); + } + Ok(runs_by_trigger) + } } /// Feature-gated durable libSQL repository type for composition/test wiring. @@ -750,7 +834,13 @@ pub use worker::{ #[derive(Clone, Default)] pub struct InMemoryTriggerRepository { - state: Arc>>, + state: Arc>, +} + +#[derive(Debug, Default)] +struct InMemoryTriggerRepositoryState { + records: HashMap, + runs: HashMap, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -768,12 +858,29 @@ impl TriggerRepositoryKey { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct TriggerRunRepositoryKey { + tenant_id: TenantId, + trigger_id: TriggerId, + fire_slot: Timestamp, +} + +impl TriggerRunRepositoryKey { + fn new(tenant_id: &TenantId, trigger_id: TriggerId, fire_slot: Timestamp) -> Self { + Self { + tenant_id: tenant_id.clone(), + trigger_id, + fire_slot, + } + } +} + #[async_trait] impl TriggerRepository for InMemoryTriggerRepository { async fn upsert_trigger(&self, record: TriggerRecord) -> Result<(), TriggerError> { record.validate()?; let mut state = self.lock_state()?; - state.insert( + state.records.insert( TriggerRepositoryKey::new(&record.tenant_id, record.trigger_id), record, ); @@ -787,6 +894,7 @@ impl TriggerRepository for InMemoryTriggerRepository { ) -> Result, TriggerError> { Ok(self .lock_state()? + .records .get(&TriggerRepositoryKey::new(&tenant_id, trigger_id)) .cloned()) } @@ -794,6 +902,7 @@ impl TriggerRepository for InMemoryTriggerRepository { async fn list_triggers(&self, tenant_id: TenantId) -> Result, TriggerError> { let state = self.lock_state()?; let mut records = state + .records .values() .filter(|record| record.tenant_id == tenant_id) .cloned() @@ -816,6 +925,7 @@ impl TriggerRepository for InMemoryTriggerRepository { let limit = limit.min(MAX_TRIGGER_LIST_LIMIT); let state = self.lock_state()?; let mut records = state + .records .values() .filter(|record| { record.tenant_id == tenant_id @@ -837,6 +947,7 @@ impl TriggerRepository for InMemoryTriggerRepository { ) -> Result, TriggerError> { Ok(self .lock_state()? + .records .remove(&TriggerRepositoryKey::new(&tenant_id, trigger_id))) } @@ -850,7 +961,7 @@ impl TriggerRepository for InMemoryTriggerRepository { ) -> Result, TriggerError> { let mut state = self.lock_state()?; let key = TriggerRepositoryKey::new(&tenant_id, trigger_id); - let Some(record) = state.get(&key) else { + let Some(record) = state.records.get(&key) else { return Ok(None); }; if record.creator_user_id != creator_user_id @@ -859,7 +970,7 @@ impl TriggerRepository for InMemoryTriggerRepository { { return Ok(None); } - Ok(state.remove(&key)) + Ok(state.records.remove(&key)) } async fn list_due_triggers( @@ -873,6 +984,7 @@ impl TriggerRepository for InMemoryTriggerRepository { let limit = limit.min(MAX_DUE_TRIGGER_POLL_LIMIT); let state = self.lock_state()?; let mut selected_keys = state + .records .iter() .filter(|(_, record)| record.is_due_at(now) && !record.has_active_fire()) .map(|(key, record)| { @@ -890,7 +1002,7 @@ impl TriggerRepository for InMemoryTriggerRepository { selected_keys.truncate(limit); Ok(selected_keys .into_iter() - .filter_map(|(_, _, _, key)| state.get(&key).cloned()) + .filter_map(|(_, _, _, key)| state.records.get(&key).cloned()) .collect()) } @@ -910,6 +1022,7 @@ impl TriggerRepository for InMemoryTriggerRepository { let mut selected_records = { let state = self.lock_state()?; state + .records .values() .filter_map(|record| { let active_fire_slot = record.active_fire_slot?; @@ -951,7 +1064,7 @@ impl TriggerRepository for InMemoryTriggerRepository { ) -> Result { let mut state = self.lock_state()?; let key = TriggerRepositoryKey::new(&request.tenant_id, request.trigger_id); - let Some(record) = state.get_mut(&key) else { + let Some(record) = state.records.get_mut(&key) else { return Ok(ClaimDueFireOutcome::NotFound); }; @@ -973,8 +1086,20 @@ impl TriggerRepository for InMemoryTriggerRepository { record.active_fire_slot = Some(request.fire_slot); record.active_run_ref = None; + let record = record.clone(); + state.runs.insert( + TriggerRunRepositoryKey::new(&request.tenant_id, request.trigger_id, request.fire_slot), + TriggerRunRecord::running( + request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + request.now, + ), + ); + prune_run_history_locked(&mut state, &record.tenant_id, record.trigger_id); Ok(ClaimDueFireOutcome::Claimed(ClaimedTriggerFire { - record: record.clone(), + record, fire_slot: request.fire_slot, })) } @@ -1005,6 +1130,13 @@ impl TriggerRepository for InMemoryTriggerRepository { else { return Ok(None); }; + self.upsert_running_run_history( + &request.tenant_id, + request.trigger_id, + request.fire_slot, + request.run_id, + record.last_run_at.unwrap_or(request.submitted_at), + )?; Ok(Some(record)) } @@ -1034,6 +1166,13 @@ impl TriggerRepository for InMemoryTriggerRepository { else { return Ok(None); }; + self.upsert_running_run_history( + &request.tenant_id, + request.trigger_id, + request.fire_slot, + request.original_run_id, + record.last_run_at.unwrap_or(request.replayed_at), + )?; Ok(Some(record)) } @@ -1062,6 +1201,14 @@ impl TriggerRepository for InMemoryTriggerRepository { else { return Ok(None); }; + self.complete_run_history( + &request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), + )?; Ok(Some(record)) } @@ -1086,6 +1233,14 @@ impl TriggerRepository for InMemoryTriggerRepository { else { return Ok(None); }; + self.complete_run_history( + &request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), + )?; Ok(Some(record)) } @@ -1109,6 +1264,14 @@ impl TriggerRepository for InMemoryTriggerRepository { else { return Ok(None); }; + self.complete_run_history( + &request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), + )?; Ok(Some(record)) } @@ -1118,7 +1281,7 @@ impl TriggerRepository for InMemoryTriggerRepository { ) -> Result, TriggerError> { let mut state = self.lock_state()?; let key = TriggerRepositoryKey::new(&request.tenant_id, request.trigger_id); - let Some(record) = state.get_mut(&key) else { + let Some(record) = state.records.get_mut(&key) else { return Ok(None); }; if record.active_fire_slot != Some(request.fire_slot) @@ -1128,15 +1291,88 @@ impl TriggerRepository for InMemoryTriggerRepository { } record.active_fire_slot = None; record.active_run_ref = None; - Ok(Some(record.clone())) + let record = record.clone(); + state + .runs + .entry(TriggerRunRepositoryKey::new( + &request.tenant_id, + request.trigger_id, + request.fire_slot, + )) + .and_modify(|run| { + run.run_id = Some(request.run_id); + run.status = request.status; + run.completed_at = Some(Utc::now()); + }) + .or_insert_with(|| { + let mut run = TriggerRunRecord::running( + request.tenant_id.clone(), + request.trigger_id, + request.fire_slot, + Some(request.run_id), + request.fire_slot, + ); + run.status = request.status; + run.completed_at = Some(Utc::now()); + run + }); + prune_run_history_locked(&mut state, &request.tenant_id, request.trigger_id); + Ok(Some(record)) + } + + async fn list_trigger_run_history( + &self, + tenant_id: TenantId, + trigger_id: TriggerId, + limit: usize, + ) -> Result, TriggerError> { + if limit == 0 { + return Ok(Vec::new()); + } + let limit = limit.min(MAX_TRIGGER_RUN_HISTORY_LIMIT); + let state = self.lock_state()?; + let mut runs = state + .runs + .values() + .filter(|run| run.tenant_id == tenant_id && run.trigger_id == trigger_id) + .cloned() + .collect::>(); + runs.sort_by_key(|run| std::cmp::Reverse(run.fire_slot)); + runs.truncate(limit); + Ok(runs) + } + + async fn list_trigger_run_history_batch( + &self, + tenant_id: TenantId, + trigger_ids: &[TriggerId], + limit: usize, + ) -> Result>, TriggerError> { + let mut runs_by_trigger = HashMap::with_capacity(trigger_ids.len()); + if limit == 0 || trigger_ids.is_empty() { + return Ok(runs_by_trigger); + } + let limit = limit.min(MAX_TRIGGER_RUN_HISTORY_LIMIT); + let state = self.lock_state()?; + for trigger_id in trigger_ids { + let mut runs = state + .runs + .values() + .filter(|run| run.tenant_id == tenant_id && run.trigger_id == *trigger_id) + .cloned() + .collect::>(); + runs.sort_by_key(|run| std::cmp::Reverse(run.fire_slot)); + runs.truncate(limit); + runs_by_trigger.insert(*trigger_id, runs); + } + Ok(runs_by_trigger) } } impl InMemoryTriggerRepository { fn lock_state( &self, - ) -> Result>, TriggerError> - { + ) -> Result, TriggerError> { self.state.lock().map_err(|_| TriggerError::Backend { reason: "trigger repository mutex poisoned".to_string(), }) @@ -1151,7 +1387,7 @@ impl InMemoryTriggerRepository { ) -> Result, TriggerError> { let mut state = self.lock_state()?; let key = TriggerRepositoryKey::new(tenant_id, trigger_id); - let Some(record) = state.get_mut(&key) else { + let Some(record) = state.records.get_mut(&key) else { return Ok(None); }; if record.active_fire_slot != Some(fire_slot) { @@ -1160,6 +1396,84 @@ impl InMemoryTriggerRepository { update(record)?; Ok(Some(record.clone())) } + + fn upsert_running_run_history( + &self, + tenant_id: &TenantId, + trigger_id: TriggerId, + fire_slot: Timestamp, + run_id: TurnRunId, + submitted_at: Timestamp, + ) -> Result<(), TriggerError> { + let mut state = self.lock_state()?; + state.runs.insert( + TriggerRunRepositoryKey::new(tenant_id, trigger_id, fire_slot), + TriggerRunRecord::running( + tenant_id.clone(), + trigger_id, + fire_slot, + Some(run_id), + submitted_at, + ), + ); + prune_run_history_locked(&mut state, tenant_id, trigger_id); + Ok(()) + } + + fn complete_run_history( + &self, + tenant_id: &TenantId, + trigger_id: TriggerId, + fire_slot: Timestamp, + run_id: Option, + status: TriggerRunHistoryStatus, + completed_at: Timestamp, + ) -> Result<(), TriggerError> { + let mut state = self.lock_state()?; + state + .runs + .entry(TriggerRunRepositoryKey::new( + tenant_id, trigger_id, fire_slot, + )) + .and_modify(|run| { + if run.run_id.is_none() { + run.run_id = run_id; + } + run.status = status; + run.completed_at = Some(completed_at); + }) + .or_insert_with(|| { + let mut run = TriggerRunRecord::running( + tenant_id.clone(), + trigger_id, + fire_slot, + run_id, + fire_slot, + ); + run.status = status; + run.completed_at = Some(completed_at); + run + }); + prune_run_history_locked(&mut state, tenant_id, trigger_id); + Ok(()) + } +} + +fn prune_run_history_locked( + state: &mut InMemoryTriggerRepositoryState, + tenant_id: &TenantId, + trigger_id: TriggerId, +) { + let mut keys = state + .runs + .keys() + .filter(|key| key.tenant_id == *tenant_id && key.trigger_id == trigger_id) + .cloned() + .collect::>(); + keys.sort_by_key(|key| std::cmp::Reverse(key.fire_slot)); + for key in keys.into_iter().skip(MAX_TRIGGER_RUN_HISTORY_RETAINED) { + state.runs.remove(&key); + } } pub(crate) fn reject_non_future_next_run_at( diff --git a/crates/ironclaw_triggers/src/libsql.rs b/crates/ironclaw_triggers/src/libsql.rs index 1885d90c03b..fd8a0917d97 100644 --- a/crates/ironclaw_triggers/src/libsql.rs +++ b/crates/ironclaw_triggers/src/libsql.rs @@ -1,5 +1,5 @@ #[cfg(feature = "libsql")] -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; #[cfg(feature = "libsql")] use async_trait::async_trait; @@ -17,13 +17,15 @@ use crate::{ ActiveTriggerScanCursor, ClaimDueFireOutcome, ClaimDueFireRequest, ClaimedTriggerFire, ClearActiveFireRequest, FireAcceptedRequest, FirePermanentFailedRequest, FireReplayedRequest, FireRetryableFailedRequest, FireTerminalFailedRequest, TriggerCompletionPolicy, TriggerError, - TriggerId, TriggerRecord, TriggerRepository, TriggerRunStatus, TriggerSchedule, - TriggerSourceKind, TriggerState, reject_failed_result_after_active_run, - reject_non_future_next_run_at, reject_run_ref_rewrite, + TriggerId, TriggerRecord, TriggerRepository, TriggerRouteThreadId, TriggerRunHistoryStatus, + TriggerRunRecord, TriggerRunStatus, TriggerSchedule, TriggerSourceKind, TriggerState, + reject_failed_result_after_active_run, reject_non_future_next_run_at, reject_run_ref_rewrite, }; #[cfg(feature = "libsql")] const TRIGGER_TABLE: &str = "trigger_records"; +#[cfg(feature = "libsql")] +const TRIGGER_RUN_TABLE: &str = "trigger_run_history"; #[cfg(feature = "libsql")] const TRIGGER_COLUMNS: &str = "\ @@ -69,6 +71,26 @@ const ACTIVE_RUN_REF_COL: usize = 16; #[cfg(feature = "libsql")] const CREATED_AT_COL: usize = 17; +#[cfg(feature = "libsql")] +const TRIGGER_RUN_COLUMNS: &str = "\ + tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at"; +#[cfg(feature = "libsql")] +const RUN_TENANT_ID_COL: usize = 0; +#[cfg(feature = "libsql")] +const RUN_TRIGGER_ID_COL: usize = 1; +#[cfg(feature = "libsql")] +const RUN_FIRE_SLOT_COL: usize = 2; +#[cfg(feature = "libsql")] +const RUN_ID_COL: usize = 3; +#[cfg(feature = "libsql")] +const RUN_THREAD_ID_COL: usize = 4; +#[cfg(feature = "libsql")] +const RUN_STATUS_COL: usize = 5; +#[cfg(feature = "libsql")] +const RUN_SUBMITTED_AT_COL: usize = 6; +#[cfg(feature = "libsql")] +const RUN_COMPLETED_AT_COL: usize = 7; + /// Durable libSQL trigger repository. #[cfg(feature = "libsql")] pub struct LibSqlTriggerRepository { @@ -155,6 +177,33 @@ impl LibSqlTriggerRepository { ) .await .map_err(|error| backend_error("create trigger active scan index", error))?; + conn.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {TRIGGER_RUN_TABLE} ( + tenant_id TEXT NOT NULL, + trigger_id TEXT NOT NULL, + fire_slot TEXT NOT NULL, + run_id TEXT, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + submitted_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (tenant_id, trigger_id, fire_slot) + )" + ), + (), + ) + .await + .map_err(|error| backend_error("create trigger_run_history table", error))?; + conn.execute( + &format!( + "CREATE INDEX IF NOT EXISTS trigger_run_history_trigger_fire_slot_idx + ON {TRIGGER_RUN_TABLE} (tenant_id, trigger_id, fire_slot DESC)" + ), + (), + ) + .await + .map_err(|error| backend_error("create trigger run history list index", error))?; Ok::<(), TriggerError>(()) } .await; @@ -476,36 +525,64 @@ impl TriggerRepository for LibSqlTriggerRepository { let conn = self.connect().await?; let fire_slot = fmt_ts(&request.fire_slot); let now = fmt_ts(&request.now); - let mut rows = conn - .query( - &format!( - "UPDATE {TRIGGER_TABLE} - SET active_fire_slot = ?4, - active_run_ref = NULL - WHERE tenant_id = ?1 - AND trigger_id = ?2 - AND state = ?3 - AND next_run_at = ?4 - AND ?4 <= ?5 - AND active_fire_slot IS NULL - AND active_run_ref IS NULL - RETURNING {TRIGGER_COLUMNS}" + begin_immediate(&conn, "begin trigger fire claim").await?; + let claim_result = async { + let mut rows = conn + .query( + &format!( + "UPDATE {TRIGGER_TABLE} + SET active_fire_slot = ?4, + active_run_ref = NULL + WHERE tenant_id = ?1 + AND trigger_id = ?2 + AND state = ?3 + AND next_run_at = ?4 + AND ?4 <= ?5 + AND active_fire_slot IS NULL + AND active_run_ref IS NULL + RETURNING {TRIGGER_COLUMNS}" + ), + params![ + request.tenant_id.as_str(), + request.trigger_id.to_string(), + state_text(TriggerState::Scheduled), + fire_slot, + now, + ], + ) + .await + .map_err(|error| backend_error("claim trigger fire", error))?; + let Some(record) = returned_record(&mut rows, "read claimed trigger fire").await? + else { + return Ok(None); + }; + upsert_run_history( + &conn, + &TriggerRunRecord::running( + request.tenant_id.clone(), + request.trigger_id, + request.fire_slot, + None, + request.now, ), - params![ - request.tenant_id.as_str(), - request.trigger_id.to_string(), - state_text(TriggerState::Scheduled), - fire_slot, - now, - ], ) - .await - .map_err(|error| backend_error("claim trigger fire", error))?; - if let Some(record) = returned_record(&mut rows, "read claimed trigger fire").await? { - return Ok(ClaimDueFireOutcome::Claimed(ClaimedTriggerFire { - record, - fire_slot: request.fire_slot, - })); + .await?; + Ok(Some(record)) + } + .await; + match claim_result { + Ok(Some(record)) => { + commit(&conn, "commit trigger fire claim").await?; + return Ok(ClaimDueFireOutcome::Claimed(ClaimedTriggerFire { + record, + fire_slot: request.fire_slot, + })); + } + Ok(None) => rollback(&conn, "rollback missed trigger fire claim").await?, + Err(error) => { + rollback(&conn, "rollback failed trigger fire claim").await?; + return Err(error); + } } let Some(record) = fetch_record(&conn, &request.tenant_id, request.trigger_id).await? @@ -598,33 +675,59 @@ impl TriggerRepository for LibSqlTriggerRepository { let fire_slot_text = fmt_ts(&fire_slot); let last_status = status_text(TriggerRunStatus::Error); - let mut rows = conn - .query( - &format!( - "UPDATE {TRIGGER_TABLE} - SET last_status = ?3, - active_fire_slot = NULL, - active_run_ref = NULL - WHERE tenant_id = ?1 - AND trigger_id = ?2 - AND active_fire_slot = ?4 - AND active_run_ref IS NULL - AND next_run_at <= ?4 - RETURNING {TRIGGER_COLUMNS}" - ), - params![ - tenant_id.as_str(), - trigger_id.to_string(), - last_status, - fire_slot_text, - ], + begin_immediate(&conn, "begin retryable trigger fire failure").await?; + let update_result = async { + let mut rows = conn + .query( + &format!( + "UPDATE {TRIGGER_TABLE} + SET last_status = ?3, + active_fire_slot = NULL, + active_run_ref = NULL + WHERE tenant_id = ?1 + AND trigger_id = ?2 + AND active_fire_slot = ?4 + AND active_run_ref IS NULL + AND next_run_at <= ?4 + RETURNING {TRIGGER_COLUMNS}" + ), + params![ + tenant_id.as_str(), + trigger_id.to_string(), + last_status, + fire_slot_text, + ], + ) + .await + .map_err(|error| backend_error("mark retryable trigger fire failure", error))?; + let Some(record) = + returned_record(&mut rows, "read retryable trigger fire failure").await? + else { + return Ok(None); + }; + complete_run_history( + &conn, + &tenant_id, + trigger_id, + fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), ) - .await - .map_err(|error| backend_error("mark retryable trigger fire failure", error))?; - if let Some(record) = - returned_record(&mut rows, "read retryable trigger fire failure").await? - { - return Ok(Some(record)); + .await?; + Ok(Some(record)) + } + .await; + match update_result { + Ok(Some(record)) => { + commit(&conn, "commit retryable trigger fire failure").await?; + return Ok(Some(record)); + } + Ok(None) => rollback(&conn, "rollback missed retryable trigger fire failure").await?, + Err(error) => { + rollback(&conn, "rollback failed retryable trigger fire failure").await?; + return Err(error); + } } resolve_missed_fire_result_update(&conn, &tenant_id, trigger_id, fire_slot, None, None) .await @@ -653,34 +756,60 @@ impl TriggerRepository for LibSqlTriggerRepository { let fire_slot_text = fmt_ts(&fire_slot); let next_run_at = fmt_ts(&next_run_at); let last_status = status_text(TriggerRunStatus::Error); - let mut rows = conn - .query( - &format!( - "UPDATE {TRIGGER_TABLE} - SET last_status = ?3, - next_run_at = ?5, - active_fire_slot = NULL, - active_run_ref = NULL - WHERE tenant_id = ?1 - AND trigger_id = ?2 - AND active_fire_slot = ?4 - AND active_run_ref IS NULL - RETURNING {TRIGGER_COLUMNS}" - ), - params![ - tenant_id.as_str(), - trigger_id.to_string(), - last_status, - fire_slot_text, - next_run_at, - ], + begin_immediate(&conn, "begin permanent trigger fire failure").await?; + let update_result = async { + let mut rows = conn + .query( + &format!( + "UPDATE {TRIGGER_TABLE} + SET last_status = ?3, + next_run_at = ?5, + active_fire_slot = NULL, + active_run_ref = NULL + WHERE tenant_id = ?1 + AND trigger_id = ?2 + AND active_fire_slot = ?4 + AND active_run_ref IS NULL + RETURNING {TRIGGER_COLUMNS}" + ), + params![ + tenant_id.as_str(), + trigger_id.to_string(), + last_status, + fire_slot_text, + next_run_at, + ], + ) + .await + .map_err(|error| backend_error("mark permanent trigger fire failure", error))?; + let Some(record) = + returned_record(&mut rows, "read permanent trigger fire failure").await? + else { + return Ok(None); + }; + complete_run_history( + &conn, + &tenant_id, + trigger_id, + fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), ) - .await - .map_err(|error| backend_error("mark permanent trigger fire failure", error))?; - if let Some(record) = - returned_record(&mut rows, "read permanent trigger fire failure").await? - { - return Ok(Some(record)); + .await?; + Ok(Some(record)) + } + .await; + match update_result { + Ok(Some(record)) => { + commit(&conn, "commit permanent trigger fire failure").await?; + return Ok(Some(record)); + } + Ok(None) => rollback(&conn, "rollback missed permanent trigger fire failure").await?, + Err(error) => { + rollback(&conn, "rollback failed permanent trigger fire failure").await?; + return Err(error); + } } resolve_missed_fire_result_update(&conn, &tenant_id, trigger_id, fire_slot, None, None) .await @@ -699,34 +828,60 @@ impl TriggerRepository for LibSqlTriggerRepository { let last_status = status_text(TriggerRunStatus::Error); let completed = state_text(TriggerState::Completed); let conn = self.connect().await?; - let mut rows = conn - .query( - &format!( - "UPDATE {TRIGGER_TABLE} - SET state = ?3, - last_status = ?4, - active_fire_slot = NULL, - active_run_ref = NULL - WHERE tenant_id = ?1 - AND trigger_id = ?2 - AND active_fire_slot = ?5 - AND active_run_ref IS NULL - RETURNING {TRIGGER_COLUMNS}" - ), - params![ - tenant_id.as_str(), - trigger_id.to_string(), - completed, - last_status, - fire_slot_text, - ], + begin_immediate(&conn, "begin terminal trigger fire failure").await?; + let update_result = async { + let mut rows = conn + .query( + &format!( + "UPDATE {TRIGGER_TABLE} + SET state = ?3, + last_status = ?4, + active_fire_slot = NULL, + active_run_ref = NULL + WHERE tenant_id = ?1 + AND trigger_id = ?2 + AND active_fire_slot = ?5 + AND active_run_ref IS NULL + RETURNING {TRIGGER_COLUMNS}" + ), + params![ + tenant_id.as_str(), + trigger_id.to_string(), + completed, + last_status, + fire_slot_text, + ], + ) + .await + .map_err(|error| backend_error("mark terminal trigger fire failure", error))?; + let Some(record) = + returned_record(&mut rows, "read terminal trigger fire failure").await? + else { + return Ok(None); + }; + complete_run_history( + &conn, + &tenant_id, + trigger_id, + fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), ) - .await - .map_err(|error| backend_error("mark terminal trigger fire failure", error))?; - if let Some(record) = - returned_record(&mut rows, "read terminal trigger fire failure").await? - { - return Ok(Some(record)); + .await?; + Ok(Some(record)) + } + .await; + match update_result { + Ok(Some(record)) => { + commit(&conn, "commit terminal trigger fire failure").await?; + return Ok(Some(record)); + } + Ok(None) => rollback(&conn, "rollback missed terminal trigger fire failure").await?, + Err(error) => { + rollback(&conn, "rollback failed terminal trigger fire failure").await?; + return Err(error); + } } resolve_missed_fire_result_update(&conn, &tenant_id, trigger_id, fire_slot, None, None) .await @@ -737,29 +892,151 @@ impl TriggerRepository for LibSqlTriggerRepository { request: ClearActiveFireRequest, ) -> Result, TriggerError> { let conn = self.connect().await?; - // Keep active-fire clearing atomic as one predicate-guarded write. + begin_immediate(&conn, "begin clear active trigger fire").await?; + let clear_result = async { + // Keep active-fire clearing atomic as one predicate-guarded write. + let mut rows = conn + .query( + &format!( + "UPDATE {TRIGGER_TABLE} + SET active_fire_slot = NULL, + active_run_ref = NULL + WHERE tenant_id = ?1 + AND trigger_id = ?2 + AND active_fire_slot = ?3 + AND active_run_ref = ?4 + RETURNING {TRIGGER_COLUMNS}" + ), + params![ + request.tenant_id.as_str(), + request.trigger_id.to_string(), + fmt_ts(&request.fire_slot), + request.run_id.to_string(), + ], + ) + .await + .map_err(|error| backend_error("clear active trigger fire", error))?; + let Some(record) = returned_record(&mut rows, "read cleared trigger fire").await? + else { + return Ok(None); + }; + complete_run_history( + &conn, + &request.tenant_id, + request.trigger_id, + request.fire_slot, + Some(request.run_id), + request.status, + Utc::now(), + ) + .await?; + Ok(Some(record)) + } + .await; + match clear_result { + Ok(Some(record)) => { + commit(&conn, "commit clear active trigger fire").await?; + Ok(Some(record)) + } + Ok(None) => { + rollback(&conn, "rollback missed clear active trigger fire").await?; + Ok(None) + } + Err(error) => { + rollback(&conn, "rollback failed clear active trigger fire").await?; + Err(error) + } + } + } + + async fn list_trigger_run_history( + &self, + tenant_id: TenantId, + trigger_id: TriggerId, + limit: usize, + ) -> Result, TriggerError> { + if limit == 0 { + return Ok(Vec::new()); + } + let limit = limit.min(crate::MAX_TRIGGER_RUN_HISTORY_LIMIT) as i64; + let conn = self.connect().await?; let mut rows = conn .query( &format!( - "UPDATE {TRIGGER_TABLE} - SET active_fire_slot = NULL, - active_run_ref = NULL - WHERE tenant_id = ?1 - AND trigger_id = ?2 - AND active_fire_slot = ?3 - AND active_run_ref = ?4 - RETURNING {TRIGGER_COLUMNS}" + "SELECT {TRIGGER_RUN_COLUMNS} + FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = ?1 AND trigger_id = ?2 + ORDER BY fire_slot DESC + LIMIT ?3" ), - params![ - request.tenant_id.as_str(), - request.trigger_id.to_string(), - fmt_ts(&request.fire_slot), - request.run_id.to_string(), - ], + params![tenant_id.as_str(), trigger_id.to_string(), limit], ) .await - .map_err(|error| backend_error("clear active trigger fire", error))?; - returned_record(&mut rows, "read cleared trigger fire").await + .map_err(|error| backend_error("query trigger run history", error))?; + let mut runs = Vec::new(); + loop { + match rows.next().await { + Ok(Some(row)) => runs.push(row_to_run_record(&row)?), + Ok(None) => break, + Err(error) => return Err(backend_error("read trigger run history row", error)), + } + } + Ok(runs) + } + + async fn list_trigger_run_history_batch( + &self, + tenant_id: TenantId, + trigger_ids: &[TriggerId], + limit: usize, + ) -> Result>, TriggerError> { + let mut runs_by_trigger = HashMap::with_capacity(trigger_ids.len()); + if limit == 0 || trigger_ids.is_empty() { + return Ok(runs_by_trigger); + } + let limit = limit.min(crate::MAX_TRIGGER_RUN_HISTORY_LIMIT) as i64; + let placeholders = (0..trigger_ids.len()) + .map(|index| format!("?{}", index + 2)) + .collect::>() + .join(", "); + let limit_parameter = trigger_ids.len() + 2; + let sql = format!( + "SELECT {TRIGGER_RUN_COLUMNS} + FROM ( + SELECT {TRIGGER_RUN_COLUMNS}, + ROW_NUMBER() OVER (PARTITION BY trigger_id ORDER BY fire_slot DESC) AS row_rank + FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = ?1 AND trigger_id IN ({placeholders}) + ) + WHERE row_rank <= ?{limit_parameter} + ORDER BY trigger_id, fire_slot DESC" + ); + let mut params = Vec::with_capacity(trigger_ids.len() + 2); + params.push(libsql::Value::Text(tenant_id.as_str().to_string())); + params.extend( + trigger_ids + .iter() + .map(|trigger_id| libsql::Value::Text(trigger_id.to_string())), + ); + params.push(libsql::Value::Integer(limit)); + let conn = self.connect().await?; + let mut rows = conn + .query(&sql, params) + .await + .map_err(|error| backend_error("query trigger run history batch", error))?; + loop { + match rows.next().await { + Ok(Some(row)) => { + let run = row_to_run_record(&row)?; + runs_by_trigger.entry(run.trigger_id).or_default().push(run); + } + Ok(None) => break, + Err(error) => { + return Err(backend_error("read trigger run history batch row", error)); + } + } + } + Ok(runs_by_trigger) } } @@ -872,6 +1149,30 @@ async fn returned_record( } } +#[cfg(feature = "libsql")] +async fn begin_immediate(conn: &libsql::Connection, operation: &str) -> Result<(), TriggerError> { + conn.execute("BEGIN IMMEDIATE", ()) + .await + .map(|_| ()) + .map_err(|error| backend_error(operation, error)) +} + +#[cfg(feature = "libsql")] +async fn commit(conn: &libsql::Connection, operation: &str) -> Result<(), TriggerError> { + conn.execute("COMMIT", ()) + .await + .map(|_| ()) + .map_err(|error| backend_error(operation, error)) +} + +#[cfg(feature = "libsql")] +async fn rollback(conn: &libsql::Connection, operation: &str) -> Result<(), TriggerError> { + conn.execute("ROLLBACK", ()) + .await + .map(|_| ()) + .map_err(|error| backend_error(operation, error)) +} + #[cfg(feature = "libsql")] async fn write_record( conn: &libsql::Connection, @@ -969,37 +1270,64 @@ async fn mark_successful_fire_result( let next_run_at_text = fmt_ts(&update.next_run_at); let active_run_ref = update.run_id.to_string(); let last_status = status_text(TriggerRunStatus::Ok); - let mut rows = conn - .query( - &format!( - "UPDATE {TRIGGER_TABLE} - SET last_run_at = ?3, - last_fired_slot = ?4, - last_status = ?5, - next_run_at = ?6, - active_fire_slot = ?4, - active_run_ref = ?7 - WHERE tenant_id = ?1 - AND trigger_id = ?2 - AND active_fire_slot = ?4 - AND active_run_ref IS NULL - AND ?6 > ?4 - RETURNING {TRIGGER_COLUMNS}" + begin_immediate(conn, "begin successful trigger fire result").await?; + let update_result = async { + let mut rows = conn + .query( + &format!( + "UPDATE {TRIGGER_TABLE} + SET last_run_at = ?3, + last_fired_slot = ?4, + last_status = ?5, + next_run_at = ?6, + active_fire_slot = ?4, + active_run_ref = ?7 + WHERE tenant_id = ?1 + AND trigger_id = ?2 + AND active_fire_slot = ?4 + AND active_run_ref IS NULL + AND ?6 > ?4 + RETURNING {TRIGGER_COLUMNS}" + ), + params![ + update.tenant_id.as_str(), + update.trigger_id.to_string(), + result_at, + fire_slot_text, + last_status, + next_run_at_text, + active_run_ref, + ], + ) + .await + .map_err(|error| backend_error(update.update_operation, error))?; + let Some(record) = returned_record(&mut rows, update.read_operation).await? else { + return Ok(None); + }; + upsert_run_history( + conn, + &TriggerRunRecord::running( + update.tenant_id.clone(), + update.trigger_id, + update.fire_slot, + Some(update.run_id), + record.last_run_at.unwrap_or(update.result_at), ), - params![ - update.tenant_id.as_str(), - update.trigger_id.to_string(), - result_at, - fire_slot_text, - last_status, - next_run_at_text, - active_run_ref, - ], ) - .await - .map_err(|error| backend_error(update.update_operation, error))?; - if let Some(record) = returned_record(&mut rows, update.read_operation).await? { - return Ok(Some(record)); + .await?; + Ok(Some(record)) + } + .await; + match update_result { + Ok(Some(record)) => { + commit(conn, "commit successful trigger fire result").await?; + return Ok(Some(record)); + } + Ok(None) => rollback(conn, "rollback missed successful trigger fire result").await?, + Err(error) => { + rollback(conn, "rollback failed successful trigger fire result").await?; + return Err(error); + } } resolve_missed_fire_result_update( conn, @@ -1024,6 +1352,149 @@ struct SuccessfulFireResultUpdate<'a> { read_operation: &'static str, } +#[cfg(feature = "libsql")] +fn row_to_run_record(row: &libsql::Row) -> Result { + let tenant_id = TenantId::new(required_text(row, RUN_TENANT_ID_COL, "tenant_id")?) + .map_err(|error| invalid_record("tenant_id", error.to_string()))?; + let trigger_id = TriggerId::parse(&required_text(row, RUN_TRIGGER_ID_COL, "trigger_id")?)?; + let fire_slot = parse_timestamp( + &required_text(row, RUN_FIRE_SLOT_COL, "fire_slot")?, + "fire_slot", + )?; + let run_id = optional_text(row, RUN_ID_COL, "run_id")? + .map(|value| parse_turn_run_id_with_field(&value, "run_id")) + .transpose()?; + let thread_id = TriggerRouteThreadId::new(required_text(row, RUN_THREAD_ID_COL, "thread_id")?)?; + let status = parse_run_history_status(&required_text(row, RUN_STATUS_COL, "status")?)?; + let submitted_at = parse_timestamp( + &required_text(row, RUN_SUBMITTED_AT_COL, "submitted_at")?, + "submitted_at", + )?; + let completed_at = optional_text(row, RUN_COMPLETED_AT_COL, "completed_at")? + .map(|value| parse_timestamp(&value, "completed_at")) + .transpose()?; + Ok(TriggerRunRecord { + tenant_id, + trigger_id, + fire_slot, + run_id, + thread_id, + status, + submitted_at, + completed_at, + }) +} + +#[cfg(feature = "libsql")] +async fn upsert_run_history( + conn: &libsql::Connection, + run: &TriggerRunRecord, +) -> Result<(), TriggerError> { + conn.execute( + &format!( + "INSERT INTO {TRIGGER_RUN_TABLE} ( + tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (tenant_id, trigger_id, fire_slot) DO UPDATE SET + run_id = excluded.run_id, + thread_id = excluded.thread_id, + status = excluded.status, + submitted_at = excluded.submitted_at, + completed_at = excluded.completed_at" + ), + params![ + run.tenant_id.as_str(), + run.trigger_id.to_string(), + fmt_ts(&run.fire_slot), + opt_turn_run_id(run.run_id.as_ref()), + run.thread_id.as_str(), + run_history_status_text(run.status), + fmt_ts(&run.submitted_at), + opt_ts(run.completed_at.as_ref()), + ], + ) + .await + .map_err(|error| backend_error("upsert trigger run history", error))?; + prune_run_history(conn, &run.tenant_id, run.trigger_id).await?; + Ok(()) +} + +#[cfg(feature = "libsql")] +async fn complete_run_history( + conn: &libsql::Connection, + tenant_id: &TenantId, + trigger_id: TriggerId, + fire_slot: Timestamp, + run_id: Option, + status: TriggerRunHistoryStatus, + completed_at: Timestamp, +) -> Result<(), TriggerError> { + let run_id_value = opt_turn_run_id(run_id.as_ref()); + conn.execute( + &format!( + "INSERT INTO {TRIGGER_RUN_TABLE} ( + tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (tenant_id, trigger_id, fire_slot) DO UPDATE SET + run_id = COALESCE(trigger_run_history.run_id, excluded.run_id), + status = excluded.status, + completed_at = excluded.completed_at" + ), + params![ + tenant_id.as_str(), + trigger_id.to_string(), + fmt_ts(&fire_slot), + run_id_value, + TriggerRunRecord::running( + tenant_id.clone(), + trigger_id, + fire_slot, + run_id, + fire_slot, + ) + .thread_id + .as_str(), + run_history_status_text(status), + fmt_ts(&fire_slot), + fmt_ts(&completed_at), + ], + ) + .await + .map_err(|error| backend_error("complete trigger run history", error))?; + prune_run_history(conn, tenant_id, trigger_id).await?; + Ok(()) +} + +#[cfg(feature = "libsql")] +async fn prune_run_history( + conn: &libsql::Connection, + tenant_id: &TenantId, + trigger_id: TriggerId, +) -> Result<(), TriggerError> { + conn.execute( + &format!( + "DELETE FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = ?1 + AND trigger_id = ?2 + AND fire_slot NOT IN ( + SELECT fire_slot + FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = ?1 AND trigger_id = ?2 + ORDER BY fire_slot DESC + LIMIT ?3 + )" + ), + params![ + tenant_id.as_str(), + trigger_id.to_string(), + crate::MAX_TRIGGER_RUN_HISTORY_RETAINED as i64, + ], + ) + .await + .map_err(|error| backend_error("prune trigger run history", error))?; + Ok(()) +} + #[cfg(feature = "libsql")] fn required_text(row: &libsql::Row, index: usize, field: &str) -> Result { row.get(index as i32) @@ -1049,7 +1520,12 @@ fn parse_timestamp(value: &str, field: &str) -> Result #[cfg(feature = "libsql")] fn parse_turn_run_id(value: &str) -> Result { - TurnRunId::parse(value).map_err(|error| invalid_record("active_run_ref", error.to_string())) + parse_turn_run_id_with_field(value, "active_run_ref") +} + +#[cfg(feature = "libsql")] +fn parse_turn_run_id_with_field(value: &str, field: &str) -> Result { + TurnRunId::parse(value).map_err(|error| invalid_record(field, error.to_string())) } #[cfg(feature = "libsql")] @@ -1169,6 +1645,28 @@ fn parse_run_status(value: &str) -> Result { } } +#[cfg(feature = "libsql")] +fn run_history_status_text(value: TriggerRunHistoryStatus) -> &'static str { + match value { + TriggerRunHistoryStatus::Running => "running", + TriggerRunHistoryStatus::Ok => "ok", + TriggerRunHistoryStatus::Error => "error", + } +} + +#[cfg(feature = "libsql")] +fn parse_run_history_status(value: &str) -> Result { + match value { + "running" => Ok(TriggerRunHistoryStatus::Running), + "ok" => Ok(TriggerRunHistoryStatus::Ok), + "error" => Ok(TriggerRunHistoryStatus::Error), + other => Err(invalid_record( + "status", + format!("unsupported trigger run history status `{other}`"), + )), + } +} + #[cfg(feature = "libsql")] fn schedule_expression_text(schedule: &TriggerSchedule) -> String { match schedule { diff --git a/crates/ironclaw_triggers/src/postgres.rs b/crates/ironclaw_triggers/src/postgres.rs index fe1f7089847..9702d3bba84 100644 --- a/crates/ironclaw_triggers/src/postgres.rs +++ b/crates/ironclaw_triggers/src/postgres.rs @@ -1,5 +1,8 @@ +use std::collections::HashMap; + use async_trait::async_trait; use chrono::{DateTime, SecondsFormat, Utc}; +use deadpool_postgres::GenericClient; use ironclaw_host_api::{AgentId, ProjectId, TenantId, Timestamp, UserId}; use ironclaw_turns::TurnRunId; use tokio_postgres::Row; @@ -8,17 +11,20 @@ use crate::{ ActiveTriggerScanCursor, ClaimDueFireOutcome, ClaimDueFireRequest, ClaimedTriggerFire, ClearActiveFireRequest, FireAcceptedRequest, FirePermanentFailedRequest, FireReplayedRequest, FireRetryableFailedRequest, FireTerminalFailedRequest, TriggerCompletionPolicy, TriggerError, - TriggerId, TriggerRecord, TriggerRepository, TriggerRunStatus, TriggerSchedule, - TriggerSourceKind, TriggerState, reject_failed_result_after_active_run, - reject_non_future_next_run_at, reject_run_ref_rewrite, + TriggerId, TriggerRecord, TriggerRepository, TriggerRouteThreadId, TriggerRunHistoryStatus, + TriggerRunRecord, TriggerRunStatus, TriggerSchedule, TriggerSourceKind, TriggerState, + reject_failed_result_after_active_run, reject_non_future_next_run_at, reject_run_ref_rewrite, }; const TRIGGER_TABLE: &str = "trigger_records"; +const TRIGGER_RUN_TABLE: &str = "trigger_run_history"; const TRIGGER_COLUMNS: &str = "\ trigger_id, tenant_id, creator_user_id, agent_id, project_id, \ name, source, schedule_expression, completion_policy, prompt, \ state, next_run_at, last_run_at, last_fired_slot, last_status, \ active_fire_slot, active_run_ref, created_at"; +const TRIGGER_RUN_COLUMNS: &str = "\ + tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at"; const TRIGGER_MIGRATION_ADVISORY_LOCK: i64 = 717_263_529; /// PostgreSQL-backed [`TriggerRepository`] storing trigger records. @@ -417,6 +423,17 @@ impl TriggerRepository for PostgresTriggerRepository { .await .map_err(|error| backend_error("claim trigger fire", error))?; let record = row_to_record(&row)?; + upsert_run_history( + &tx, + &TriggerRunRecord::running( + request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + request.now, + ), + ) + .await?; tx.commit() .await .map_err(|error| backend_error("commit trigger fire claim", error))?; @@ -468,6 +485,17 @@ impl TriggerRepository for PostgresTriggerRepository { }, ) .await?; + upsert_run_history( + &tx, + &TriggerRunRecord::running( + request.tenant_id.clone(), + request.trigger_id, + request.fire_slot, + Some(request.run_id), + record.last_run_at.unwrap_or(request.submitted_at), + ), + ) + .await?; tx.commit() .await .map_err(|error| backend_error("commit accepted trigger fire", error))?; @@ -510,6 +538,17 @@ impl TriggerRepository for PostgresTriggerRepository { }, ) .await?; + upsert_run_history( + &tx, + &TriggerRunRecord::running( + request.tenant_id.clone(), + request.trigger_id, + request.fire_slot, + Some(request.original_run_id), + record.last_run_at.unwrap_or(request.replayed_at), + ), + ) + .await?; tx.commit() .await .map_err(|error| backend_error("commit replayed trigger fire", error))?; @@ -567,6 +606,16 @@ impl TriggerRepository for PostgresTriggerRepository { .await .map_err(|error| backend_error("mark retryable trigger fire failure", error))?; let record = row_to_record(&row)?; + complete_run_history( + &tx, + &request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), + ) + .await?; tx.commit() .await .map_err(|error| backend_error("commit retryable trigger fire failure", error))?; @@ -621,6 +670,16 @@ impl TriggerRepository for PostgresTriggerRepository { .await .map_err(|error| backend_error("mark permanent trigger fire failure", error))?; let record = row_to_record(&row)?; + complete_run_history( + &tx, + &request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), + ) + .await?; tx.commit() .await .map_err(|error| backend_error("commit permanent trigger fire failure", error))?; @@ -680,6 +739,16 @@ impl TriggerRepository for PostgresTriggerRepository { return Ok(None); }; let record = row_to_record(&row)?; + complete_run_history( + &tx, + &request.tenant_id, + request.trigger_id, + request.fire_slot, + None, + TriggerRunHistoryStatus::Error, + Utc::now(), + ) + .await?; tx.commit() .await .map_err(|error| backend_error("commit terminal trigger fire failure", error))?; @@ -690,12 +759,16 @@ impl TriggerRepository for PostgresTriggerRepository { &self, request: ClearActiveFireRequest, ) -> Result, TriggerError> { - let client = self.connect().await?; + let mut client = self.connect().await?; + let tx = client + .transaction() + .await + .map_err(|error| backend_error("begin clear active trigger fire", error))?; let trigger_id = request.trigger_id.to_string(); let fire_slot = fmt_ts(&request.fire_slot); let run_id = request.run_id.to_string(); // Keep active-fire clearing atomic as one predicate-guarded write. - let row = client + let row = tx .query_opt( &format!( "UPDATE {TRIGGER_TABLE} @@ -717,10 +790,98 @@ impl TriggerRepository for PostgresTriggerRepository { .await .map_err(|error| backend_error("clear active trigger fire", error))?; match row { - Some(row) => Ok(Some(row_to_record(&row)?)), - None => Ok(None), + Some(row) => { + complete_run_history( + &tx, + &request.tenant_id, + request.trigger_id, + request.fire_slot, + Some(request.run_id), + request.status, + Utc::now(), + ) + .await?; + let record = row_to_record(&row)?; + tx.commit() + .await + .map_err(|error| backend_error("commit clear active trigger fire", error))?; + Ok(Some(record)) + } + None => { + tx.commit().await.map_err(|error| { + backend_error("commit missed clear active trigger fire", error) + })?; + Ok(None) + } } } + + async fn list_trigger_run_history( + &self, + tenant_id: TenantId, + trigger_id: TriggerId, + limit: usize, + ) -> Result, TriggerError> { + if limit == 0 { + return Ok(Vec::new()); + } + let limit = limit.min(crate::MAX_TRIGGER_RUN_HISTORY_LIMIT) as i64; + let client = self.connect().await?; + let rows = client + .query( + &format!( + "SELECT {TRIGGER_RUN_COLUMNS} + FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = $1 AND trigger_id = $2 + ORDER BY fire_slot DESC + LIMIT $3" + ), + &[&tenant_id.as_str(), &trigger_id.to_string(), &limit], + ) + .await + .map_err(|error| backend_error("query trigger run history", error))?; + rows.iter().map(row_to_run_record).collect() + } + + async fn list_trigger_run_history_batch( + &self, + tenant_id: TenantId, + trigger_ids: &[TriggerId], + limit: usize, + ) -> Result>, TriggerError> { + let mut runs_by_trigger = HashMap::with_capacity(trigger_ids.len()); + if limit == 0 || trigger_ids.is_empty() { + return Ok(runs_by_trigger); + } + let limit = limit.min(crate::MAX_TRIGGER_RUN_HISTORY_LIMIT) as i64; + let trigger_ids = trigger_ids + .iter() + .map(ToString::to_string) + .collect::>(); + let client = self.connect().await?; + let rows = client + .query( + &format!( + "SELECT {TRIGGER_RUN_COLUMNS} + FROM ( + SELECT {TRIGGER_RUN_COLUMNS}, + ROW_NUMBER() OVER (PARTITION BY trigger_id ORDER BY fire_slot DESC) AS row_rank + FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = $1 AND trigger_id = ANY($2::text[]) + ) AS ranked_trigger_run_history + WHERE row_rank <= $3 + ORDER BY trigger_id, fire_slot DESC" + ), + &[&tenant_id.as_str(), &trigger_ids, &limit], + ) + .await + .map_err(|error| backend_error("query trigger run history batch", error))?; + for row in rows { + let run = row_to_run_record(&row)?; + runs_by_trigger.entry(run.trigger_id).or_default().push(run); + } + Ok(runs_by_trigger) + } } async fn locked_record( @@ -793,6 +954,144 @@ struct SuccessfulFireResultUpdate<'a> { operation: &'static str, } +async fn upsert_run_history( + client: &(impl GenericClient + Sync), + run: &TriggerRunRecord, +) -> Result<(), TriggerError> { + let run_id = run.run_id.as_ref().map(ToString::to_string); + let status = run_history_status_text(run.status); + let submitted_at = fmt_ts(&run.submitted_at); + let completed_at = run.completed_at.as_ref().map(fmt_ts); + client + .execute( + &format!( + "INSERT INTO {TRIGGER_RUN_TABLE} ( + tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (tenant_id, trigger_id, fire_slot) DO UPDATE SET + run_id = EXCLUDED.run_id, + thread_id = EXCLUDED.thread_id, + status = EXCLUDED.status, + submitted_at = EXCLUDED.submitted_at, + completed_at = EXCLUDED.completed_at" + ), + &[ + &run.tenant_id.as_str(), + &run.trigger_id.to_string(), + &fmt_ts(&run.fire_slot), + &run_id, + &run.thread_id.as_str(), + &status, + &submitted_at, + &completed_at, + ], + ) + .await + .map_err(|error| backend_error("upsert trigger run history", error))?; + prune_run_history(client, &run.tenant_id, run.trigger_id).await?; + Ok(()) +} + +async fn complete_run_history( + client: &(impl GenericClient + Sync), + tenant_id: &TenantId, + trigger_id: TriggerId, + fire_slot: Timestamp, + run_id: Option, + status: TriggerRunHistoryStatus, + completed_at: Timestamp, +) -> Result<(), TriggerError> { + let run_id_text = run_id.as_ref().map(ToString::to_string); + let thread_id = + TriggerRunRecord::running(tenant_id.clone(), trigger_id, fire_slot, run_id, fire_slot) + .thread_id; + let status = run_history_status_text(status); + let fire_slot_text = fmt_ts(&fire_slot); + let completed_at = fmt_ts(&completed_at); + client + .execute( + &format!( + "INSERT INTO {TRIGGER_RUN_TABLE} ( + tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at + ) VALUES ($1, $2, $3, $4, $5, $6, $3, $7) + ON CONFLICT (tenant_id, trigger_id, fire_slot) DO UPDATE SET + run_id = COALESCE(trigger_run_history.run_id, EXCLUDED.run_id), + status = EXCLUDED.status, + completed_at = EXCLUDED.completed_at" + ), + &[ + &tenant_id.as_str(), + &trigger_id.to_string(), + &fire_slot_text, + &run_id_text, + &thread_id.as_str(), + &status, + &completed_at, + ], + ) + .await + .map_err(|error| backend_error("complete trigger run history", error))?; + prune_run_history(client, tenant_id, trigger_id).await?; + Ok(()) +} + +async fn prune_run_history( + client: &(impl GenericClient + Sync), + tenant_id: &TenantId, + trigger_id: TriggerId, +) -> Result<(), TriggerError> { + let retention_limit = crate::MAX_TRIGGER_RUN_HISTORY_RETAINED as i64; + client + .execute( + &format!( + "DELETE FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = $1 + AND trigger_id = $2 + AND fire_slot NOT IN ( + SELECT fire_slot + FROM {TRIGGER_RUN_TABLE} + WHERE tenant_id = $1 AND trigger_id = $2 + ORDER BY fire_slot DESC + LIMIT $3 + )" + ), + &[ + &tenant_id.as_str(), + &trigger_id.to_string(), + &retention_limit, + ], + ) + .await + .map_err(|error| backend_error("prune trigger run history", error))?; + Ok(()) +} + +fn row_to_run_record(row: &Row) -> Result { + let tenant_id = TenantId::new(required_text(row, "tenant_id")?) + .map_err(|error| invalid_record("tenant_id", error.to_string()))?; + let trigger_id = TriggerId::parse(&required_text(row, "trigger_id")?)?; + let fire_slot = parse_timestamp(&required_text(row, "fire_slot")?, "fire_slot")?; + let run_id = optional_text(row, "run_id")? + .map(|value| parse_turn_run_id_with_field(&value, "run_id")) + .transpose()?; + let thread_id = TriggerRouteThreadId::new(required_text(row, "thread_id")?)?; + let status = parse_run_history_status(&required_text(row, "status")?)?; + let submitted_at = parse_timestamp(&required_text(row, "submitted_at")?, "submitted_at")?; + let completed_at = optional_text(row, "completed_at")? + .map(|value| parse_timestamp(&value, "completed_at")) + .transpose()?; + Ok(TriggerRunRecord { + tenant_id, + trigger_id, + fire_slot, + run_id, + thread_id, + status, + submitted_at, + completed_at, + }) +} + fn row_to_record(row: &Row) -> Result { let trigger_id = TriggerId::parse(&required_text(row, "trigger_id")?)?; let tenant_id = TenantId::new(required_text(row, "tenant_id")?) @@ -867,7 +1166,11 @@ fn parse_timestamp(value: &str, field: &str) -> Result } fn parse_turn_run_id(value: &str) -> Result { - TurnRunId::parse(value).map_err(|error| invalid_record("active_run_ref", error.to_string())) + parse_turn_run_id_with_field(value, "active_run_ref") +} + +fn parse_turn_run_id_with_field(value: &str, field: &str) -> Result { + TurnRunId::parse(value).map_err(|error| invalid_record(field, error.to_string())) } fn fmt_ts(value: &Timestamp) -> String { @@ -946,6 +1249,26 @@ fn parse_run_status(value: &str) -> Result { } } +fn run_history_status_text(value: TriggerRunHistoryStatus) -> &'static str { + match value { + TriggerRunHistoryStatus::Running => "running", + TriggerRunHistoryStatus::Ok => "ok", + TriggerRunHistoryStatus::Error => "error", + } +} + +fn parse_run_history_status(value: &str) -> Result { + match value { + "running" => Ok(TriggerRunHistoryStatus::Running), + "ok" => Ok(TriggerRunHistoryStatus::Ok), + "error" => Ok(TriggerRunHistoryStatus::Error), + other => Err(invalid_record( + "status", + format!("unsupported trigger run history status `{other}`"), + )), + } +} + fn schedule_expression_text(schedule: &TriggerSchedule) -> String { match schedule { TriggerSchedule::Cron { expression } => expression.clone(), @@ -999,4 +1322,19 @@ CREATE INDEX IF NOT EXISTS trigger_records_scoped_list_idx CREATE INDEX IF NOT EXISTS trigger_records_active_fire_slot_idx ON trigger_records (active_fire_slot, tenant_id, trigger_id) WHERE active_fire_slot IS NOT NULL; + +CREATE TABLE IF NOT EXISTS trigger_run_history ( + tenant_id TEXT NOT NULL, + trigger_id TEXT NOT NULL, + fire_slot TEXT NOT NULL, + run_id TEXT, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + submitted_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (tenant_id, trigger_id, fire_slot) +); + +CREATE INDEX IF NOT EXISTS trigger_run_history_trigger_fire_slot_idx + ON trigger_run_history (tenant_id, trigger_id, fire_slot DESC); "#; diff --git a/crates/ironclaw_triggers/src/worker/active_cleanup.rs b/crates/ironclaw_triggers/src/worker/active_cleanup.rs index c2c1889b9e3..51d48f7ac85 100644 --- a/crates/ironclaw_triggers/src/worker/active_cleanup.rs +++ b/crates/ironclaw_triggers/src/worker/active_cleanup.rs @@ -1,4 +1,9 @@ -use crate::{ActiveTriggerScanCursor, ClearActiveFireRequest, TriggerError, TriggerRecord}; +use ironclaw_turns::TurnStatus; + +use crate::{ + ActiveTriggerScanCursor, ClearActiveFireRequest, TriggerError, TriggerRecord, + TriggerRunHistoryStatus, +}; use super::{ TriggerActiveRunState, TriggerActiveRunStateRequest, TriggerPollerFailureReason, @@ -112,7 +117,7 @@ impl TriggerPollerWorker { } }; match state { - TriggerActiveRunState::Terminal => { + TriggerActiveRunState::Terminal { status } => { if self .deps .repository @@ -121,6 +126,7 @@ impl TriggerPollerWorker { trigger_id: record.trigger_id, fire_slot, run_id, + status: terminal_run_history_status(status), }) .await? .is_some() @@ -206,3 +212,19 @@ impl TriggerPollerWorker { Ok(()) } } + +fn terminal_run_history_status(status: TurnStatus) -> TriggerRunHistoryStatus { + match status { + TurnStatus::Completed => TriggerRunHistoryStatus::Ok, + TurnStatus::Cancelled | TurnStatus::Failed | TurnStatus::RecoveryRequired => { + TriggerRunHistoryStatus::Error + } + TurnStatus::Queued + | TurnStatus::Running + | TurnStatus::BlockedApproval + | TurnStatus::BlockedAuth + | TurnStatus::BlockedResource + | TurnStatus::BlockedDependentRun + | TurnStatus::CancelRequested => TriggerRunHistoryStatus::Error, + } +} diff --git a/crates/ironclaw_triggers/src/worker/ports.rs b/crates/ironclaw_triggers/src/worker/ports.rs index f180f79d2af..a3aa71e9985 100644 --- a/crates/ironclaw_triggers/src/worker/ports.rs +++ b/crates/ironclaw_triggers/src/worker/ports.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use ironclaw_host_api::{TenantId, Timestamp}; -use ironclaw_turns::TurnRunId; +use ironclaw_turns::{TurnRunId, TurnStatus}; use crate::{TriggerError, TriggerFire, TriggerId, TriggerMaterializedPrompt}; @@ -83,7 +83,7 @@ pub struct TriggerActiveRunStateRequest { pub enum TriggerActiveRunState { Missing, Nonterminal, - Terminal, + Terminal { status: TurnStatus }, } #[async_trait] diff --git a/crates/ironclaw_triggers/src/worker/tests.rs b/crates/ironclaw_triggers/src/worker/tests.rs index 0066fb46afb..546b4653094 100644 --- a/crates/ironclaw_triggers/src/worker/tests.rs +++ b/crates/ironclaw_triggers/src/worker/tests.rs @@ -6,7 +6,7 @@ use std::{ use async_trait::async_trait; use chrono::{TimeZone, Utc}; use ironclaw_host_api::{AgentId, ProjectId, TenantId, Timestamp, UserId}; -use ironclaw_turns::TurnRunId; +use ironclaw_turns::{TurnRunId, TurnStatus}; use super::*; use crate::{ @@ -16,8 +16,8 @@ use crate::{ TRIGGER_TRUSTED_ADAPTER_INSTALLATION_ID, TRIGGER_TRUSTED_ADAPTER_KIND, TRIGGER_TRUSTED_EXTERNAL_ACTOR_NAMESPACE, TriggerCompletionPolicy, TriggerError, TriggerFire, TriggerId, TriggerInboundContentRef, TriggerMaterializedPrompt, TriggerPromptMaterializer, - TriggerRecord, TriggerRepository, TriggerRunStatus, TriggerSchedule, TriggerSourceKind, - TriggerSourceProvider, TriggerState, + TriggerRecord, TriggerRepository, TriggerRunHistoryStatus, TriggerRunStatus, TriggerSchedule, + TriggerSourceKind, TriggerSourceProvider, TriggerState, }; fn ts(seconds: i64) -> Timestamp { @@ -526,7 +526,9 @@ async fn tick_clears_terminal_active_run() { record.active_run_ref = Some(run_id); repo.upsert_trigger(record).await.expect("insert active"); let active_lookup = Arc::new(RecordingActiveRunLookup::with_state( - TriggerActiveRunState::Terminal, + TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }, )); let worker = worker( repo.clone(), @@ -558,6 +560,48 @@ async fn tick_clears_terminal_active_run() { .expect("record present"); assert_eq!(persisted.active_fire_slot, None); assert_eq!(persisted.active_run_ref, None); + let runs = repo + .list_trigger_run_history(tenant("tenant-a"), trigger_id, 10) + .await + .expect("list run history"); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Ok); +} + +#[tokio::test] +async fn tick_records_failed_terminal_active_run_as_error() { + let repo = Arc::new(InMemoryTriggerRepository::default()); + let trigger_id = TriggerId::parse("01HZZZZZZZZZZZZZZZZZZZZZZY").expect("ulid"); + let fire_slot = ts(1_704_067_200); + let run_id = TurnRunId::parse("01890f0f-9b6f-7a85-9e5b-9f21a93c4f5d").expect("run id"); + let mut record = sample_record(trigger_id, tenant("tenant-a"), ts(1_704_067_260)); + record.active_fire_slot = Some(fire_slot); + record.active_run_ref = Some(run_id); + repo.upsert_trigger(record).await.expect("insert active"); + let worker = worker( + repo.clone(), + Arc::new(RecordingMaterializer::success("content:trigger-fire")), + Arc::new(RecordingSubmitter::with_outcomes(Vec::new())), + Arc::new(RecordingActiveRunLookup::with_state( + TriggerActiveRunState::Terminal { + status: TurnStatus::Failed, + }, + )), + ); + + let report = worker.tick_once(fire_slot).await.expect("tick succeeds"); + + assert_eq!( + report.results.last().map(|result| &result.outcome), + Some(&TriggerPollerFireOutcome::ClearedTerminalActive { run_id }) + ); + let runs = repo + .list_trigger_run_history(tenant("tenant-a"), trigger_id, 10) + .await + .expect("list run history"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].run_id, Some(run_id)); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Error); + assert!(runs[0].completed_at.is_some()); } #[tokio::test] @@ -607,7 +651,9 @@ async fn tick_active_cleanup_cursor_reaches_terminal_rows_after_blocked_page() { Ok(TriggerActiveRunState::Nonterminal), Ok(TriggerActiveRunState::Nonterminal), Ok(TriggerActiveRunState::Nonterminal), - Ok(TriggerActiveRunState::Terminal), + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }), ])); let worker = worker_with_config( repo.clone(), @@ -830,10 +876,18 @@ async fn tick_retries_active_page_when_clear_fails_before_advancing_cursor() { second_id, )); let active_lookup = Arc::new(RecordingActiveRunLookup::with_results(vec![ - Ok(TriggerActiveRunState::Terminal), - Ok(TriggerActiveRunState::Terminal), - Ok(TriggerActiveRunState::Terminal), - Ok(TriggerActiveRunState::Terminal), + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }), + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }), + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }), + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }), ])); let worker = worker_with_config( repo.clone(), @@ -890,7 +944,9 @@ async fn tick_reports_terminal_active_clear_race() { Arc::new(RecordingMaterializer::success("content:trigger-fire")), Arc::new(RecordingSubmitter::with_outcomes(Vec::new())), Arc::new(RecordingActiveRunLookup::with_state( - TriggerActiveRunState::Terminal, + TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }, )), ); @@ -928,7 +984,9 @@ async fn tick_clears_terminal_active_and_processes_due_trigger() { }, )])), Arc::new(RecordingActiveRunLookup::with_state( - TriggerActiveRunState::Terminal, + TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }, )), ); @@ -1029,8 +1087,12 @@ async fn tick_retries_active_lookup_error_before_advancing_cursor() { Err(TriggerError::Backend { reason: "turn state unavailable".to_string(), }), - Ok(TriggerActiveRunState::Terminal), - Ok(TriggerActiveRunState::Terminal), + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }), + Ok(TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }), ])); let worker = worker_with_config( repo.clone(), @@ -1139,7 +1201,9 @@ async fn tick_replayed_submit_can_be_cleared_on_a_later_tick_without_stopping_du }, )])), Arc::new(RecordingActiveRunLookup::with_results(vec![Ok( - TriggerActiveRunState::Terminal, + TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }, )])), ); @@ -1244,7 +1308,9 @@ async fn tick_keeps_claim_only_active_fire_blocked() { let materializer = Arc::new(RecordingMaterializer::success("content:trigger-fire")); let submitter = Arc::new(RecordingSubmitter::with_outcomes(Vec::new())); let active_lookup = Arc::new(RecordingActiveRunLookup::with_state( - TriggerActiveRunState::Terminal, + TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }, )); let worker = worker( repo.clone(), @@ -1301,7 +1367,9 @@ async fn tick_active_cleanup_cursor_advances_past_claim_only_record() { let materializer = Arc::new(RecordingMaterializer::success("content:trigger-fire")); let submitter = Arc::new(RecordingSubmitter::with_outcomes(Vec::new())); let active_lookup = Arc::new(RecordingActiveRunLookup::with_state( - TriggerActiveRunState::Terminal, + TriggerActiveRunState::Terminal { + status: TurnStatus::Completed, + }, )); let worker = worker_with_config( repo.clone(), diff --git a/crates/ironclaw_triggers/tests/repository_contract.rs b/crates/ironclaw_triggers/tests/repository_contract.rs index 45c8b0f8ef3..34147864ee3 100644 --- a/crates/ironclaw_triggers/tests/repository_contract.rs +++ b/crates/ironclaw_triggers/tests/repository_contract.rs @@ -1326,6 +1326,7 @@ mod fire_claim_contract { use ironclaw_triggers::{ ClaimDueFireOutcome, ClaimDueFireRequest, FireAcceptedRequest, FirePermanentFailedRequest, FireReplayedRequest, FireRetryableFailedRequest, FireTerminalFailedRequest, + TriggerFireIdentity, TriggerRunHistoryStatus, }; async fn assert_fire_claim_and_update_contract(repo: &impl TriggerRepository) { @@ -1908,6 +1909,7 @@ mod fire_claim_contract { fire_slot, run_id: TurnRunId::parse("01890f0f-9b6f-7a85-9e5b-9f21a93c4f67") .expect("valid run"), + status: TriggerRunHistoryStatus::Ok, }) .await .expect("clear with wrong run ref"); @@ -1928,6 +1930,7 @@ mod fire_claim_contract { trigger_id, fire_slot: fire_slot + chrono::Duration::minutes(1), run_id, + status: TriggerRunHistoryStatus::Ok, }) .await .expect("clear with wrong fire slot"); @@ -1948,6 +1951,7 @@ mod fire_claim_contract { trigger_id, fire_slot, run_id, + status: TriggerRunHistoryStatus::Ok, }) .await .expect("clear with wrong tenant"); @@ -1968,6 +1972,7 @@ mod fire_claim_contract { trigger_id, fire_slot, run_id, + status: TriggerRunHistoryStatus::Ok, }) .await .expect("clear active fire") @@ -2491,6 +2496,253 @@ mod fire_claim_contract { assert_fire_claim_exclusions_and_active_gate_contract(repo).await; assert_fire_result_rejects_invalid_next_run_at(repo).await; assert_fire_clear_contract(repo).await; + assert_run_history_lifecycle_contract(repo).await; + assert_run_history_retention_contract(repo).await; + } + + async fn assert_run_history_lifecycle_contract(repo: &impl TriggerRepository) { + let trigger_id = TriggerId::parse("01J00000000000000000000030").expect("ulid"); + let tenant_id = tenant("tenant-run-history"); + let fire_slot = ts(1_704_067_200); + let claim_now = ts(1_704_067_203); + let submitted_at = ts(1_704_067_205); + let record = sample_record(trigger_id, tenant_id.clone(), fire_slot); + let expected_next_run_at = record + .schedule + .next_slot_after(fire_slot) + .expect("next slot calculation") + .expect("future slot"); + repo.upsert_trigger(record).await.expect("insert record"); + + let claimed = repo + .claim_due_fire(ClaimDueFireRequest { + tenant_id: tenant_id.clone(), + trigger_id, + fire_slot, + now: claim_now, + }) + .await + .expect("claim fire"); + assert!(matches!(claimed, ClaimDueFireOutcome::Claimed(_))); + + let runs = repo + .list_trigger_run_history(tenant_id.clone(), trigger_id, 10) + .await + .expect("list claimed run history"); + assert_eq!(runs.len(), 1); + let expected_thread_id = + TriggerFireIdentity::new(tenant_id.clone(), trigger_id, fire_slot).route_thread_id; + assert_eq!(runs[0].tenant_id, tenant_id); + assert_eq!(runs[0].trigger_id, trigger_id); + assert_eq!(runs[0].fire_slot, fire_slot); + assert_eq!(runs[0].run_id, None); + assert_eq!(runs[0].thread_id, expected_thread_id); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Running); + assert_eq!(runs[0].submitted_at, claim_now); + assert_eq!(runs[0].completed_at, None); + + let run_id = TurnRunId::parse("01890f0f-9b6f-7a85-9e5b-9f21a93c4f80").expect("valid run"); + repo.mark_fire_accepted(FireAcceptedRequest { + tenant_id: tenant_id.clone(), + trigger_id, + fire_slot, + run_id, + submitted_at, + next_run_at: expected_next_run_at, + }) + .await + .expect("mark accepted") + .expect("accepted fire should persist"); + + let runs = repo + .list_trigger_run_history(tenant_id.clone(), trigger_id, 10) + .await + .expect("list accepted run history"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].run_id, Some(run_id)); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Running); + assert_eq!(runs[0].submitted_at, submitted_at); + assert_eq!(runs[0].completed_at, None); + + repo.clear_active_fire(ClearActiveFireRequest { + tenant_id: tenant_id.clone(), + trigger_id, + fire_slot, + run_id, + status: TriggerRunHistoryStatus::Ok, + }) + .await + .expect("clear active fire") + .expect("active fire should clear"); + + let runs = repo + .list_trigger_run_history(tenant_id.clone(), trigger_id, 10) + .await + .expect("list cleared run history"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].run_id, Some(run_id)); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Ok); + assert_eq!(runs[0].submitted_at, submitted_at); + assert!(runs[0].completed_at.is_some()); + + let empty_runs = repo + .list_trigger_run_history(tenant_id.clone(), trigger_id, 0) + .await + .expect("zero limit returns empty run history"); + assert!(empty_runs.is_empty()); + + let second_fire_slot = expected_next_run_at; + let second_claim_now = second_fire_slot + chrono::Duration::seconds(3); + let second_submitted_at = second_fire_slot + chrono::Duration::seconds(5); + let second_run_id = + TurnRunId::parse("01890f0f-9b6f-7a85-9e5b-9f21a93c4f81").expect("valid run"); + let second_next_run_at = second_fire_slot + chrono::Duration::days(1); + let second_claimed = repo + .claim_due_fire(ClaimDueFireRequest { + tenant_id: tenant_id.clone(), + trigger_id, + fire_slot: second_fire_slot, + now: second_claim_now, + }) + .await + .expect("claim second fire"); + assert!(matches!(second_claimed, ClaimDueFireOutcome::Claimed(_))); + repo.mark_fire_accepted(FireAcceptedRequest { + tenant_id: tenant_id.clone(), + trigger_id, + fire_slot: second_fire_slot, + run_id: second_run_id, + submitted_at: second_submitted_at, + next_run_at: second_next_run_at, + }) + .await + .expect("mark second fire accepted") + .expect("second accepted fire should persist"); + + let newest_limited_runs = repo + .list_trigger_run_history(tenant_id.clone(), trigger_id, 1) + .await + .expect("list bounded run history"); + assert_eq!(newest_limited_runs.len(), 1); + assert_eq!(newest_limited_runs[0].fire_slot, second_fire_slot); + assert_eq!(newest_limited_runs[0].run_id, Some(second_run_id)); + assert_eq!( + newest_limited_runs[0].status, + TriggerRunHistoryStatus::Running + ); + + let other_trigger_id = TriggerId::parse("01J00000000000000000000032").expect("ulid"); + let batch_runs = repo + .list_trigger_run_history_batch(tenant_id.clone(), &[trigger_id, other_trigger_id], 1) + .await + .expect("list batched run history"); + assert_eq!( + batch_runs + .get(&trigger_id) + .expect("trigger history present")[0] + .fire_slot, + second_fire_slot + ); + assert!( + batch_runs + .get(&other_trigger_id) + .map(Vec::is_empty) + .unwrap_or(true) + ); + + let other_trigger_runs = repo + .list_trigger_run_history(tenant_id.clone(), other_trigger_id, 10) + .await + .expect("list other trigger history"); + assert!(other_trigger_runs.is_empty()); + + let other_tenant_runs = repo + .list_trigger_run_history(tenant("tenant-run-history-other"), trigger_id, 10) + .await + .expect("list other tenant history"); + assert!(other_tenant_runs.is_empty()); + + let failed_trigger_id = TriggerId::parse("01J00000000000000000000031").expect("ulid"); + let failed_tenant_id = tenant("tenant-run-history-failed"); + repo.upsert_trigger(sample_record( + failed_trigger_id, + failed_tenant_id.clone(), + fire_slot, + )) + .await + .expect("insert failed record"); + let failed_claim = repo + .claim_due_fire(ClaimDueFireRequest { + tenant_id: failed_tenant_id.clone(), + trigger_id: failed_trigger_id, + fire_slot, + now: claim_now, + }) + .await + .expect("claim failed fire"); + assert!(matches!(failed_claim, ClaimDueFireOutcome::Claimed(_))); + + repo.mark_fire_terminally_failed(FireTerminalFailedRequest { + tenant_id: failed_tenant_id.clone(), + trigger_id: failed_trigger_id, + fire_slot, + }) + .await + .expect("mark terminal failure") + .expect("terminal failure should persist"); + + let runs = repo + .list_trigger_run_history(failed_tenant_id, failed_trigger_id, 10) + .await + .expect("list failed run history"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].run_id, None); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Error); + assert!(runs[0].completed_at.is_some()); + } + + async fn assert_run_history_retention_contract(repo: &impl TriggerRepository) { + let trigger_id = TriggerId::parse("01J00000000000000000000033").expect("ulid"); + let tenant_id = tenant("tenant-run-history-retention"); + let base_fire_slot = ts(1_704_067_200); + repo.upsert_trigger(sample_record(trigger_id, tenant_id.clone(), base_fire_slot)) + .await + .expect("insert retention record"); + + for offset in 0..=500 { + let fire_slot = base_fire_slot + chrono::Duration::minutes(offset); + let run_id = TurnRunId::new(); + let mut active_record = sample_record(trigger_id, tenant_id.clone(), fire_slot); + active_record.active_fire_slot = Some(fire_slot); + active_record.active_run_ref = Some(run_id); + repo.upsert_trigger(active_record) + .await + .expect("upsert active retention record"); + repo.clear_active_fire(ClearActiveFireRequest { + tenant_id: tenant_id.clone(), + trigger_id, + fire_slot, + run_id, + status: TriggerRunHistoryStatus::Ok, + }) + .await + .expect("clear retention fire") + .expect("active fire should clear"); + } + + let retained = repo + .list_trigger_run_history(tenant_id, trigger_id, 501) + .await + .expect("list retained run history"); + assert_eq!(retained.len(), 500); + assert_eq!( + retained.first().expect("newest retained").fire_slot, + base_fire_slot + chrono::Duration::minutes(500) + ); + assert_eq!( + retained.last().expect("oldest retained").fire_slot, + base_fire_slot + chrono::Duration::minutes(1) + ); } fn assert_error_contains(error: TriggerError, expected: &str) { @@ -2507,6 +2759,8 @@ mod fire_claim_contract { assert_fire_claim_exclusions_and_active_gate_contract(&repo).await; assert_fire_result_rejects_invalid_next_run_at(&repo).await; assert_fire_clear_contract(&repo).await; + assert_run_history_lifecycle_contract(&repo).await; + assert_run_history_retention_contract(&repo).await; } #[cfg(feature = "libsql")] diff --git a/crates/ironclaw_webui_v2/CLAUDE.md b/crates/ironclaw_webui_v2/CLAUDE.md index 5fc1cdc06a4..3b1bbc591bb 100644 --- a/crates/ironclaw_webui_v2/CLAUDE.md +++ b/crates/ironclaw_webui_v2/CLAUDE.md @@ -60,7 +60,7 @@ browser-reachable. | `webui.v2.stream_events_ws` | GET | `/api/webchat/v2/threads/{thread_id}/ws` | WebSocket | `ProjectionOnly` | | `webui.v2.cancel_run` | POST | `/api/webchat/v2/threads/{thread_id}/runs/{run_id}/cancel` | None | `TurnCoordinator` | | `webui.v2.resolve_gate` | POST | `/api/webchat/v2/threads/{thread_id}/runs/{run_id}/gates/{gate_ref}/resolve` | None | `TurnCoordinator` | -| `webui.v2.list_automations` | GET | `/api/webchat/v2/automations` (optional `?limit=N`) | None | `ProductWorkflow` | +| `webui.v2.list_automations` | GET | `/api/webchat/v2/automations` (optional `?limit=N&run_limit=N`) | None | `ProductWorkflow` | | `webui.v2.list_connectable_channels` | GET | `/api/webchat/v2/channels/connectable` | None | `ProjectionOnly` | | `webui.v2.list_extensions` | GET | `/api/webchat/v2/extensions` | None | `ProjectionOnly` | | `webui.v2.list_extension_registry` | GET | `/api/webchat/v2/extensions/registry` | None | `ProjectionOnly` | diff --git a/crates/ironclaw_webui_v2/src/handlers.rs b/crates/ironclaw_webui_v2/src/handlers.rs index 43e12a66fd7..1a0dd167619 100644 --- a/crates/ironclaw_webui_v2/src/handlers.rs +++ b/crates/ironclaw_webui_v2/src/handlers.rs @@ -433,14 +433,18 @@ pub struct ListThreadsQuery { /// `GET /api/webchat/v2/automations` /// /// Lists the caller-scoped schedule automations visible to the browser. The -/// optional `?limit=N` query is capped by the product workflow facade; the -/// response is a single bounded page and does not include a cursor. +/// optional `?limit=N` and `?run_limit=N` queries are capped by the product +/// workflow facade; the response is a single bounded page and does not include +/// a cursor. pub async fn list_automations( State(state): State, Extension(caller): Extension, Query(query): Query, ) -> Result, WebUiV2HttpError> { - let request = WebUiListAutomationsRequest { limit: query.limit }; + let request = WebUiListAutomationsRequest { + limit: query.limit, + run_limit: query.run_limit, + }; let response = state.services().list_automations(caller, request).await?; Ok(Json(response)) } @@ -450,6 +454,9 @@ pub struct ListAutomationsQuery { /// Optional maximum number of schedule automations to return. #[serde(default)] pub limit: Option, + /// Optional maximum number of recent runs to return per automation row. + #[serde(default)] + pub run_limit: Option, } /// `GET /api/webchat/v2/channels/connectable` diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs index d3708e57be6..01cf9738bad 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -28,7 +28,8 @@ use ironclaw_product_adapters::{ }; use ironclaw_product_workflow::{ LifecyclePackageRef, LifecyclePhase, LlmActiveSelection, LlmConfigSnapshot, LlmModelsResult, - LlmProbeRequest, LlmProbeResult, LlmProviderView, RebornAutomationInfo, RebornAutomationSource, + LlmProbeRequest, LlmProbeResult, LlmProviderView, RebornAutomationInfo, + RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest, @@ -656,6 +657,14 @@ fn automation_info(automation_id: &str, name: &str, cron: &str) -> RebornAutomat next_run_at: None, last_run_at: None, last_status: None, + recent_runs: vec![RebornAutomationRecentRunInfo { + run_id: Some("run-listed".to_string()), + thread_id: "thread-listed".to_string(), + fire_slot: None, + status: RebornAutomationRecentRunStatus::Running, + submitted_at: "2026-06-03T09:00:01Z".parse().expect("submitted at"), + completed_at: None, + }], is_active: true, created_at: None, } @@ -1015,7 +1024,7 @@ async fn stream_events_last_event_id_header_takes_precedence_over_query() { } #[tokio::test] -async fn list_automations_forwards_query_limit_to_facade() { +async fn list_automations_forwards_query_limits_to_facade() { let services = Arc::new(StubServices::default()); let router = router_with(services.clone()); @@ -1023,7 +1032,7 @@ async fn list_automations_forwards_query_limit_to_facade() { .oneshot( Request::builder() .method(Method::GET) - .uri("/api/webchat/v2/automations?limit=5") + .uri("/api/webchat/v2/automations?limit=5&run_limit=7") .body(Body::empty()) .expect("request"), ) @@ -1033,6 +1042,14 @@ async fn list_automations_forwards_query_limit_to_facade() { assert_eq!(response.status(), StatusCode::OK); let body = read_json(response).await; assert_eq!(body["automations"][0]["automation_id"], "automation-listed"); + assert_eq!( + body["automations"][0]["recent_runs"][0]["thread_id"], + "thread-listed" + ); + assert_eq!( + body["automations"][0]["recent_runs"][0]["status"], + "running" + ); let calls = services .list_automations_calls @@ -1041,10 +1058,11 @@ async fn list_automations_forwards_query_limit_to_facade() { .clone(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].limit, Some(5)); + assert_eq!(calls[0].run_limit, Some(7)); } #[tokio::test] -async fn list_automations_omits_limit_and_forwards_none() { +async fn list_automations_omits_limits_and_forwards_none() { let services = Arc::new(StubServices::default()); let router = router_with(services.clone()); @@ -1070,6 +1088,7 @@ async fn list_automations_omits_limit_and_forwards_none() { .clone(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].limit, None); + assert_eq!(calls[0].run_limit, None); } #[tokio::test] @@ -1099,6 +1118,33 @@ async fn list_automations_rejects_invalid_limit_query_with_400() { ); } +#[tokio::test] +async fn list_automations_rejects_invalid_run_limit_query_with_400() { + let services = Arc::new(StubServices::default()); + let router = router_with(services.clone()); + + let response = router + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/webchat/v2/automations?run_limit=not-a-number") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!( + services + .list_automations_calls + .lock() + .expect("lock") + .is_empty(), + "invalid query input must be rejected before reaching the facade" + ); +} + #[tokio::test] async fn list_automations_error_maps_to_http_status() { let services = Arc::new(StubServices::default()); diff --git a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js index fdb100a5708..0ba15c4fd29 100644 --- a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js +++ b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js @@ -628,6 +628,8 @@ registerPack("en", { "automations.filterLabel": "Automation status filter", "automations.filter.all": "All", "automations.filter.active": "Active", + "automations.filter.running": "Running", + "automations.filter.failures": "Failures", "automations.filter.paused": "Paused", "automations.refresh": "Refresh automations", "automations.error.loadFailed": "Unable to load automations", @@ -637,9 +639,10 @@ registerPack("en", { "automations.summary.active": "Active", "automations.summary.activeDetail": "Enabled schedules waiting for their next run.", - "automations.summary.paused": "Paused", - "automations.summary.pausedDetail": - "Schedules currently not expected to run.", + "automations.summary.running": "Running now", + "automations.summary.runningDetail": "Runs currently in progress.", + "automations.summary.failures": "Failures", + "automations.summary.failuresDetail": "Failed runs in the visible history.", "automations.summary.nextRun": "Next run", "automations.summary.none": "None", "automations.summary.nextRunDetail": "Soonest scheduled run in this list.", @@ -650,8 +653,25 @@ registerPack("en", { "automations.table.name": "Name", "automations.table.schedule": "Schedule", "automations.table.nextRun": "Next run", - "automations.table.lastRun": "Last run", + "automations.table.recentRuns": "Recent runs", + "automations.table.noRuns": "No runs", "automations.table.status": "Status", + "automations.status.running": "Running", + "automations.status.needsReview": "Needs review", + "automations.detail.emptyTitle": "Select an automation", + "automations.detail.emptyDescription": + "Choose a schedule to inspect recent runs.", + "automations.detail.schedule": "Schedule", + "automations.detail.successRate": "Success rate", + "automations.detail.lastCompleted": "Last completed", + "automations.detail.currentRun": "Current run", + "automations.detail.noCurrentRun": "No active run", + "automations.detail.recentRuns": "Recent runs", + "automations.detail.noRuns": "This automation has not produced any visible runs yet.", + "automations.detail.openRun": "Open run", + "automations.detail.thread": "thread", + "automations.detail.run": "run", + "automations.detail.noThread": "No thread attached", // Routines page "routines.explorer": "Tasks", diff --git a/crates/ironclaw_webui_v2_static/static/js/lib/api.js b/crates/ironclaw_webui_v2_static/static/js/lib/api.js index 075ce16d1a1..f67301b6182 100644 --- a/crates/ironclaw_webui_v2_static/static/js/lib/api.js +++ b/crates/ironclaw_webui_v2_static/static/js/lib/api.js @@ -135,9 +135,10 @@ export function deleteThread({ threadId } = {}) { // --- Automations --- -export function listAutomations({ limit } = {}) { +export function listAutomations({ limit, runLimit } = {}) { const params = new URLSearchParams(); if (limit != null) params.set("limit", String(limit)); + if (runLimit != null) params.set("run_limit", String(runLimit)); const query = params.toString(); return apiFetch(`${V2_BASE}/automations${query ? `?${query}` : ""}`); } diff --git a/crates/ironclaw_webui_v2_static/static/js/lib/api.test.mjs b/crates/ironclaw_webui_v2_static/static/js/lib/api.test.mjs index f64974a3ed4..8f115a10d46 100644 --- a/crates/ironclaw_webui_v2_static/static/js/lib/api.test.mjs +++ b/crates/ironclaw_webui_v2_static/static/js/lib/api.test.mjs @@ -18,11 +18,11 @@ test("listAutomations reads through the v2 automations route", async () => { }); }; - const response = await listAutomations({ limit: 50 }); + const response = await listAutomations({ limit: 50, runLimit: 25 }); assert.deepEqual(response, { automations: [] }); assert.equal(calls.length, 1); - assert.equal(calls[0].path, "/api/webchat/v2/automations?limit=50"); + assert.equal(calls[0].path, "/api/webchat/v2/automations?limit=50&run_limit=25"); assert.equal(calls[0].options.credentials, "same-origin"); assert.equal(calls[0].options.headers.get("Authorization"), "Bearer token-1"); }); diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/automations-page.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/automations-page.js index 3f77eefc3e6..6631490959c 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/automations-page.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/automations-page.js @@ -7,12 +7,26 @@ import { useAutomations } from "./hooks/useAutomations.js"; export function AutomationsPage() { const t = useT(); const [filter, setFilter] = React.useState("all"); + const [selectedAutomationId, setSelectedAutomationId] = React.useState(null); const automationsState = useAutomations(); const showErrorOnly = automationsState.error && !automationsState.isLoading && automationsState.automations.length === 0; + React.useEffect(() => { + if (!automationsState.automations.length) { + setSelectedAutomationId(null); + return; + } + const stillExists = automationsState.automations.some( + (automation) => automation.automation_id === selectedAutomationId + ); + if (!stillExists) { + setSelectedAutomationId(automationsState.automations[0].automation_id); + } + }, [automationsState.automations, selectedAutomationId]); + return html`
@@ -50,6 +64,8 @@ export function AutomationsPage() { onFilterChange=${setFilter} onRefresh=${automationsState.refetch} isRefreshing=${automationsState.isRefreshing} + selectedAutomationId=${selectedAutomationId} + onSelectAutomation=${setSelectedAutomationId} /> `} `} diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js index 197b9eac36a..880825c97fa 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js @@ -1,3 +1,4 @@ +import { useNavigate } from "react-router"; import { Button } from "../../../design-system/button.js"; import { Icon } from "../../../design-system/icons.js"; import { EmptyPanel, Panel, StatusPill } from "../../../design-system/primitives.js"; @@ -9,19 +10,195 @@ import { filterAutomations } from "../lib/automations-presenters.js"; const AUTOMATION_FILTERS = [ { value: "all", labelKey: "automations.filter.all" }, { value: "active", labelKey: "automations.filter.active" }, + { value: "running", labelKey: "automations.filter.running" }, + { value: "failures", labelKey: "automations.filter.failures" }, { value: "paused", labelKey: "automations.filter.paused" }, ]; +function MetaItem({ label, value, tone }) { + return html` +
+
+ ${label} +
+
+ ${value || "—"} +
+
+ `; +} + +function RunDots({ runs }) { + const t = useT(); + const visibleRuns = runs.slice(0, 8); + if (!visibleRuns.length) { + return html`${t("automations.table.noRuns")}`; + } + + return html` +
+ ${visibleRuns.map((run) => html` + + `)} +
+ `; +} + +function RecentRunRow({ run, onOpenRun }) { + const t = useT(); + const canOpen = Boolean(run.chat_path); + + return html` +
+
+ <${StatusPill} tone=${run.status_tone} label=${run.status_label} /> +
+
+
${run.fired_label}
+
+ ${run.thread_id + ? `${t("automations.detail.thread")} ${run.thread_id}` + : t("automations.detail.noThread")} +
+ ${run.run_id && + html` +
+ ${t("automations.detail.run")} ${run.run_id} +
+ `} +
+ <${Button} + variant="secondary" + size="sm" + disabled=${!canOpen} + onClick=${canOpen ? () => onOpenRun(run.chat_path) : undefined} + > + <${Icon} name="chat" className="h-4 w-4" /> + ${t("automations.detail.openRun")} + +
+ `; +} + +function AutomationDetailPanel({ automation }) { + const t = useT(); + const navigate = useNavigate(); + + if (!automation) { + return html` + <${Panel} className="p-4 sm:p-5"> + <${EmptyPanel} + boxed=${false} + title=${t("automations.detail.emptyTitle")} + description=${t("automations.detail.emptyDescription")} + /> + + `; + } + + const activeRun = automation.current_run; + + return html` + <${Panel} className="overflow-hidden"> +
+
+
+

+ ${automation.display_name} +

+
+ ${automation.automation_id} +
+
+ <${StatusPill} + tone=${automation.has_running_run ? "info" : automation.state_tone} + label=${automation.has_running_run + ? t("automations.status.running") + : automation.state_label} + /> +
+
+ +
+
+ <${MetaItem} label=${t("automations.detail.schedule")} value=${automation.schedule_label} /> + <${MetaItem} + label=${t("automations.detail.successRate")} + value=${automation.success_rate_label} + tone=${automation.has_failed_runs ? "danger" : "success"} + /> + <${MetaItem} label=${t("automations.detail.lastCompleted")} value=${automation.last_run_label} /> + <${MetaItem} + label=${t("automations.detail.currentRun")} + value=${activeRun?.run_id || activeRun?.thread_id || t("automations.detail.noCurrentRun")} + tone=${automation.has_running_run ? "info" : null} + /> +
+ +
+
+

+ ${t("automations.detail.recentRuns")} +

+ <${RunDots} runs=${automation.recent_runs} /> +
+ + ${automation.recent_runs.length + ? html` +
+ ${automation.recent_runs.map((run) => html` + <${RecentRunRow} + key=${run.run_id || run.thread_id || run.timestamp_source} + run=${run} + onOpenRun=${navigate} + /> + `)} +
+ ` + : html` +
+ ${t("automations.detail.noRuns")} +
+ `} +
+
+ + `; +} + export function AutomationsList({ automations, filter, onFilterChange, onRefresh, isRefreshing, + selectedAutomationId, + onSelectAutomation, }) { const t = useT(); const filtered = filterAutomations(automations, filter); const hasAutomations = automations.length > 0; + const selectedAutomation = + filtered.find((automation) => automation.automation_id === selectedAutomationId) || + filtered[0] || + null; return html`
@@ -87,71 +264,84 @@ export function AutomationsList({ /> ` : html` - <${Panel} className="overflow-hidden"> -
- - - - - - - - - - - - ${filtered.map((automation) => html` - - - - - - +
+ <${Panel} className="overflow-hidden"> +
+
- ${t("automations.table.name")} - - ${t("automations.table.schedule")} - - ${t("automations.table.nextRun")} - - ${t("automations.table.lastRun")} - - ${t("automations.table.status")} -
-
- ${automation.display_name} -
-
- ${automation.automation_id} -
-
- ${automation.schedule_label} - - ${automation.next_run_label} - -
- ${automation.last_run_label} -
-
- <${StatusPill} - tone=${automation.last_status_tone} - label=${automation.last_status_label} - /> -
-
- <${StatusPill} - tone=${automation.state_tone} - label=${automation.state_label} - /> -
+ + + + + + + - `)} - -
+ ${t("automations.table.name")} + + ${t("automations.table.schedule")} + + ${t("automations.table.nextRun")} + + ${t("automations.table.recentRuns")} + + ${t("automations.table.status")} +
-
- + + + ${filtered.map((automation) => { + const selected = + automation.automation_id === selectedAutomation?.automation_id; + return html` + onSelectAutomation(automation.automation_id)} + className=${cn( + "cursor-pointer border-b border-[var(--v2-panel-border)] last:border-0 hover:bg-white/[0.03]", + selected && "bg-[var(--v2-accent-soft)]/30" + )} + > + +
+ ${automation.display_name} +
+
+ ${automation.automation_id} +
+ + + ${automation.schedule_label} + + + ${automation.next_run_label} + + + <${RunDots} runs=${automation.recent_runs} /> + + + <${StatusPill} + tone=${automation.has_running_run + ? "info" + : automation.has_failed_runs + ? "danger" + : automation.state_tone} + label=${automation.has_running_run + ? t("automations.status.running") + : automation.has_failed_runs + ? t("automations.status.needsReview") + : automation.state_label} + /> + + + `; + })} + + +
+ + + <${AutomationDetailPanel} automation=${selectedAutomation} /> +
`}
`; diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js index ed48f99e14d..75d83159a0d 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js @@ -20,11 +20,18 @@ export function AutomationsSummaryStrip({ summary }) { detail: t("automations.summary.activeDetail"), }, { - key: "paused", - label: t("automations.summary.paused"), - value: summary?.paused ?? 0, - tone: "warning", - detail: t("automations.summary.pausedDetail"), + key: "running", + label: t("automations.summary.running"), + value: summary?.running ?? 0, + tone: "info", + detail: t("automations.summary.runningDetail"), + }, + { + key: "failures", + label: t("automations.summary.failures"), + value: summary?.failures ?? 0, + tone: (summary?.failures ?? 0) > 0 ? "danger" : "success", + detail: t("automations.summary.failuresDetail"), }, { key: "nextRun", @@ -37,7 +44,7 @@ export function AutomationsSummaryStrip({ summary }) { return html` <${Panel} className="p-4 sm:p-5"> -
+
${cards.map((card) => html`
listAutomations({ limit: AUTOMATIONS_PAGE_LIMIT }), + queryFn: () => + listAutomations({ + limit: AUTOMATIONS_PAGE_LIMIT, + runLimit: AUTOMATION_RUNS_LIMIT, + }), refetchInterval: 30000, refetchIntervalInBackground: false, }); diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js index 24584f99941..c238e53ea1c 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js @@ -35,6 +35,21 @@ const STATE_PRESENTATION = { const LAST_STATUS_PRESENTATION = { ok: { label: "Done", tone: "success" }, error: { label: "Error", tone: "danger" }, + running: { label: "Running", tone: "info" }, +}; + +const RUN_STATUS_PRESENTATION = { + ok: { label: "OK", tone: "success" }, + error: { label: "Error", tone: "danger" }, + running: { label: "Running", tone: "info" }, + unknown: { label: "Unknown", tone: "muted" }, +}; + +const AUTOMATION_FILTER_STRATEGIES = { + active: isBrowserActive, + paused: isBrowserPaused, + running: (automation) => automation.has_running_run, + failures: (automation) => automation.has_failed_runs, }; export function normalizeAutomations(response) { @@ -43,35 +58,27 @@ export function normalizeAutomations(response) { : []; return automations .filter((automation) => automation?.source?.type === "schedule") - .map((automation) => ({ - ...automation, - display_name: automation.name || "Untitled automation", - schedule_label: scheduleLabel(automation.source?.cron), - state_label: stateLabel(automation.state), - state_tone: stateTone(automation.state), - next_run_timestamp: parseTimestamp(automation.next_run_at), - next_run_label: formatAutomationDate(automation.next_run_at, "Not scheduled"), - last_run_label: formatAutomationDate(automation.last_run_at, "No runs yet"), - last_status_label: lastStatusLabel(automation.last_status), - last_status_tone: lastStatusTone(automation.last_status), - created_label: formatAutomationDate(automation.created_at, "Unknown"), - })) + .map((automation) => normalizeAutomation(automation)) .sort(compareAutomations); } export function filterAutomations(automations, filter) { - if (filter === "active") { - return automations.filter((automation) => isBrowserActive(automation)); - } - if (filter === "paused") { - return automations.filter((automation) => isBrowserPaused(automation)); - } - return automations; + const strategy = AUTOMATION_FILTER_STRATEGIES[filter]; + return strategy ? automations.filter(strategy) : automations; } export function automationSummary(automations) { const active = automations.filter((automation) => isBrowserActive(automation)).length; - const paused = automations.filter((automation) => isBrowserPaused(automation)).length; + const running = automations.reduce( + (count, automation) => + count + automation.recent_runs.filter((run) => run.status === "running").length, + 0, + ); + const failures = automations.reduce( + (count, automation) => + count + automation.recent_runs.filter((run) => run.status === "error").length, + 0, + ); const next = automations .filter((automation) => nextRunTimestamp(automation) !== null) .sort( @@ -82,7 +89,8 @@ export function automationSummary(automations) { return { scheduled: automations.length, active, - paused, + running, + failures, nextRun: next?.next_run_label || null, }; } @@ -161,6 +169,81 @@ export function lastStatusTone(status) { return LAST_STATUS_PRESENTATION[status]?.tone || "muted"; } +export function runStatusLabel(status) { + return RUN_STATUS_PRESENTATION[normalizeRunStatus(status)]?.label || "Unknown"; +} + +export function runStatusTone(status) { + return RUN_STATUS_PRESENTATION[normalizeRunStatus(status)]?.tone || "muted"; +} + +function normalizeAutomation(automation) { + const recentRuns = normalizeRuns(automation.recent_runs); + const latestRun = recentRuns[0] || null; + const currentRun = recentRuns.find((run) => run.status === "running") || null; + const lastCompletedRun = + recentRuns.find((run) => run.status === "ok" || run.status === "error") || + null; + const lastStatus = lastCompletedRun?.status || automation.last_status; + const lastRunAt = lastCompletedRun?.completed_at || null; + + return { + ...automation, + display_name: automation.name || "Untitled automation", + schedule_label: scheduleLabel(automation.source?.cron), + state_label: stateLabel(automation.state), + state_tone: stateTone(automation.state), + next_run_timestamp: parseTimestamp(automation.next_run_at), + next_run_label: formatAutomationDate(automation.next_run_at, "Not scheduled"), + last_run_label: formatAutomationDate(lastRunAt, "No runs yet"), + last_status_label: lastStatusLabel(lastStatus), + last_status_tone: lastStatusTone(lastStatus), + created_label: formatAutomationDate(automation.created_at, "Unknown"), + recent_runs: recentRuns, + latest_run: latestRun, + current_run: currentRun, + has_running_run: recentRuns.some((run) => run.status === "running"), + has_failed_runs: recentRuns.some((run) => run.status === "error"), + success_rate_label: successRateLabel(recentRuns), + }; +} + +function normalizeRuns(runs) { + if (!Array.isArray(runs)) return []; + return runs + .map((run) => { + const status = normalizeRunStatus(run?.status); + const timestampSource = + run?.fired_at || run?.fire_slot || run?.submitted_at || run?.completed_at || null; + const timestamp = parseTimestamp(timestampSource); + return { + ...run, + status, + status_label: runStatusLabel(status), + status_tone: runStatusTone(status), + timestamp, + timestamp_source: timestampSource, + fired_label: formatAutomationDate(timestampSource, "Unscheduled"), + submitted_label: formatAutomationDate(run?.submitted_at, "Not submitted"), + completed_label: formatAutomationDate(run?.completed_at, "Not completed"), + chat_path: run?.thread_id ? `/chat/${encodeURIComponent(run.thread_id)}` : null, + }; + }) + .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); +} + +function normalizeRunStatus(status) { + if (status === "ok" || status === "error" || status === "running") return status; + return "unknown"; +} + +function successRateLabel(runs) { + const terminalRuns = runs.filter((run) => run.status === "ok" || run.status === "error"); + if (!terminalRuns.length) return "No completed runs"; + const ok = terminalRuns.filter((run) => run.status === "ok").length; + return `${Math.round((ok / terminalRuns.length) * 100)}% visible runs`; +} + function compareAutomations(a, b) { const aActive = isBrowserActive(a); const bActive = isBrowserActive(b); diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs index b7d33fb2005..b5912a08b87 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs @@ -133,7 +133,8 @@ test("filterAutomations, sorting, and summary use browser-visible active state", assert.deepEqual(automationSummary(automations), { scheduled: 3, active: 2, - paused: 1, + running: 0, + failures: 0, nextRun: automations[0].next_run_label, }); }); @@ -156,7 +157,8 @@ test("automationSummary ignores unparseable next_run_at values", () => { assert.deepEqual(automationSummary(automations), { scheduled: 1, active: 1, - paused: 0, + running: 0, + failures: 0, nextRun: null, }); }); @@ -177,3 +179,72 @@ test("normalizeAutomations preserves explicit unknown state even when is_active assert.equal(automations[0].state_label, "Unknown"); assert.equal(automations[0].state_tone, "muted"); }); + +test("normalizeAutomations presents bounded recent run history", () => { + const automations = normalizeAutomations({ + automations: [ + { + automation_id: "daily", + name: "Daily summary", + source: { type: "schedule", cron: "0 9 * * *" }, + state: "active", + next_run_at: "2026-06-06T16:00:00Z", + recent_runs: [ + { + status: "error", + fire_slot: "2026-06-04T16:00:00Z", + submitted_at: "2026-06-04T16:00:01Z", + completed_at: "2026-06-04T16:03:00Z", + thread_id: "thread-error", + run_id: "run-error", + }, + { + status: "running", + fired_at: "2026-06-05T16:00:00Z", + submitted_at: "2026-06-05T16:00:01Z", + thread_id: "thread-running", + run_id: "run-running", + }, + { + status: "ok", + fire_slot: "2026-06-03T16:00:00Z", + submitted_at: "2026-06-03T16:00:01Z", + completed_at: "2026-06-03T16:02:00Z", + thread_id: "thread-ok", + run_id: "run-ok", + }, + ], + }, + ], + }); + + assert.equal(automations[0].recent_runs.length, 3); + assert.deepEqual( + automations[0].recent_runs.map((run) => run.run_id), + ["run-running", "run-error", "run-ok"], + ); + assert.equal(automations[0].has_running_run, true); + assert.equal(automations[0].has_failed_runs, true); + assert.equal(automations[0].latest_run.run_id, "run-running"); + assert.equal(automations[0].current_run.run_id, "run-running"); + assert.match(automations[0].last_run_label, /Jun 4/); + assert.equal(automations[0].last_status_label, "Error"); + assert.equal(automations[0].last_status_tone, "danger"); + assert.equal(automations[0].recent_runs[0].chat_path, "/chat/thread-running"); + assert.equal(automations[0].success_rate_label, "50% visible runs"); + assert.deepEqual(automationSummary(automations), { + scheduled: 1, + active: 1, + running: 1, + failures: 1, + nextRun: automations[0].next_run_label, + }); + assert.deepEqual( + filterAutomations(automations, "running").map((automation) => automation.automation_id), + ["daily"], + ); + assert.deepEqual( + filterAutomations(automations, "failures").map((automation) => automation.automation_id), + ["daily"], + ); +}); diff --git a/docs/reborn/contracts/triggers.md b/docs/reborn/contracts/triggers.md index 4258110d48a..f8202488496 100644 --- a/docs/reborn/contracts/triggers.md +++ b/docs/reborn/contracts/triggers.md @@ -287,6 +287,27 @@ The turn pipeline remains the source of truth for admission, active-lock handlin In V1, `last_status` reflects submit outcome only. It is separate from the active-fire claim and does not become an in-flight sentinel. +V1 also persists bounded per-trigger run-history rows for browser inspection: + +- each row is scoped by `(tenant_id, trigger_id, fire_slot)` and records the + deterministic trigger route thread id, optional submitted `TurnRunId`, + status, `submitted_at`, and optional `completed_at`; +- `Running` means the fire was claimed or submitted and no terminal cleanup has + completed for that `fire_slot`; +- `Ok` means active-run cleanup observed a completed terminal turn and cleared + the exact active fire; +- `Error` means poller-owned claim or submit processing failed before an active + run could complete, or observed a failed, cancelled, or recovery-required + terminal turn; +- list APIs return newest rows first and clamp caller limits to the repository + maximum. A zero limit returns no rows. User-facing list paths must use the + batched repository query when loading histories for multiple triggers; +- durable repositories retain only the newest 500 run-history rows per trigger. + +Run-history rows are observational. They must not be used as the idempotency +ledger for fire replay; deterministic fire identity and the trusted conversation +binding remain the replay source of truth. + Replay of an already accepted/submitted slot returns the original accepted message and turn submission. If that submitted turn later reaches a terminal failure, V1 does not mint a second turn for the same `fire_slot`; retry-on-run- @@ -355,7 +376,8 @@ The trigger system must expose `trigger_create`, `trigger_list`, and `trigger_re services between `trigger_create` pairing and trigger-poller fire submission. The shared service preserves the conversation store's mutation lock across both paths and avoids racing optimistic durable-state writes. -- `trigger_list` is caller-scoped and surfaces the current schedule state plus `last_status`. +- `trigger_list` is caller-scoped and surfaces the current schedule state plus + `last_status` and a bounded `recent_runs` projection when requested. - `trigger_remove` is caller-scoped delete. - Local-dev builds compiled with `libsql` store trigger records in the local-dev libSQL database (`reborn-local-dev.db`) through the same From c60e80a337e16f354fcf72d9f742fd761c6a413e Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 15:04:03 -0700 Subject: [PATCH 02/13] fix: hide automation run threads from chat list --- .../src/reborn_services.rs | 20 ++++++- .../tests/reborn_services_contract.rs | 55 +++++++++++++++++++ .../src/trigger_poller_trusted_submit.rs | 23 +++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index cc70055f026..d041e23e6c7 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -22,8 +22,8 @@ use ironclaw_product_adapters::{ }; use ironclaw_threads::{ AcceptInboundMessageRequest, AcceptedInboundMessageReplay, EnsureThreadRequest, MessageContent, - MessageStatus, ReplayAcceptedInboundMessageRequest, SessionThreadError, SessionThreadService, - ThreadHistoryRequest, ThreadMessageId, ThreadScope, + MessageStatus, ReplayAcceptedInboundMessageRequest, SessionThreadError, SessionThreadRecord, + SessionThreadService, ThreadHistoryRequest, ThreadMessageId, ThreadScope, }; use ironclaw_turns::{ AcceptedMessageRef, GateRef, GetRunStateRequest, IdempotencyKey, ResumeTurnPrecondition, @@ -1332,7 +1332,11 @@ impl RebornServicesApi for RebornServices { .await .map_err(map_thread_error)?; Ok(RebornListThreadsResponse { - threads: response.threads, + threads: response + .threads + .into_iter() + .filter(|thread| !is_automation_trigger_thread(thread)) + .collect(), next_cursor: response.next_cursor, }) } @@ -1702,6 +1706,16 @@ fn automation_unavailable() -> RebornServicesError { RebornServicesError::service_unavailable(true) } +fn is_automation_trigger_thread(thread: &SessionThreadRecord) -> bool { + thread + .metadata_json + .as_deref() + .and_then(|metadata| serde_json::from_str::(metadata).ok()) + .is_some_and(|metadata| { + metadata.get("source").and_then(serde_json::Value::as_str) == Some("automation_trigger") + }) +} + fn outbound_preferences_unavailable() -> RebornServicesError { RebornServicesError::service_unavailable(false) } diff --git a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs index fcfc9acfb36..c9bf6e0aaa0 100644 --- a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs @@ -5497,3 +5497,58 @@ async fn list_threads_unimplemented_backend_returns_service_unavailable() { ); assert_eq!(json["retryable"], true); } + +#[tokio::test] +async fn list_threads_hides_automation_trigger_threads() { + let thread_service = Arc::new(InMemorySessionThreadService::default()); + let services = RebornServices::new( + thread_service.clone(), + Arc::new(FakeTurnCoordinator::default()), + ); + let caller = caller(); + let visible_thread_id = ThreadId::new("thread-visible").expect("visible thread id"); + let automation_thread_id = ThreadId::new("thread-automation").expect("automation thread id"); + + thread_service + .ensure_thread(EnsureThreadRequest { + scope: thread_scope_for(&caller), + thread_id: Some(visible_thread_id.clone()), + created_by_actor_id: caller.user_id.as_str().to_string(), + title: Some("Visible chat".to_string()), + metadata_json: Some(json!({ "source": "webui" }).to_string()), + }) + .await + .expect("visible thread"); + thread_service + .ensure_thread(EnsureThreadRequest { + scope: thread_scope_for(&caller), + thread_id: Some(automation_thread_id.clone()), + created_by_actor_id: caller.user_id.as_str().to_string(), + title: Some("Automation run".to_string()), + metadata_json: Some( + json!({ + "source": "automation_trigger", + "trigger_id": "trigger-scheduled-summary", + }) + .to_string(), + ), + }) + .await + .expect("automation thread"); + + let response = services + .list_threads(caller, WebUiListThreadsRequest::default()) + .await + .expect("list threads"); + let thread_ids = response + .threads + .iter() + .map(|thread| thread.thread_id.clone()) + .collect::>(); + + assert_eq!(thread_ids, vec![visible_thread_id]); + assert!( + !thread_ids.contains(&automation_thread_id), + "automation trigger threads should be accessible by direct id but hidden from the chat list", + ); +} diff --git a/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs b/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs index 817656fee12..20fd42cc53f 100644 --- a/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs +++ b/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs @@ -193,6 +193,7 @@ where let accepted = record_trigger_prompt( Arc::clone(&self.thread_service), &resolution, + fire.identity.trigger_id(), &fire.prompt, fire.identity.external_event_id().as_str(), &self.default_agent_id, @@ -269,6 +270,7 @@ fn trigger_resolve_request( async fn record_trigger_prompt( thread_service: Arc, resolution: &ConversationBindingResolution, + trigger_id: TriggerId, prompt: &str, external_event_id: &str, default_agent_id: &AgentId, @@ -292,7 +294,7 @@ async fn record_trigger_prompt( thread_id: Some(resolution.turn_scope.thread_id.clone()), created_by_actor_id: resolution.actor.user_id.as_str().to_string(), title: None, - metadata_json: None, + metadata_json: Some(trigger_thread_metadata_json(trigger_id)), }) .await .map_err(|error| InboundTurnError::DurableState { @@ -324,6 +326,14 @@ async fn record_trigger_prompt( }) } +fn trigger_thread_metadata_json(trigger_id: TriggerId) -> String { + serde_json::json!({ + "source": "automation_trigger", + "trigger_id": trigger_id.to_string(), + }) + .to_string() +} + fn trigger_authorization_error(error: TriggerFireAuthError) -> TriggerError { match error { TriggerFireAuthError::Denied { reason } => { @@ -1505,6 +1515,7 @@ mod tests { record_trigger_prompt( thread_service.clone(), &resolution, + TriggerId::new(), "summarize unread mail", "event-trigger-hook", &agent_id, @@ -1515,6 +1526,7 @@ mod tests { record_trigger_prompt( thread_service.clone(), &resolution, + TriggerId::new(), "summarize unread mail", "event-trigger-hook", &agent_id, @@ -1653,6 +1665,15 @@ mod tests { .threads .first() .expect("worker path records trigger prompt"); + let metadata: serde_json::Value = serde_json::from_str( + thread + .metadata_json + .as_deref() + .expect("trigger thread metadata"), + ) + .expect("trigger thread metadata json"); + assert_eq!(metadata["source"], "automation_trigger"); + assert_eq!(metadata["trigger_id"], trigger_id.to_string()); let history = thread_service .list_thread_history(ThreadHistoryRequest { scope: expected_scope, From f7db96ceb59abfc8fe99f5fed76799421b20d6bc Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 15:20:18 -0700 Subject: [PATCH 03/13] fix: address automation run review feedback --- .../first_party_tools/trigger_management.rs | 18 +- .../tests/first_party_builtin_tools.rs | 175 +++++++++++++++++- .../src/reborn_services.rs | 2 +- .../src/reborn_services/types.rs | 28 ++- .../tests/reborn_services_contract.rs | 69 ++++++- .../src/automation.rs | 78 ++------ crates/ironclaw_triggers/src/lib.rs | 40 ++-- crates/ironclaw_triggers/src/libsql.rs | 37 ++-- crates/ironclaw_triggers/src/postgres.rs | 2 +- .../tests/repository_contract.rs | 41 ++++ .../components/automations-list.js | 24 ++- .../automations/lib/automations-presenters.js | 2 +- .../lib/automations-presenters.test.mjs | 20 ++ 13 files changed, 412 insertions(+), 124 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs b/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs index df5ba105abf..1691a3a0a01 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs @@ -9,7 +9,7 @@ use ironclaw_host_api::{ }; use ironclaw_triggers::{ TriggerCompletionPolicy, TriggerError, TriggerId, TriggerRecord, TriggerRepository, - TriggerRunHistoryStatus, TriggerRunRecord, TriggerSchedule, TriggerSourceKind, TriggerState, + TriggerRunRecord, TriggerSchedule, TriggerSourceKind, TriggerState, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -24,7 +24,7 @@ use super::{ first_party_capability_manifest, input_error, resource_profile, }; -const TRIGGER_LIST_LIMIT: usize = 100; +const TRIGGER_LIST_MAX_LIMIT: usize = 100; const TRIGGER_RUN_HISTORY_DEFAULT_LIMIT: usize = 25; const TRIGGER_RUN_HISTORY_MAX_LIMIT: usize = 100; @@ -271,8 +271,8 @@ async fn list_triggers( let input: TriggerListInput = serde_json::from_value(input).map_err(|_| input_error())?; let limit = input .limit - .unwrap_or(TRIGGER_LIST_LIMIT) - .min(TRIGGER_LIST_LIMIT); + .unwrap_or(TRIGGER_LIST_MAX_LIMIT) + .min(TRIGGER_LIST_MAX_LIMIT); let run_limit = input .run_limit .unwrap_or(TRIGGER_RUN_HISTORY_DEFAULT_LIMIT) @@ -354,20 +354,12 @@ fn trigger_run_output(run: &TriggerRunRecord) -> Value { "fire_slot": run.fire_slot, "run_id": run.run_id.as_ref().map(ToString::to_string), "thread_id": run.thread_id.as_str(), - "status": trigger_run_history_status_text(run.status), + "status": run.status, "submitted_at": run.submitted_at, "completed_at": run.completed_at, }) } -fn trigger_run_history_status_text(status: TriggerRunHistoryStatus) -> &'static str { - match status { - TriggerRunHistoryStatus::Running => "running", - TriggerRunHistoryStatus::Ok => "ok", - TriggerRunHistoryStatus::Error => "error", - } -} - fn trigger_remove_output(record: &TriggerRecord) -> Value { json!({ "trigger_id": record.trigger_id.to_string(), diff --git a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs index 0b5694f2df4..552f3fc6aaf 100644 --- a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs +++ b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, HashMap}, io::Write, net::{IpAddr, Ipv4Addr}, path::Path, @@ -52,7 +52,7 @@ use ironclaw_secrets::InMemorySecretStore; use ironclaw_triggers::{ ClaimDueFireRequest, ClearActiveFireRequest, FireAcceptedRequest, InMemoryTriggerRepository, MAX_TRIGGER_NAME_BYTES, MAX_TRIGGER_PROMPT_BYTES, TriggerError, TriggerRecord, - TriggerRepository, TriggerRunHistoryStatus, + TriggerRepository, TriggerRunHistoryStatus, TriggerRunRecord, }; use ironclaw_trust::{ AdminConfig, AdminEntry, AuthorityCeiling, EffectiveTrustClass, HostTrustAssignment, @@ -1225,6 +1225,32 @@ async fn builtin_trigger_management_maps_repository_errors_to_backend() { assert_eq!(remove_error, RuntimeFailureKind::Backend); } +#[tokio::test] +async fn builtin_trigger_list_maps_batch_run_history_repository_error_to_backend() { + let repository = Arc::new(BatchRunHistoryFailingTriggerRepository::default()); + let runtime = runtime_with_trigger_repository(repository); + let context = execution_context([TRIGGER_CREATE_CAPABILITY_ID, TRIGGER_LIST_CAPABILITY_ID]); + + invoke_with_context( + &runtime, + TRIGGER_CREATE_CAPABILITY_ID, + json!({ + "name": "Batch history failure", + "prompt": "Create trigger before listing history", + "cron": "0 8 * * *" + }), + context.clone(), + ) + .await + .unwrap(); + + let error = invoke_with_context(&runtime, TRIGGER_LIST_CAPABILITY_ID, json!({}), context) + .await + .unwrap_err(); + + assert_eq!(error, RuntimeFailureKind::Backend); +} + #[tokio::test] async fn builtin_rejects_oversized_inputs_before_dispatch() { let outcome = runtime() @@ -6316,6 +6342,11 @@ struct RemoveFailingTriggerRepository { remove_attempts: std::sync::Mutex, } +#[derive(Default)] +struct BatchRunHistoryFailingTriggerRepository { + inner: InMemoryTriggerRepository, +} + impl RemoveFailingTriggerRepository { fn remove_attempts(&self) -> usize { *self.remove_attempts.lock().unwrap() @@ -6460,6 +6491,146 @@ impl TriggerRepository for RemoveFailingTriggerRepository { } } +#[async_trait] +impl TriggerRepository for BatchRunHistoryFailingTriggerRepository { + async fn upsert_trigger( + &self, + record: ironclaw_triggers::TriggerRecord, + ) -> Result<(), ironclaw_triggers::TriggerError> { + self.inner.upsert_trigger(record).await + } + + async fn get_trigger( + &self, + tenant_id: TenantId, + trigger_id: ironclaw_triggers::TriggerId, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.get_trigger(tenant_id, trigger_id).await + } + + async fn list_triggers( + &self, + tenant_id: TenantId, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.list_triggers(tenant_id).await + } + + async fn list_scoped_triggers( + &self, + tenant_id: TenantId, + creator_user_id: UserId, + agent_id: Option, + project_id: Option, + limit: usize, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner + .list_scoped_triggers(tenant_id, creator_user_id, agent_id, project_id, limit) + .await + } + + async fn remove_trigger( + &self, + tenant_id: TenantId, + trigger_id: ironclaw_triggers::TriggerId, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.remove_trigger(tenant_id, trigger_id).await + } + + async fn remove_scoped_trigger( + &self, + tenant_id: TenantId, + creator_user_id: UserId, + agent_id: Option, + project_id: Option, + trigger_id: ironclaw_triggers::TriggerId, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner + .remove_scoped_trigger(tenant_id, creator_user_id, agent_id, project_id, trigger_id) + .await + } + + async fn list_due_triggers( + &self, + now: Timestamp, + limit: usize, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.list_due_triggers(now, limit).await + } + + async fn list_active_triggers( + &self, + limit: usize, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.list_active_triggers(limit).await + } + + async fn list_active_triggers_after( + &self, + after: Option, + limit: usize, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.list_active_triggers_after(after, limit).await + } + + async fn claim_due_fire( + &self, + request: ironclaw_triggers::ClaimDueFireRequest, + ) -> Result { + self.inner.claim_due_fire(request).await + } + + async fn mark_fire_accepted( + &self, + request: ironclaw_triggers::FireAcceptedRequest, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.mark_fire_accepted(request).await + } + + async fn mark_fire_replayed( + &self, + request: ironclaw_triggers::FireReplayedRequest, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.mark_fire_replayed(request).await + } + + async fn mark_fire_retryable_failed( + &self, + request: ironclaw_triggers::FireRetryableFailedRequest, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.mark_fire_retryable_failed(request).await + } + + async fn mark_fire_permanently_failed( + &self, + request: ironclaw_triggers::FirePermanentFailedRequest, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.mark_fire_permanently_failed(request).await + } + + async fn mark_fire_terminally_failed( + &self, + request: ironclaw_triggers::FireTerminalFailedRequest, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.mark_fire_terminally_failed(request).await + } + + async fn clear_active_fire( + &self, + request: ironclaw_triggers::ClearActiveFireRequest, + ) -> Result, ironclaw_triggers::TriggerError> { + self.inner.clear_active_fire(request).await + } + + async fn list_trigger_run_history_batch( + &self, + _tenant_id: TenantId, + _trigger_ids: &[ironclaw_triggers::TriggerId], + _limit: usize, + ) -> Result>, TriggerError> { + Err(trigger_backend_error()) + } +} + #[async_trait] impl TriggerRepository for FailingTriggerRepository { async fn upsert_trigger( diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index d041e23e6c7..a2911dcee22 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -2418,7 +2418,7 @@ fn clamp_automation_list_limit(requested: Option) -> usize { fn clamp_automation_run_limit(requested: Option) -> usize { let raw = requested.unwrap_or(AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE); - let clamped = raw.clamp(1, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE); + let clamped = raw.min(AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE); clamped as usize } diff --git a/crates/ironclaw_product_workflow/src/reborn_services/types.rs b/crates/ironclaw_product_workflow/src/reborn_services/types.rs index 8974ec3450b..05059829898 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/types.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/types.rs @@ -641,7 +641,7 @@ pub enum RebornAutomationRunStatus { } /// Browser-visible status for an individual automation run. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RebornAutomationRecentRunStatus { Running, @@ -649,14 +649,36 @@ pub enum RebornAutomationRecentRunStatus { Error, } +impl Default for RebornAutomationRecentRunStatus { + fn default() -> Self { + Self::Error + } +} + +impl<'de> Deserialize<'de> for RebornAutomationRecentRunStatus { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + Ok(match value.as_str() { + Some("running") => Self::Running, + Some("ok") => Self::Ok, + Some("error") => Self::Error, + _ => Self::Error, + }) + } +} + /// Browser-safe automation run projection. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RebornAutomationRecentRunInfo { #[serde(default, skip_serializing_if = "Option::is_none")] - pub run_id: Option, - pub thread_id: String, + pub run_id: Option, + pub thread_id: ThreadId, #[serde(default, skip_serializing_if = "Option::is_none")] pub fire_slot: Option>, + #[serde(default)] pub status: RebornAutomationRecentRunStatus, pub submitted_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs index c9bf6e0aaa0..a39c6e9658a 100644 --- a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs @@ -111,6 +111,10 @@ fn run_id_string() -> String { "3d54a1f0-0a7f-4b9c-a350-4258f2fa3e18".to_string() } +fn automation_run_id() -> TurnRunId { + TurnRunId::parse("11111111-1111-1111-1111-111111111111").expect("valid automation run id") +} + fn fake_thread_history(owner: &WebUiAuthenticatedCaller, thread_id: &str) -> ThreadHistory { let thread_id = ThreadId::new(thread_id).expect("valid thread id"); let scope = ThreadScope { @@ -906,8 +910,8 @@ fn automation_info( last_run_at: None, last_status, recent_runs: vec![RebornAutomationRecentRunInfo { - run_id: Some("run-listed".to_string()), - thread_id: "thread-listed".to_string(), + run_id: Some(automation_run_id()), + thread_id: ThreadId::new("thread-listed").expect("valid thread id"), fire_slot: Some("2026-06-03T09:00:00Z".parse().expect("fire slot")), status: RebornAutomationRecentRunStatus::Ok, submitted_at: "2026-06-03T09:00:01Z".parse().expect("submitted at"), @@ -4003,7 +4007,7 @@ async fn list_automation_dispatches_through_product_facade() { RebornAutomationRecentRunStatus::Ok ); assert_eq!( - listed.automations[0].recent_runs[0].thread_id, + listed.automations[0].recent_runs[0].thread_id.as_str(), "thread-listed" ); @@ -4778,7 +4782,7 @@ async fn list_automations_clamps_oversize_run_limit_before_product_facade() { } #[tokio::test] -async fn list_automations_clamps_zero_run_limit_before_product_facade() { +async fn list_automations_allows_zero_run_limit_before_product_facade() { let automation_facade = Arc::new(RecordingAutomationFacade::default()); let services = RebornServices::new( Arc::new(InMemorySessionThreadService::default()), @@ -4800,8 +4804,8 @@ async fn list_automations_clamps_zero_run_limit_before_product_facade() { let list_calls = automation_facade.list_calls(); assert_eq!(list_calls.len(), 1); assert_eq!( - list_calls[0].run_limit, 1, - "automation run history limit must be clamped to at least one run" + list_calls[0].run_limit, 0, + "explicit zero automation run history limit must disable embedded run history" ); } @@ -4826,6 +4830,59 @@ fn reborn_automation_state_round_trips_serde_for_every_variant() { } } +#[test] +fn reborn_automation_recent_run_info_round_trips_typed_ids_and_sanitizes_status() { + let recent_run = RebornAutomationRecentRunInfo { + run_id: Some(automation_run_id()), + thread_id: ThreadId::new("thread-listed").expect("valid thread id"), + fire_slot: Some("2026-06-03T09:00:00Z".parse().expect("fire slot")), + status: RebornAutomationRecentRunStatus::Running, + submitted_at: "2026-06-03T09:00:01Z".parse().expect("submitted at"), + completed_at: None, + }; + + let serialized = serde_json::to_value(&recent_run).expect("serialize recent run"); + assert_eq!( + serialized, + json!({ + "run_id": "11111111-1111-1111-1111-111111111111", + "thread_id": "thread-listed", + "fire_slot": "2026-06-03T09:00:00Z", + "status": "running", + "submitted_at": "2026-06-03T09:00:01Z", + }) + ); + + let deserialized: RebornAutomationRecentRunInfo = + serde_json::from_value(serialized).expect("deserialize recent run"); + assert_eq!(deserialized, recent_run); + + let sanitized: RebornAutomationRecentRunInfo = serde_json::from_value(json!({ + "run_id": "11111111-1111-1111-1111-111111111111", + "thread_id": "thread-listed", + "status": { "backend": "future" }, + "submitted_at": "2026-06-03T09:00:01Z", + })) + .expect("deserialize sanitized recent run"); + assert_eq!(sanitized.status, RebornAutomationRecentRunStatus::Error); + + serde_json::from_value::(json!({ + "run_id": "not-a-uuid", + "thread_id": "thread-listed", + "status": "running", + "submitted_at": "2026-06-03T09:00:01Z", + })) + .expect_err("recent run rejects malformed run_id"); + + serde_json::from_value::(json!({ + "run_id": "11111111-1111-1111-1111-111111111111", + "thread_id": "thread/listed", + "status": "running", + "submitted_at": "2026-06-03T09:00:01Z", + })) + .expect_err("recent run rejects malformed thread_id"); +} + #[tokio::test] async fn list_automations_returns_empty_list() { let services = RebornServices::new( diff --git a/crates/ironclaw_reborn_composition/src/automation.rs b/crates/ironclaw_reborn_composition/src/automation.rs index c9ee9ff6107..55cad134b82 100644 --- a/crates/ironclaw_reborn_composition/src/automation.rs +++ b/crates/ironclaw_reborn_composition/src/automation.rs @@ -12,12 +12,11 @@ use ironclaw_host_runtime::{ }; use ironclaw_product_workflow::{ AutomationListRequest, AutomationProductFacade, ProductAgentBoundCaller, RebornAutomationInfo, - RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationRunStatus, - RebornAutomationSource, RebornAutomationState, RebornServicesError, RebornServicesErrorCode, - RebornServicesErrorKind, + RebornAutomationRecentRunInfo, RebornAutomationRunStatus, RebornAutomationSource, + RebornAutomationState, RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind, }; use ironclaw_trust::{AuthorityCeiling, EffectiveTrustClass, TrustDecision, TrustProvenance}; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use serde_json::{Value, json}; const AUTOMATION_BACKEND_TIMEOUT: Duration = Duration::from_secs(30); @@ -156,30 +155,13 @@ struct RawAutomationRecord { #[serde(default)] last_status: Option, #[serde(default)] - recent_runs: Vec, + recent_runs: Vec, #[serde(default)] is_active: bool, #[serde(default)] created_at: Option>, } -#[derive(Debug, Deserialize)] -struct RawAutomationRecentRunRecord { - #[serde(default)] - run_id: Option, - thread_id: String, - #[serde(default)] - fire_slot: Option>, - #[serde( - default = "default_recent_run_error_status", - deserialize_with = "deserialize_recent_run_status" - )] - status: RebornAutomationRecentRunStatus, - submitted_at: DateTime, - #[serde(default)] - completed_at: Option>, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind")] enum RawAutomationSchedule { @@ -217,29 +199,12 @@ fn automation_info(record: RawAutomationRecord) -> Option next_run_at: record.next_run_at, last_run_at: record.last_run_at, last_status: record.last_status, - recent_runs: record - .recent_runs - .into_iter() - .map(automation_recent_run_info) - .collect(), + recent_runs: record.recent_runs, is_active: record.is_active, created_at: record.created_at, }) } -fn automation_recent_run_info( - record: RawAutomationRecentRunRecord, -) -> RebornAutomationRecentRunInfo { - RebornAutomationRecentRunInfo { - run_id: record.run_id, - thread_id: record.thread_id, - fire_slot: record.fire_slot, - status: record.status, - submitted_at: record.submitted_at, - completed_at: record.completed_at, - } -} - fn automation_source(schedule: RawAutomationSchedule) -> Option { match schedule { RawAutomationSchedule::Cron { expression } => { @@ -271,25 +236,6 @@ fn sanitize_automation_list_output(output: &mut Value) { } } -fn default_recent_run_error_status() -> RebornAutomationRecentRunStatus { - RebornAutomationRecentRunStatus::Error -} - -fn deserialize_recent_run_status<'de, D>( - deserializer: D, -) -> Result -where - D: Deserializer<'de>, -{ - let value = Value::deserialize(deserializer)?; - Ok(match value.as_str() { - Some("running") => RebornAutomationRecentRunStatus::Running, - Some("ok") => RebornAutomationRecentRunStatus::Ok, - Some("error") => RebornAutomationRecentRunStatus::Error, - _ => RebornAutomationRecentRunStatus::Error, - }) -} - fn trigger_execution_context( caller: &ProductAgentBoundCaller, capability_id: &str, @@ -500,10 +446,16 @@ mod tests { ); assert_eq!(automations[0].recent_runs.len(), 1); assert_eq!( - automations[0].recent_runs[0].run_id.as_deref(), - Some("run-listed") + automations[0].recent_runs[0] + .run_id + .map(|run_id| run_id.to_string()) + .as_deref(), + Some("11111111-1111-1111-1111-111111111111") + ); + assert_eq!( + automations[0].recent_runs[0].thread_id.as_str(), + "thread-listed" ); - assert_eq!(automations[0].recent_runs[0].thread_id, "thread-listed"); assert_eq!( automations[0].recent_runs[0].status, RebornAutomationRecentRunStatus::Running @@ -986,7 +938,7 @@ mod tests { "is_active": true, "created_at": "2026-06-02T18:00:00Z", "recent_runs": [{ - "run_id": "run-listed", + "run_id": "11111111-1111-1111-1111-111111111111", "thread_id": "thread-listed", "fire_slot": "2026-06-03T09:00:00Z", "status": "running", diff --git a/crates/ironclaw_triggers/src/lib.rs b/crates/ironclaw_triggers/src/lib.rs index b8c28379365..beaab35d5ed 100644 --- a/crates/ironclaw_triggers/src/lib.rs +++ b/crates/ironclaw_triggers/src/lib.rs @@ -6,7 +6,7 @@ //! by later slices. use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, str::FromStr, sync::{Arc, Mutex}, time::Duration, @@ -795,8 +795,12 @@ pub trait TriggerRepository: Send + Sync { /// Returns recent run-history rows for several tenant-scoped triggers. /// /// Each entry is ordered newest first by fire slot and truncated to `limit`. - /// Implementations should override this when they can fetch all trigger - /// histories in one query. + /// + /// The default implementation issues one serial + /// [`list_trigger_run_history`] call per trigger id. Storage-backed + /// repositories used by list-page or UI paths should override this with a + /// true batch query so callers do not accidentally introduce N sequential + /// round-trips. async fn list_trigger_run_history_batch( &self, tenant_id: TenantId, @@ -1292,6 +1296,7 @@ impl TriggerRepository for InMemoryTriggerRepository { record.active_fire_slot = None; record.active_run_ref = None; let record = record.clone(); + let completed_at = Utc::now(); state .runs .entry(TriggerRunRepositoryKey::new( @@ -1302,7 +1307,7 @@ impl TriggerRepository for InMemoryTriggerRepository { .and_modify(|run| { run.run_id = Some(request.run_id); run.status = request.status; - run.completed_at = Some(Utc::now()); + run.completed_at = Some(completed_at); }) .or_insert_with(|| { let mut run = TriggerRunRecord::running( @@ -1310,10 +1315,10 @@ impl TriggerRepository for InMemoryTriggerRepository { request.trigger_id, request.fire_slot, Some(request.run_id), - request.fire_slot, + completed_at, ); run.status = request.status; - run.completed_at = Some(Utc::now()); + run.completed_at = Some(completed_at); run }); prune_run_history_locked(&mut state, &request.tenant_id, request.trigger_id); @@ -1354,16 +1359,23 @@ impl TriggerRepository for InMemoryTriggerRepository { } let limit = limit.min(MAX_TRIGGER_RUN_HISTORY_LIMIT); let state = self.lock_state()?; + let trigger_id_set = trigger_ids.iter().copied().collect::>(); for trigger_id in trigger_ids { - let mut runs = state - .runs - .values() - .filter(|run| run.tenant_id == tenant_id && run.trigger_id == *trigger_id) - .cloned() - .collect::>(); + runs_by_trigger.insert(*trigger_id, Vec::new()); + } + for run in state + .runs + .values() + .filter(|run| run.tenant_id == tenant_id && trigger_id_set.contains(&run.trigger_id)) + { + runs_by_trigger + .entry(run.trigger_id) + .or_default() + .push(run.clone()); + } + for runs in runs_by_trigger.values_mut() { runs.sort_by_key(|run| std::cmp::Reverse(run.fire_slot)); runs.truncate(limit); - runs_by_trigger.insert(*trigger_id, runs); } Ok(runs_by_trigger) } @@ -1448,7 +1460,7 @@ impl InMemoryTriggerRepository { trigger_id, fire_slot, run_id, - fire_slot, + completed_at, ); run.status = status; run.completed_at = Some(completed_at); diff --git a/crates/ironclaw_triggers/src/libsql.rs b/crates/ironclaw_triggers/src/libsql.rs index fd8a0917d97..ac469e3a5eb 100644 --- a/crates/ironclaw_triggers/src/libsql.rs +++ b/crates/ironclaw_triggers/src/libsql.rs @@ -995,33 +995,21 @@ impl TriggerRepository for LibSqlTriggerRepository { return Ok(runs_by_trigger); } let limit = limit.min(crate::MAX_TRIGGER_RUN_HISTORY_LIMIT) as i64; - let placeholders = (0..trigger_ids.len()) - .map(|index| format!("?{}", index + 2)) - .collect::>() - .join(", "); - let limit_parameter = trigger_ids.len() + 2; + let trigger_ids_json = trigger_ids_json_array(trigger_ids); let sql = format!( "SELECT {TRIGGER_RUN_COLUMNS} FROM ( SELECT {TRIGGER_RUN_COLUMNS}, ROW_NUMBER() OVER (PARTITION BY trigger_id ORDER BY fire_slot DESC) AS row_rank FROM {TRIGGER_RUN_TABLE} - WHERE tenant_id = ?1 AND trigger_id IN ({placeholders}) + WHERE tenant_id = ?1 AND trigger_id IN (SELECT value FROM json_each(?2)) ) - WHERE row_rank <= ?{limit_parameter} + WHERE row_rank <= ?3 ORDER BY trigger_id, fire_slot DESC" ); - let mut params = Vec::with_capacity(trigger_ids.len() + 2); - params.push(libsql::Value::Text(tenant_id.as_str().to_string())); - params.extend( - trigger_ids - .iter() - .map(|trigger_id| libsql::Value::Text(trigger_id.to_string())), - ); - params.push(libsql::Value::Integer(limit)); let conn = self.connect().await?; let mut rows = conn - .query(&sql, params) + .query(&sql, params![tenant_id.as_str(), trigger_ids_json, limit]) .await .map_err(|error| backend_error("query trigger run history batch", error))?; loop { @@ -1419,6 +1407,21 @@ async fn upsert_run_history( Ok(()) } +#[cfg(feature = "libsql")] +fn trigger_ids_json_array(trigger_ids: &[TriggerId]) -> String { + let mut value = String::from("["); + for (index, trigger_id) in trigger_ids.iter().enumerate() { + if index > 0 { + value.push(','); + } + value.push('"'); + value.push_str(&trigger_id.to_string()); + value.push('"'); + } + value.push(']'); + value +} + #[cfg(feature = "libsql")] async fn complete_run_history( conn: &libsql::Connection, @@ -1455,7 +1458,7 @@ async fn complete_run_history( .thread_id .as_str(), run_history_status_text(status), - fmt_ts(&fire_slot), + fmt_ts(&completed_at), fmt_ts(&completed_at), ], ) diff --git a/crates/ironclaw_triggers/src/postgres.rs b/crates/ironclaw_triggers/src/postgres.rs index 9702d3bba84..9c5ac6ba391 100644 --- a/crates/ironclaw_triggers/src/postgres.rs +++ b/crates/ironclaw_triggers/src/postgres.rs @@ -1013,7 +1013,7 @@ async fn complete_run_history( &format!( "INSERT INTO {TRIGGER_RUN_TABLE} ( tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at - ) VALUES ($1, $2, $3, $4, $5, $6, $3, $7) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) ON CONFLICT (tenant_id, trigger_id, fire_slot) DO UPDATE SET run_id = COALESCE(trigger_run_history.run_id, EXCLUDED.run_id), status = EXCLUDED.status, diff --git a/crates/ironclaw_triggers/tests/repository_contract.rs b/crates/ironclaw_triggers/tests/repository_contract.rs index 34147864ee3..bbf220c9aff 100644 --- a/crates/ironclaw_triggers/tests/repository_contract.rs +++ b/crates/ironclaw_triggers/tests/repository_contract.rs @@ -2699,6 +2699,47 @@ mod fire_claim_contract { assert_eq!(runs[0].run_id, None); assert_eq!(runs[0].status, TriggerRunHistoryStatus::Error); assert!(runs[0].completed_at.is_some()); + + let missing_history_trigger_id = + TriggerId::parse("01J00000000000000000000034").expect("ulid"); + let missing_history_tenant_id = tenant("tenant-run-history-missing-running-row"); + let missing_history_fire_slot = ts(1_704_067_300); + let missing_history_run_id = + TurnRunId::parse("01890f0f-9b6f-7a85-9e5b-9f21a93c4f82").expect("valid run"); + let mut missing_history_record = sample_record( + missing_history_trigger_id, + missing_history_tenant_id.clone(), + missing_history_fire_slot, + ); + missing_history_record.active_fire_slot = Some(missing_history_fire_slot); + missing_history_record.active_run_ref = Some(missing_history_run_id); + repo.upsert_trigger(missing_history_record) + .await + .expect("insert active record without run history row"); + + repo.clear_active_fire(ClearActiveFireRequest { + tenant_id: missing_history_tenant_id.clone(), + trigger_id: missing_history_trigger_id, + fire_slot: missing_history_fire_slot, + run_id: missing_history_run_id, + status: TriggerRunHistoryStatus::Ok, + }) + .await + .expect("clear active fire without running history row") + .expect("active fire should clear"); + + let runs = repo + .list_trigger_run_history(missing_history_tenant_id, missing_history_trigger_id, 10) + .await + .expect("list inserted completion run history"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].run_id, Some(missing_history_run_id)); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Ok); + assert_eq!( + runs[0].submitted_at, + runs[0].completed_at.expect("completion timestamp"), + "completion-only run-history rows must use completed_at as fallback submitted_at" + ); } async fn assert_run_history_retention_contract(repo: &impl TriggerRepository) { diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js index 880825c97fa..6074cfda0fc 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js @@ -46,7 +46,7 @@ function RunDots({ runs }) {
${visibleRuns.map((run) => html` ${automation.recent_runs.map((run) => html` <${RecentRunRow} - key=${run.run_id || run.thread_id || run.timestamp_source} + key=${recentRunKey(run)} run=${run} onOpenRun=${navigate} /> @@ -294,10 +304,18 @@ export function AutomationsList({ return html` onSelectAutomation(automation.automation_id)} + onKeyDown=${(event) => + automationRowKeyDown( + event, + automation.automation_id, + onSelectAutomation + )} className=${cn( - "cursor-pointer border-b border-[var(--v2-panel-border)] last:border-0 hover:bg-white/[0.03]", + "cursor-pointer border-b border-[var(--v2-panel-border)] last:border-0 hover:bg-white/[0.03] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--v2-accent)]", selected && "bg-[var(--v2-accent-soft)]/30" )} > diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js index c238e53ea1c..1e8c0ab2494 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js @@ -185,7 +185,7 @@ function normalizeAutomation(automation) { recentRuns.find((run) => run.status === "ok" || run.status === "error") || null; const lastStatus = lastCompletedRun?.status || automation.last_status; - const lastRunAt = lastCompletedRun?.completed_at || null; + const lastRunAt = lastCompletedRun?.completed_at || automation.last_run_at || null; return { ...automation, diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs index b5912a08b87..1ea0ef88807 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.test.mjs @@ -68,6 +68,26 @@ test("normalizeAutomations handles empty and malformed schedule payloads", () => assert.equal(automations[0].last_status_tone, "muted"); }); +test("normalizeAutomations preserves legacy last_run_at when recent history is empty", () => { + const automations = normalizeAutomations({ + automations: [ + { + automation_id: "legacy-run", + name: "Legacy run", + source: { type: "schedule", cron: "0 9 * * *" }, + state: "active", + last_run_at: "2026-06-04T16:01:00Z", + last_status: "ok", + recent_runs: [], + }, + ], + }); + + assert.equal(automations.length, 1); + assert.match(automations[0].last_run_label, /Jun 4/); + assert.equal(automations[0].last_status_label, "Done"); +}); + test("scheduleLabel presents common recurring schedules in friendly language", () => { assert.equal(scheduleLabel("30 14 * * *"), "Every day at 2:30 PM"); assert.equal(scheduleLabel("0 30 14 * * *"), "Every day at 2:30 PM"); From 125cf1ff0ed8b98b4616adbb4edbc811841aab53 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 15:25:38 -0700 Subject: [PATCH 04/13] fix(webui): use valid automation row aria state --- .../static/js/pages/automations/components/automations-list.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js index 6074cfda0fc..2e6d1d8d198 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js @@ -306,7 +306,7 @@ export function AutomationsList({ key=${automation.automation_id} tabIndex=${0} role="button" - aria-selected=${selected} + aria-pressed=${selected} onClick=${() => onSelectAutomation(automation.automation_id)} onKeyDown=${(event) => automationRowKeyDown( From 45a61de76dfc7db9ea3a4e6ecb629e0fd48d0cc2 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 15:38:30 -0700 Subject: [PATCH 05/13] fix: resolve automation CI failures --- .../src/reborn_services/types.rs | 9 ++------- crates/ironclaw_triggers/src/postgres.rs | 6 +++--- .../tests/webui_v2_handlers_contract.rs | 6 ++++-- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/reborn_services/types.rs b/crates/ironclaw_product_workflow/src/reborn_services/types.rs index 05059829898..efd0fd5625f 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/types.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/types.rs @@ -641,20 +641,15 @@ pub enum RebornAutomationRunStatus { } /// Browser-visible status for an individual automation run. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RebornAutomationRecentRunStatus { Running, Ok, + #[default] Error, } -impl Default for RebornAutomationRecentRunStatus { - fn default() -> Self { - Self::Error - } -} - impl<'de> Deserialize<'de> for RebornAutomationRecentRunStatus { fn deserialize(deserializer: D) -> Result where diff --git a/crates/ironclaw_triggers/src/postgres.rs b/crates/ironclaw_triggers/src/postgres.rs index 9c5ac6ba391..1ef5b77cb18 100644 --- a/crates/ironclaw_triggers/src/postgres.rs +++ b/crates/ironclaw_triggers/src/postgres.rs @@ -955,7 +955,7 @@ struct SuccessfulFireResultUpdate<'a> { } async fn upsert_run_history( - client: &(impl GenericClient + Sync), + client: &impl GenericClient, run: &TriggerRunRecord, ) -> Result<(), TriggerError> { let run_id = run.run_id.as_ref().map(ToString::to_string); @@ -993,7 +993,7 @@ async fn upsert_run_history( } async fn complete_run_history( - client: &(impl GenericClient + Sync), + client: &impl GenericClient, tenant_id: &TenantId, trigger_id: TriggerId, fire_slot: Timestamp, @@ -1036,7 +1036,7 @@ async fn complete_run_history( } async fn prune_run_history( - client: &(impl GenericClient + Sync), + client: &impl GenericClient, tenant_id: &TenantId, trigger_id: TriggerId, ) -> Result<(), TriggerError> { diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs index 01cf9738bad..661670be3b6 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -658,8 +658,10 @@ fn automation_info(automation_id: &str, name: &str, cron: &str) -> RebornAutomat last_run_at: None, last_status: None, recent_runs: vec![RebornAutomationRecentRunInfo { - run_id: Some("run-listed".to_string()), - thread_id: "thread-listed".to_string(), + run_id: Some( + TurnRunId::parse("11111111-1111-1111-1111-111111111111").expect("valid run id"), + ), + thread_id: ThreadId::new("thread-listed").expect("valid thread id"), fire_slot: None, status: RebornAutomationRecentRunStatus::Running, submitted_at: "2026-06-03T09:00:01Z".parse().expect("submitted at"), From 3f330229f5a1678dfd46ebcb1e9b8cac59f0cbf1 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 15:51:43 -0700 Subject: [PATCH 06/13] fix: address automation review comments --- .../tests/first_party_builtin_tools.rs | 125 ++++++++++++++++++ .../src/reborn_services.rs | 34 +++-- .../src/reborn_services/types.rs | 21 +-- .../tests/reborn_services_contract.rs | 46 ++++++- crates/ironclaw_triggers/src/lib.rs | 25 +++- crates/ironclaw_triggers/src/libsql.rs | 14 +- crates/ironclaw_triggers/src/postgres.rs | 17 +-- 7 files changed, 224 insertions(+), 58 deletions(-) diff --git a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs index 552f3fc6aaf..3a954d92cec 100644 --- a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs +++ b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs @@ -1093,6 +1093,131 @@ async fn builtin_trigger_list_embeds_recent_run_history_with_run_limit() { ); } +#[tokio::test] +async fn builtin_trigger_list_with_zero_run_limit_returns_empty_recent_runs() { + let repository = Arc::new(InMemoryTriggerRepository::default()); + let runtime = runtime_with_trigger_repository(repository.clone()); + let context = execution_context([TRIGGER_CREATE_CAPABILITY_ID, TRIGGER_LIST_CAPABILITY_ID]); + + invoke_with_context( + &runtime, + TRIGGER_CREATE_CAPABILITY_ID, + json!({ + "name": "Zero run limit trigger", + "prompt": "Create history rows", + "cron": "0 8 * * *" + }), + context.clone(), + ) + .await + .unwrap(); + + let record = repository + .list_triggers(context.resource_scope.tenant_id.clone()) + .await + .unwrap() + .pop() + .expect("persisted trigger"); + seed_completed_trigger_runs(&repository, &record, 1).await; + + let listed = invoke_with_context( + &runtime, + TRIGGER_LIST_CAPABILITY_ID, + json!({ "run_limit": 0 }), + context, + ) + .await + .unwrap(); + + assert_eq!(listed["triggers"][0]["recent_runs"], json!([])); +} + +#[tokio::test] +async fn builtin_trigger_list_clamps_oversized_run_limit_to_max() { + let repository = Arc::new(InMemoryTriggerRepository::default()); + let runtime = runtime_with_trigger_repository(repository.clone()); + let context = execution_context([TRIGGER_CREATE_CAPABILITY_ID, TRIGGER_LIST_CAPABILITY_ID]); + + invoke_with_context( + &runtime, + TRIGGER_CREATE_CAPABILITY_ID, + json!({ + "name": "Oversized run limit trigger", + "prompt": "Create many history rows", + "cron": "0 8 * * *" + }), + context.clone(), + ) + .await + .unwrap(); + + let record = repository + .list_triggers(context.resource_scope.tenant_id.clone()) + .await + .unwrap() + .pop() + .expect("persisted trigger"); + seed_completed_trigger_runs(&repository, &record, 101).await; + + let listed = invoke_with_context( + &runtime, + TRIGGER_LIST_CAPABILITY_ID, + json!({ "run_limit": 200 }), + context, + ) + .await + .unwrap(); + + assert_eq!( + listed["triggers"][0]["recent_runs"] + .as_array() + .unwrap() + .len(), + 100 + ); +} + +async fn seed_completed_trigger_runs( + repository: &InMemoryTriggerRepository, + record: &TriggerRecord, + count: usize, +) { + for index in 0..count { + let fire_slot = record.next_run_at + chrono::Duration::minutes(index as i64); + let run_id = TurnRunId::new(); + repository + .claim_due_fire(ClaimDueFireRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot, + now: fire_slot, + }) + .await + .unwrap(); + repository + .mark_fire_accepted(FireAcceptedRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot, + run_id, + submitted_at: fire_slot + chrono::Duration::seconds(1), + next_run_at: fire_slot + chrono::Duration::minutes(1), + }) + .await + .unwrap(); + repository + .clear_active_fire(ClearActiveFireRequest { + tenant_id: record.tenant_id.clone(), + trigger_id: record.trigger_id, + fire_slot, + run_id, + status: TriggerRunHistoryStatus::Ok, + }) + .await + .unwrap(); + } +} + #[tokio::test] async fn builtin_trigger_remove_rejects_invalid_trigger_id() { let repository = Arc::new(InMemoryTriggerRepository::default()); diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index a2911dcee22..14c1366f80f 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -1354,7 +1354,7 @@ impl RebornServicesApi for RebornServices { )); }; let limit = clamp_automation_list_limit(request.limit); - let run_limit = clamp_automation_run_limit(request.run_limit); + let run_limit = resolve_automation_run_limit(request.run_limit); let automations = self .automation_facade .list_automations(caller, AutomationListRequest { limit, run_limit }) @@ -1706,14 +1706,29 @@ fn automation_unavailable() -> RebornServicesError { RebornServicesError::service_unavailable(true) } +const AUTOMATION_TRIGGER_THREAD_SOURCE: &str = "automation_trigger"; + fn is_automation_trigger_thread(thread: &SessionThreadRecord) -> bool { - thread - .metadata_json - .as_deref() - .and_then(|metadata| serde_json::from_str::(metadata).ok()) - .is_some_and(|metadata| { - metadata.get("source").and_then(serde_json::Value::as_str) == Some("automation_trigger") - }) + let Some(metadata) = thread.metadata_json.as_deref() else { + return false; + }; + if !metadata.contains(AUTOMATION_TRIGGER_THREAD_SOURCE) { + return false; + } + match serde_json::from_str::(metadata) { + Ok(metadata) => { + metadata.get("source").and_then(serde_json::Value::as_str) + == Some(AUTOMATION_TRIGGER_THREAD_SOURCE) + } + Err(error) => { + tracing::debug!( + error = %error, + thread_id = %thread.thread_id, + "failed to parse thread metadata_json for automation filter" + ); + false + } + } } fn outbound_preferences_unavailable() -> RebornServicesError { @@ -2416,8 +2431,9 @@ fn clamp_automation_list_limit(requested: Option) -> usize { clamped as usize } -fn clamp_automation_run_limit(requested: Option) -> usize { +fn resolve_automation_run_limit(requested: Option) -> usize { let raw = requested.unwrap_or(AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE); + // 0 is intentional: callers suppress embedded run history by passing run_limit=0. let clamped = raw.min(AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE); clamped as usize } diff --git a/crates/ironclaw_product_workflow/src/reborn_services/types.rs b/crates/ironclaw_product_workflow/src/reborn_services/types.rs index efd0fd5625f..18d7f1b463b 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/types.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/types.rs @@ -641,28 +641,15 @@ pub enum RebornAutomationRunStatus { } /// Browser-visible status for an individual automation run. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RebornAutomationRecentRunStatus { Running, Ok, - #[default] Error, -} - -impl<'de> Deserialize<'de> for RebornAutomationRecentRunStatus { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = serde_json::Value::deserialize(deserializer)?; - Ok(match value.as_str() { - Some("running") => Self::Running, - Some("ok") => Self::Ok, - Some("error") => Self::Error, - _ => Self::Error, - }) - } + #[default] + #[serde(other)] + Unknown, } /// Browser-safe automation run projection. diff --git a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs index a39c6e9658a..0de48ab1b03 100644 --- a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs @@ -4831,7 +4831,7 @@ fn reborn_automation_state_round_trips_serde_for_every_variant() { } #[test] -fn reborn_automation_recent_run_info_round_trips_typed_ids_and_sanitizes_status() { +fn reborn_automation_recent_run_info_round_trips_typed_ids_and_preserves_unknown_status() { let recent_run = RebornAutomationRecentRunInfo { run_id: Some(automation_run_id()), thread_id: ThreadId::new("thread-listed").expect("valid thread id"), @@ -4857,14 +4857,36 @@ fn reborn_automation_recent_run_info_round_trips_typed_ids_and_sanitizes_status( serde_json::from_value(serialized).expect("deserialize recent run"); assert_eq!(deserialized, recent_run); - let sanitized: RebornAutomationRecentRunInfo = serde_json::from_value(json!({ + let future_status: RebornAutomationRecentRunInfo = serde_json::from_value(json!({ + "run_id": "11111111-1111-1111-1111-111111111111", + "thread_id": "thread-listed", + "status": "cancelled", + "submitted_at": "2026-06-03T09:00:01Z", + })) + .expect("deserialize future recent run status"); + assert_eq!( + future_status.status, + RebornAutomationRecentRunStatus::Unknown + ); + + let defaulted_status: RebornAutomationRecentRunInfo = serde_json::from_value(json!({ + "run_id": "11111111-1111-1111-1111-111111111111", + "thread_id": "thread-listed", + "submitted_at": "2026-06-03T09:00:01Z", + })) + .expect("deserialize defaulted recent run status"); + assert_eq!( + defaulted_status.status, + RebornAutomationRecentRunStatus::Unknown + ); + + serde_json::from_value::(json!({ "run_id": "11111111-1111-1111-1111-111111111111", "thread_id": "thread-listed", "status": { "backend": "future" }, "submitted_at": "2026-06-03T09:00:01Z", })) - .expect("deserialize sanitized recent run"); - assert_eq!(sanitized.status, RebornAutomationRecentRunStatus::Error); + .expect_err("recent run rejects malformed status"); serde_json::from_value::(json!({ "run_id": "not-a-uuid", @@ -5565,6 +5587,8 @@ async fn list_threads_hides_automation_trigger_threads() { let caller = caller(); let visible_thread_id = ThreadId::new("thread-visible").expect("visible thread id"); let automation_thread_id = ThreadId::new("thread-automation").expect("automation thread id"); + let malformed_metadata_thread_id = + ThreadId::new("thread-malformed-metadata").expect("malformed metadata thread id"); thread_service .ensure_thread(EnsureThreadRequest { @@ -5592,6 +5616,16 @@ async fn list_threads_hides_automation_trigger_threads() { }) .await .expect("automation thread"); + thread_service + .ensure_thread(EnsureThreadRequest { + scope: thread_scope_for(&caller), + thread_id: Some(malformed_metadata_thread_id.clone()), + created_by_actor_id: caller.user_id.as_str().to_string(), + title: Some("Malformed metadata chat".to_string()), + metadata_json: Some(r#"{"source":"automation_trigger""#.to_string()), + }) + .await + .expect("malformed metadata thread"); let response = services .list_threads(caller, WebUiListThreadsRequest::default()) @@ -5603,7 +5637,9 @@ async fn list_threads_hides_automation_trigger_threads() { .map(|thread| thread.thread_id.clone()) .collect::>(); - assert_eq!(thread_ids, vec![visible_thread_id]); + assert_eq!(thread_ids.len(), 2); + assert!(thread_ids.contains(&visible_thread_id)); + assert!(thread_ids.contains(&malformed_metadata_thread_id)); assert!( !thread_ids.contains(&automation_thread_id), "automation trigger threads should be accessible by direct id but hidden from the chat list", diff --git a/crates/ironclaw_triggers/src/lib.rs b/crates/ironclaw_triggers/src/lib.rs index beaab35d5ed..ede5612f0af 100644 --- a/crates/ironclaw_triggers/src/lib.rs +++ b/crates/ironclaw_triggers/src/lib.rs @@ -403,6 +403,15 @@ pub enum TriggerRunHistoryStatus { Error, } +#[cfg(any(feature = "libsql", feature = "postgres"))] +pub(crate) fn trigger_run_history_status_text(value: TriggerRunHistoryStatus) -> &'static str { + match value { + TriggerRunHistoryStatus::Running => "running", + TriggerRunHistoryStatus::Ok => "ok", + TriggerRunHistoryStatus::Error => "error", + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TriggerRunRecord { pub tenant_id: TenantId, @@ -796,11 +805,11 @@ pub trait TriggerRepository: Send + Sync { /// /// Each entry is ordered newest first by fire slot and truncated to `limit`. /// - /// The default implementation issues one serial - /// [`list_trigger_run_history`] call per trigger id. Storage-backed - /// repositories used by list-page or UI paths should override this with a - /// true batch query so callers do not accidentally introduce N sequential - /// round-trips. + /// The default implementation is a non-production fallback: it issues one + /// serial [`list_trigger_run_history`] call per trigger id and logs when it + /// is exercised. Storage-backed repositories used by list-page or UI paths + /// must override this with a true batch query so callers do not + /// accidentally introduce N sequential round-trips. async fn list_trigger_run_history_batch( &self, tenant_id: TenantId, @@ -811,6 +820,12 @@ pub trait TriggerRepository: Send + Sync { if limit == 0 { return Ok(runs_by_trigger); } + if !trigger_ids.is_empty() { + tracing::warn!( + trigger_count = trigger_ids.len(), + "default trigger run-history batch fallback is issuing serial per-trigger lookups" + ); + } for trigger_id in trigger_ids { runs_by_trigger.insert( *trigger_id, diff --git a/crates/ironclaw_triggers/src/libsql.rs b/crates/ironclaw_triggers/src/libsql.rs index ac469e3a5eb..cc13d3e08fe 100644 --- a/crates/ironclaw_triggers/src/libsql.rs +++ b/crates/ironclaw_triggers/src/libsql.rs @@ -20,6 +20,7 @@ use crate::{ TriggerId, TriggerRecord, TriggerRepository, TriggerRouteThreadId, TriggerRunHistoryStatus, TriggerRunRecord, TriggerRunStatus, TriggerSchedule, TriggerSourceKind, TriggerState, reject_failed_result_after_active_run, reject_non_future_next_run_at, reject_run_ref_rewrite, + trigger_run_history_status_text, }; #[cfg(feature = "libsql")] @@ -1396,7 +1397,7 @@ async fn upsert_run_history( fmt_ts(&run.fire_slot), opt_turn_run_id(run.run_id.as_ref()), run.thread_id.as_str(), - run_history_status_text(run.status), + trigger_run_history_status_text(run.status), fmt_ts(&run.submitted_at), opt_ts(run.completed_at.as_ref()), ], @@ -1457,7 +1458,7 @@ async fn complete_run_history( ) .thread_id .as_str(), - run_history_status_text(status), + trigger_run_history_status_text(status), fmt_ts(&completed_at), fmt_ts(&completed_at), ], @@ -1648,15 +1649,6 @@ fn parse_run_status(value: &str) -> Result { } } -#[cfg(feature = "libsql")] -fn run_history_status_text(value: TriggerRunHistoryStatus) -> &'static str { - match value { - TriggerRunHistoryStatus::Running => "running", - TriggerRunHistoryStatus::Ok => "ok", - TriggerRunHistoryStatus::Error => "error", - } -} - #[cfg(feature = "libsql")] fn parse_run_history_status(value: &str) -> Result { match value { diff --git a/crates/ironclaw_triggers/src/postgres.rs b/crates/ironclaw_triggers/src/postgres.rs index 1ef5b77cb18..8558801e057 100644 --- a/crates/ironclaw_triggers/src/postgres.rs +++ b/crates/ironclaw_triggers/src/postgres.rs @@ -14,6 +14,7 @@ use crate::{ TriggerId, TriggerRecord, TriggerRepository, TriggerRouteThreadId, TriggerRunHistoryStatus, TriggerRunRecord, TriggerRunStatus, TriggerSchedule, TriggerSourceKind, TriggerState, reject_failed_result_after_active_run, reject_non_future_next_run_at, reject_run_ref_rewrite, + trigger_run_history_status_text, }; const TRIGGER_TABLE: &str = "trigger_records"; @@ -959,7 +960,7 @@ async fn upsert_run_history( run: &TriggerRunRecord, ) -> Result<(), TriggerError> { let run_id = run.run_id.as_ref().map(ToString::to_string); - let status = run_history_status_text(run.status); + let status = trigger_run_history_status_text(run.status); let submitted_at = fmt_ts(&run.submitted_at); let completed_at = run.completed_at.as_ref().map(fmt_ts); client @@ -1005,15 +1006,16 @@ async fn complete_run_history( let thread_id = TriggerRunRecord::running(tenant_id.clone(), trigger_id, fire_slot, run_id, fire_slot) .thread_id; - let status = run_history_status_text(status); + let status = trigger_run_history_status_text(status); let fire_slot_text = fmt_ts(&fire_slot); let completed_at = fmt_ts(&completed_at); + let submitted_at_fallback = completed_at.clone(); client .execute( &format!( "INSERT INTO {TRIGGER_RUN_TABLE} ( tenant_id, trigger_id, fire_slot, run_id, thread_id, status, submitted_at, completed_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (tenant_id, trigger_id, fire_slot) DO UPDATE SET run_id = COALESCE(trigger_run_history.run_id, EXCLUDED.run_id), status = EXCLUDED.status, @@ -1026,6 +1028,7 @@ async fn complete_run_history( &run_id_text, &thread_id.as_str(), &status, + &submitted_at_fallback, &completed_at, ], ) @@ -1249,14 +1252,6 @@ fn parse_run_status(value: &str) -> Result { } } -fn run_history_status_text(value: TriggerRunHistoryStatus) -> &'static str { - match value { - TriggerRunHistoryStatus::Running => "running", - TriggerRunHistoryStatus::Ok => "ok", - TriggerRunHistoryStatus::Error => "error", - } -} - fn parse_run_history_status(value: &str) -> Result { match value { "running" => Ok(TriggerRunHistoryStatus::Running), From 9308d7abbeeeb46cd57df3cc8ccde641c4d8a572 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 16:04:16 -0700 Subject: [PATCH 07/13] fix: preserve terminal trigger run history --- crates/ironclaw_triggers/src/lib.rs | 49 ++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/ironclaw_triggers/src/lib.rs b/crates/ironclaw_triggers/src/lib.rs index ede5612f0af..8b7081f0cb4 100644 --- a/crates/ironclaw_triggers/src/lib.rs +++ b/crates/ironclaw_triggers/src/lib.rs @@ -1433,8 +1433,16 @@ impl InMemoryTriggerRepository { submitted_at: Timestamp, ) -> Result<(), TriggerError> { let mut state = self.lock_state()?; + let key = TriggerRunRepositoryKey::new(tenant_id, trigger_id, fire_slot); + if state + .runs + .get(&key) + .is_some_and(|run| run.completed_at.is_some()) + { + return Ok(()); + } state.runs.insert( - TriggerRunRepositoryKey::new(tenant_id, trigger_id, fire_slot), + key, TriggerRunRecord::running( tenant_id.clone(), trigger_id, @@ -2267,6 +2275,45 @@ mod tests { assert_eq!(due_records.len(), MAX_DUE_TRIGGER_POLL_LIMIT); } + #[tokio::test] + async fn in_memory_repository_running_history_does_not_overwrite_terminal_history() { + let repo = InMemoryTriggerRepository::default(); + let tenant_id = tenant("tenant-a"); + let trigger_id = TriggerId::parse("01HZZZZZZZZZZZZZZZZZZZZZZZ").expect("ulid"); + let fire_slot = ts(1_704_067_200); + let run_id = TurnRunId::parse("01890f0f-9b6f-7a85-9e5b-9f21a93c4f5a").expect("valid run"); + let completed_at = fire_slot + chrono::Duration::seconds(30); + let later_submitted_at = fire_slot + chrono::Duration::seconds(45); + + repo.complete_run_history( + &tenant_id, + trigger_id, + fire_slot, + Some(run_id), + TriggerRunHistoryStatus::Ok, + completed_at, + ) + .expect("seed terminal history"); + + repo.upsert_running_run_history( + &tenant_id, + trigger_id, + fire_slot, + run_id, + later_submitted_at, + ) + .expect("late running upsert is ignored"); + + let runs = repo + .list_trigger_run_history(tenant_id, trigger_id, 10) + .await + .expect("list run history"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].status, TriggerRunHistoryStatus::Ok); + assert_eq!(runs[0].submitted_at, completed_at); + assert_eq!(runs[0].completed_at, Some(completed_at)); + } + #[test] fn in_memory_repository_returns_backend_error_when_mutex_is_poisoned() { let repo = InMemoryTriggerRepository::default(); From 20eb5f5fbde072fec079947ac775e70628569931 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 16:13:07 -0700 Subject: [PATCH 08/13] fix: share automation trigger thread source tag --- crates/ironclaw_product_workflow/src/reborn_services.rs | 9 ++++----- .../src/trigger_poller_trusted_submit.rs | 7 ++++--- crates/ironclaw_threads/src/lib.rs | 3 +++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 14c1366f80f..1570e5a1d31 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -23,7 +23,8 @@ use ironclaw_product_adapters::{ use ironclaw_threads::{ AcceptInboundMessageRequest, AcceptedInboundMessageReplay, EnsureThreadRequest, MessageContent, MessageStatus, ReplayAcceptedInboundMessageRequest, SessionThreadError, SessionThreadRecord, - SessionThreadService, ThreadHistoryRequest, ThreadMessageId, ThreadScope, + SessionThreadService, TRIGGER_THREAD_SOURCE_TAG, ThreadHistoryRequest, ThreadMessageId, + ThreadScope, }; use ironclaw_turns::{ AcceptedMessageRef, GateRef, GetRunStateRequest, IdempotencyKey, ResumeTurnPrecondition, @@ -1706,19 +1707,17 @@ fn automation_unavailable() -> RebornServicesError { RebornServicesError::service_unavailable(true) } -const AUTOMATION_TRIGGER_THREAD_SOURCE: &str = "automation_trigger"; - fn is_automation_trigger_thread(thread: &SessionThreadRecord) -> bool { let Some(metadata) = thread.metadata_json.as_deref() else { return false; }; - if !metadata.contains(AUTOMATION_TRIGGER_THREAD_SOURCE) { + if !metadata.contains(TRIGGER_THREAD_SOURCE_TAG) { return false; } match serde_json::from_str::(metadata) { Ok(metadata) => { metadata.get("source").and_then(serde_json::Value::as_str) - == Some(AUTOMATION_TRIGGER_THREAD_SOURCE) + == Some(TRIGGER_THREAD_SOURCE_TAG) } Err(error) => { tracing::debug!( diff --git a/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs b/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs index 20fd42cc53f..d074807739f 100644 --- a/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs +++ b/crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs @@ -12,7 +12,8 @@ use ironclaw_safety::{ }; use ironclaw_threads::{ AcceptInboundMessageRequest as ThreadAcceptInboundMessageRequest, EnsureThreadRequest, - MessageContent, SessionThreadService as CanonicalSessionThreadService, ThreadScope, + MessageContent, SessionThreadService as CanonicalSessionThreadService, + TRIGGER_THREAD_SOURCE_TAG, ThreadScope, }; use ironclaw_triggers::{ TriggerError, TriggerFire, TriggerId, TriggerMaterializedPrompt, TriggerPromptMaterializer, @@ -328,7 +329,7 @@ async fn record_trigger_prompt( fn trigger_thread_metadata_json(trigger_id: TriggerId) -> String { serde_json::json!({ - "source": "automation_trigger", + "source": TRIGGER_THREAD_SOURCE_TAG, "trigger_id": trigger_id.to_string(), }) .to_string() @@ -1672,7 +1673,7 @@ mod tests { .expect("trigger thread metadata"), ) .expect("trigger thread metadata json"); - assert_eq!(metadata["source"], "automation_trigger"); + assert_eq!(metadata["source"], TRIGGER_THREAD_SOURCE_TAG); assert_eq!(metadata["trigger_id"], trigger_id.to_string()); let history = thread_service .list_thread_history(ThreadHistoryRequest { diff --git a/crates/ironclaw_threads/src/lib.rs b/crates/ironclaw_threads/src/lib.rs index 70796ff2103..3756f9e2f44 100644 --- a/crates/ironclaw_threads/src/lib.rs +++ b/crates/ironclaw_threads/src/lib.rs @@ -27,6 +27,9 @@ pub use filesystem_service::FilesystemSessionThreadService; // to it via semver. Sibling modules import directly through // `crate::title::derive_thread_title`. +/// Thread metadata source tag used for host-trusted automation trigger run threads. +pub const TRIGGER_THREAD_SOURCE_TAG: &str = "automation_trigger"; + pub use capability_display_preview::{ CapabilityDisplayPreviewEnvelope, CapabilityDisplayPreviewEnvelopeInput, CapabilityDisplayPreviewStatus, From d133298604a455379966af9dd76b4908e48ea9fd Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 16:20:35 -0700 Subject: [PATCH 09/13] fix: keep automation projection client-neutral --- .../src/reborn_services/types.rs | 10 +-- .../src/automation.rs | 66 ++++++++++++------- crates/ironclaw_reborn_composition/src/lib.rs | 2 +- .../ironclaw_reborn_composition/src/webui.rs | 6 +- .../components/automations-list.js | 40 +++++------ docs/reborn/contracts/triggers.md | 2 +- 6 files changed, 75 insertions(+), 51 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/reborn_services/types.rs b/crates/ironclaw_product_workflow/src/reborn_services/types.rs index 18d7f1b463b..43a07bdf2b7 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/types.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/types.rs @@ -245,7 +245,7 @@ pub struct RebornListThreadsResponse { pub next_cursor: Option, } -/// Bounded browser projection for caller-scoped automations. +/// Bounded product projection for caller-scoped automations. /// /// The beta API currently returns one capped page without a cursor. Future /// pagination can extend this response with an optional cursor without changing @@ -640,7 +640,7 @@ pub enum RebornAutomationRunStatus { Error, } -/// Browser-visible status for an individual automation run. +/// Client-visible status for an individual automation run. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RebornAutomationRecentRunStatus { @@ -652,7 +652,7 @@ pub enum RebornAutomationRecentRunStatus { Unknown, } -/// Browser-safe automation run projection. +/// Client-safe automation run projection. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RebornAutomationRecentRunInfo { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -667,9 +667,9 @@ pub struct RebornAutomationRecentRunInfo { pub completed_at: Option>, } -/// Allowlisted browser-visible state for automation list projections. +/// Allowlisted client-visible state for automation list projections. /// -/// Unknown runtime states are collapsed to `unknown` so the browser DTO stays +/// Unknown runtime states are collapsed to `unknown` so the client DTO stays /// typed without surfacing raw backend strings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/ironclaw_reborn_composition/src/automation.rs b/crates/ironclaw_reborn_composition/src/automation.rs index 55cad134b82..f38c9a393ed 100644 --- a/crates/ironclaw_reborn_composition/src/automation.rs +++ b/crates/ironclaw_reborn_composition/src/automation.rs @@ -22,21 +22,21 @@ use serde_json::{Value, json}; const AUTOMATION_BACKEND_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone)] -pub struct RebornWebuiAutomationFacade { +pub struct RebornAutomationProductFacade { host_runtime: Arc, backend_timeout: Duration, } -impl std::fmt::Debug for RebornWebuiAutomationFacade { +impl std::fmt::Debug for RebornAutomationProductFacade { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter - .debug_struct("RebornWebuiAutomationFacade") + .debug_struct("RebornAutomationProductFacade") .field("host_runtime", &"Arc") .finish() } } -impl RebornWebuiAutomationFacade { +impl RebornAutomationProductFacade { pub(crate) fn new(host_runtime: Arc) -> Self { Self { host_runtime, @@ -117,7 +117,7 @@ impl RebornWebuiAutomationFacade { } #[async_trait::async_trait] -impl AutomationProductFacade for RebornWebuiAutomationFacade { +impl AutomationProductFacade for RebornAutomationProductFacade { async fn list_automations( &self, caller: ProductAgentBoundCaller, @@ -233,6 +233,28 @@ fn sanitize_automation_list_output(output: &mut Value) { }; trigger_object.insert("last_status".to_string(), status); trigger_object.insert("state".to_string(), state); + sanitize_recent_run_statuses(trigger_object); + } +} + +fn sanitize_recent_run_statuses(trigger_object: &mut serde_json::Map) { + let Some(recent_runs) = trigger_object + .get_mut("recent_runs") + .and_then(Value::as_array_mut) + else { + return; + }; + for run in recent_runs { + let Some(run_object) = run.as_object_mut() else { + continue; + }; + let status = match run_object.get("status").and_then(Value::as_str) { + Some("running") => Value::String("running".to_string()), + Some("ok") => Value::String("ok".to_string()), + Some("error") => Value::String("error".to_string()), + _ => Value::String("unknown".to_string()), + }; + run_object.insert("status".to_string(), status); } } @@ -366,7 +388,7 @@ fn map_host_runtime_error(error: HostRuntimeError) -> RebornServicesError { } fn automation_extension_id() -> Result { - ExtensionId::new("reborn.webui.automation").map_err(|_| internal_invariant()) + ExtensionId::new("reborn.product.automation").map_err(|_| internal_invariant()) } fn services_error( @@ -419,12 +441,12 @@ mod tests { use serde_json::{Value, json}; use tokio::sync::Mutex; - use super::RebornWebuiAutomationFacade; + use super::RebornAutomationProductFacade; #[tokio::test] async fn automation_facade_preserves_caller_scope_and_capability_path() { let runtime = Arc::new(RecordingHostRuntime::default()); - let facade = RebornWebuiAutomationFacade::new(runtime.clone()); + let facade = RebornAutomationProductFacade::new(runtime.clone()); let caller = caller(); let automations = facade @@ -485,7 +507,7 @@ mod tests { #[tokio::test] async fn automation_facade_rejects_malformed_trigger_list_output() { - let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + let facade = RebornAutomationProductFacade::new(Arc::new(OutputHostRuntime::new(json!({ "unexpected": true })))); @@ -500,7 +522,7 @@ mod tests { #[tokio::test] async fn automation_facade_rejects_non_array_trigger_list_output() { - let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + let facade = RebornAutomationProductFacade::new(Arc::new(OutputHostRuntime::new(json!({ "triggers": { "trigger_id": "trigger-listed" } @@ -526,7 +548,7 @@ mod tests { "last_status".to_string(), json!({"trace": "internal details", "secret": "token"}), ); - let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + let facade = RebornAutomationProductFacade::new(Arc::new(OutputHostRuntime::new(json!({ "triggers": [Value::Object(trigger)] })))); @@ -561,7 +583,7 @@ mod tests { .cloned() .expect("object trigger"); non_string_status["recent_runs"][0]["status"] = json!({"raw": "backend-only"}); - let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + let facade = RebornAutomationProductFacade::new(Arc::new(OutputHostRuntime::new(json!({ "triggers": [ Value::Object(unknown_status), Value::Object(non_string_status) @@ -576,11 +598,11 @@ mod tests { assert_eq!(automations.len(), 2); assert_eq!( automations[0].recent_runs[0].status, - RebornAutomationRecentRunStatus::Error + RebornAutomationRecentRunStatus::Unknown ); assert_eq!( automations[1].recent_runs[0].status, - RebornAutomationRecentRunStatus::Error + RebornAutomationRecentRunStatus::Unknown ); } @@ -632,7 +654,7 @@ mod tests { "is_active": true, "created_at": "2026-06-02T18:00:00Z" }); - let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + let facade = RebornAutomationProductFacade::new(Arc::new(OutputHostRuntime::new(json!({ "triggers": [ Value::Object(paused), Value::Object(scheduled), @@ -657,7 +679,7 @@ mod tests { #[tokio::test] async fn automation_facade_filters_unknown_future_sources() { - let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + let facade = RebornAutomationProductFacade::new(Arc::new(OutputHostRuntime::new(json!({ "triggers": [ raw_automation("trigger-schedule", "Daily status", "0 9 * * *", Some("ok")), { @@ -682,7 +704,7 @@ mod tests { #[tokio::test] async fn automation_facade_rejects_malformed_trigger_records() { - let facade = RebornWebuiAutomationFacade::new(Arc::new(OutputHostRuntime::new(json!({ + let facade = RebornAutomationProductFacade::new(Arc::new(OutputHostRuntime::new(json!({ "triggers": [{ "name": "Missing trigger id", "schedule": {"kind": "cron", "expression": "0 9 * * *"}, @@ -705,7 +727,7 @@ mod tests { #[tokio::test] async fn automation_facade_redacts_runtime_failure_messages() { let runtime = Arc::new(FailingHostRuntime::new(RuntimeFailureKind::Internal)); - let facade = RebornWebuiAutomationFacade::new(runtime); + let facade = RebornAutomationProductFacade::new(runtime); let caller = caller(); let error = facade @@ -722,7 +744,7 @@ mod tests { #[tokio::test] async fn automation_facade_times_out_stalled_runtime() { - let facade = RebornWebuiAutomationFacade::with_backend_timeout( + let facade = RebornAutomationProductFacade::with_backend_timeout( Arc::new(HangingHostRuntime), std::time::Duration::from_millis(1), ); @@ -807,7 +829,7 @@ mod tests { for (outcome, code, kind, status_code, retryable) in cases { let facade = - RebornWebuiAutomationFacade::new(Arc::new(OutcomeHostRuntime::new(outcome))); + RebornAutomationProductFacade::new(Arc::new(OutcomeHostRuntime::new(outcome))); let error = facade .list_automations(caller(), automation_list_request(50, 10)) @@ -856,7 +878,7 @@ mod tests { for (failure_kind, code, kind, status_code, retryable) in cases { let facade = - RebornWebuiAutomationFacade::new(Arc::new(FailingHostRuntime::new(failure_kind))); + RebornAutomationProductFacade::new(Arc::new(FailingHostRuntime::new(failure_kind))); let error = facade .list_automations(caller(), automation_list_request(10, 5)) @@ -891,7 +913,7 @@ mod tests { for (host_error, code, kind, status_code, retryable) in cases { let facade = - RebornWebuiAutomationFacade::new(Arc::new(ErrorHostRuntime::new(host_error))); + RebornAutomationProductFacade::new(Arc::new(ErrorHostRuntime::new(host_error))); let error = facade .list_automations(caller(), automation_list_request(10, 5)) diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 05972ee3692..44e2c4b5ae4 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -136,7 +136,7 @@ pub use auth::{ RebornOAuthCallbackOutcome, RebornOAuthCallbackRequest, RebornOAuthCallbackResponse, RebornProductAuthServicePorts, RebornProductAuthServices, }; -pub use automation::RebornWebuiAutomationFacade; +pub use automation::RebornAutomationProductFacade; pub use budget::build_default_budget_accountant; pub use budget_events::{BudgetEventObserver, TracingBudgetEventObserver}; pub use error::RebornBuildError; diff --git a/crates/ironclaw_reborn_composition/src/webui.rs b/crates/ironclaw_reborn_composition/src/webui.rs index 7cfe32d04ce..956c3d247d4 100644 --- a/crates/ironclaw_reborn_composition/src/webui.rs +++ b/crates/ironclaw_reborn_composition/src/webui.rs @@ -12,8 +12,8 @@ use ironclaw_product_workflow::{ }; use crate::{ - RebornBuildError, RebornProductAuthServices, RebornReadiness, RebornRuntime, - RebornWebuiAutomationFacade, + RebornAutomationProductFacade, RebornBuildError, RebornProductAuthServices, RebornReadiness, + RebornRuntime, lifecycle::{ RebornLocalLifecycleFacade, RebornLocalSkillManagementError, RebornLocalSkillManagementPort, }, @@ -72,7 +72,7 @@ pub(crate) fn build_webui_services_with_connectable_channels( let automation_facade = services .host_runtime .as_ref() - .map(|host_runtime| Arc::new(RebornWebuiAutomationFacade::new(Arc::clone(host_runtime)))); + .map(|host_runtime| Arc::new(RebornAutomationProductFacade::new(Arc::clone(host_runtime)))); let mut api = ProductRebornServices::new( runtime.webui_thread_service(), diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js index 2e6d1d8d198..2034f2451df 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js @@ -65,9 +65,8 @@ function recentRunKey(run) { return run.run_id || run.thread_id || run.submitted_at || run.timestamp_source; } -function automationRowKeyDown(event, automationId, onSelectAutomation) { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); +function selectAutomationFromButton(event, automationId, onSelectAutomation) { + event.stopPropagation(); onSelectAutomation(automationId); } @@ -304,28 +303,31 @@ export function AutomationsList({ return html` onSelectAutomation(automation.automation_id)} - onKeyDown=${(event) => - automationRowKeyDown( - event, - automation.automation_id, - onSelectAutomation - )} className=${cn( - "cursor-pointer border-b border-[var(--v2-panel-border)] last:border-0 hover:bg-white/[0.03] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--v2-accent)]", + "cursor-pointer border-b border-[var(--v2-panel-border)] last:border-0 hover:bg-white/[0.03]", selected && "bg-[var(--v2-accent-soft)]/30" )} > -
- ${automation.display_name} -
-
- ${automation.automation_id} -
+ ${automation.schedule_label} diff --git a/docs/reborn/contracts/triggers.md b/docs/reborn/contracts/triggers.md index f8202488496..88af9582576 100644 --- a/docs/reborn/contracts/triggers.md +++ b/docs/reborn/contracts/triggers.md @@ -287,7 +287,7 @@ The turn pipeline remains the source of truth for admission, active-lock handlin In V1, `last_status` reflects submit outcome only. It is separate from the active-fire claim and does not become an in-flight sentinel. -V1 also persists bounded per-trigger run-history rows for browser inspection: +V1 also persists bounded per-trigger run-history rows for product-surface inspection: - each row is scoped by `(tenant_id, trigger_id, fire_slot)` and records the deterministic trigger route thread id, optional submitted `TurnRunId`, From 9d7dd6a3ad48366d4d9ab65bd4dd36991e1114cb Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 8 Jun 2026 16:51:56 -0700 Subject: [PATCH 10/13] fix: address local automation review findings --- .../src/reborn_services.rs | 75 +++++++++++---- .../tests/reborn_services_contract.rs | 92 +++++++++++++++++++ .../components/automations-list.js | 25 +---- .../automations/lib/automations-presenters.js | 23 +++-- 4 files changed, 169 insertions(+), 46 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 1570e5a1d31..531ab686e17 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -1323,23 +1323,7 @@ impl RebornServicesApi for RebornServices { owner_user_id: Some(caller.user_id.clone()), mission_id: None, }; - let response = self - .thread_service - .list_threads_for_scope(ironclaw_threads::ListThreadsForScopeRequest { - scope, - limit: request.limit, - cursor: request.cursor, - }) - .await - .map_err(map_thread_error)?; - Ok(RebornListThreadsResponse { - threads: response - .threads - .into_iter() - .filter(|thread| !is_automation_trigger_thread(thread)) - .collect(), - next_cursor: response.next_cursor, - }) + self.list_visible_threads_for_scope(scope, request).await } async fn list_automations( @@ -1640,6 +1624,54 @@ impl RebornServicesApi for RebornServices { } impl RebornServices { + async fn list_visible_threads_for_scope( + &self, + scope: ThreadScope, + request: WebUiListThreadsRequest, + ) -> Result { + let visible_limit = resolve_thread_list_limit(request.limit); + let mut cursor = request.cursor; + let mut visible_threads = Vec::with_capacity(visible_limit); + let mut next_cursor = None; + + while visible_threads.len() < visible_limit { + let remaining = visible_limit - visible_threads.len(); + let response = self + .thread_service + .list_threads_for_scope(ironclaw_threads::ListThreadsForScopeRequest { + scope: scope.clone(), + limit: Some(remaining as u32), + cursor: cursor.clone(), + }) + .await + .map_err(map_thread_error)?; + visible_threads.extend( + response + .threads + .into_iter() + .filter(|thread| !is_automation_trigger_thread(thread)), + ); + next_cursor = response.next_cursor; + let Some(next) = next_cursor.clone() else { + break; + }; + if cursor.as_deref() == Some(next.as_str()) { + tracing::warn!( + cursor = %next, + "thread listing cursor did not advance while filtering automation threads" + ); + next_cursor = None; + break; + } + cursor = Some(next); + } + + Ok(RebornListThreadsResponse { + threads: visible_threads, + next_cursor, + }) + } + fn thread_operation_lock(&self, scope: &TurnScope) -> Arc> { let key = thread_operation_key(scope); let mut locks = match self.thread_operation_locks.lock() { @@ -2418,12 +2450,21 @@ pub const AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE: u32 = 100; /// thread accumulates an unusual number of summaries. const TIMELINE_MAX_SUMMARY_ARTIFACTS: usize = 200; +const THREAD_LIST_DEFAULT_PAGE_SIZE: u32 = 50; +const THREAD_LIST_MAX_PAGE_SIZE: u32 = 200; + fn clamp_timeline_limit(requested: Option) -> usize { let raw = requested.unwrap_or(TIMELINE_DEFAULT_PAGE_SIZE); let clamped = raw.clamp(1, TIMELINE_MAX_PAGE_SIZE); clamped as usize } +fn resolve_thread_list_limit(requested: Option) -> usize { + let raw = requested.unwrap_or(THREAD_LIST_DEFAULT_PAGE_SIZE); + let clamped = raw.clamp(1, THREAD_LIST_MAX_PAGE_SIZE); + clamped as usize +} + fn clamp_automation_list_limit(requested: Option) -> usize { let raw = requested.unwrap_or(AUTOMATION_LIST_DEFAULT_PAGE_SIZE); let clamped = raw.clamp(1, AUTOMATION_LIST_MAX_PAGE_SIZE); diff --git a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs index 0de48ab1b03..a50f2a347c7 100644 --- a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs @@ -5645,3 +5645,95 @@ async fn list_threads_hides_automation_trigger_threads() { "automation trigger threads should be accessible by direct id but hidden from the chat list", ); } + +#[tokio::test] +async fn list_threads_skips_hidden_automation_threads_when_filling_page() { + let thread_service = Arc::new(InMemorySessionThreadService::default()); + let services = RebornServices::new( + thread_service.clone(), + Arc::new(FakeTurnCoordinator::default()), + ); + let caller = caller(); + let automation_thread_id = ThreadId::new("thread-a-automation").expect("automation thread id"); + let first_visible_thread_id = + ThreadId::new("thread-b-visible").expect("first visible thread id"); + let second_visible_thread_id = + ThreadId::new("thread-c-visible").expect("second visible thread id"); + + thread_service + .ensure_thread(EnsureThreadRequest { + scope: thread_scope_for(&caller), + thread_id: Some(automation_thread_id.clone()), + created_by_actor_id: caller.user_id.as_str().to_string(), + title: Some("Automation run".to_string()), + metadata_json: Some( + json!({ + "source": "automation_trigger", + "trigger_id": "trigger-scheduled-summary", + }) + .to_string(), + ), + }) + .await + .expect("automation thread"); + thread_service + .ensure_thread(EnsureThreadRequest { + scope: thread_scope_for(&caller), + thread_id: Some(first_visible_thread_id.clone()), + created_by_actor_id: caller.user_id.as_str().to_string(), + title: Some("First visible chat".to_string()), + metadata_json: Some(json!({ "source": "webui" }).to_string()), + }) + .await + .expect("first visible thread"); + thread_service + .ensure_thread(EnsureThreadRequest { + scope: thread_scope_for(&caller), + thread_id: Some(second_visible_thread_id.clone()), + created_by_actor_id: caller.user_id.as_str().to_string(), + title: Some("Second visible chat".to_string()), + metadata_json: Some(json!({ "source": "webui" }).to_string()), + }) + .await + .expect("second visible thread"); + + let first_page = services + .list_threads( + caller.clone(), + WebUiListThreadsRequest { + limit: Some(1), + cursor: None, + }, + ) + .await + .expect("list first visible page"); + assert_eq!( + first_page + .threads + .iter() + .map(|thread| thread.thread_id.clone()) + .collect::>(), + vec![first_visible_thread_id], + ); + assert_eq!(first_page.next_cursor.as_deref(), Some("thread-b-visible")); + + let second_page = services + .list_threads( + caller, + WebUiListThreadsRequest { + limit: Some(1), + cursor: first_page.next_cursor, + }, + ) + .await + .expect("list second visible page"); + assert_eq!( + second_page + .threads + .iter() + .map(|thread| thread.thread_id.clone()) + .collect::>(), + vec![second_visible_thread_id], + ); + assert_eq!(second_page.next_cursor, None); +} diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js index 2034f2451df..bf4aac7d6b3 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-list.js @@ -5,15 +5,7 @@ import { EmptyPanel, Panel, StatusPill } from "../../../design-system/primitives import { html } from "../../../lib/html.js"; import { useT } from "../../../lib/i18n.js"; import { cn } from "../../../utils/cn.js"; -import { filterAutomations } from "../lib/automations-presenters.js"; - -const AUTOMATION_FILTERS = [ - { value: "all", labelKey: "automations.filter.all" }, - { value: "active", labelKey: "automations.filter.active" }, - { value: "running", labelKey: "automations.filter.running" }, - { value: "failures", labelKey: "automations.filter.failures" }, - { value: "paused", labelKey: "automations.filter.paused" }, -]; +import { AUTOMATION_FILTERS, filterAutomations } from "../lib/automations-presenters.js"; function MetaItem({ label, value, tone }) { return html` @@ -65,11 +57,6 @@ function recentRunKey(run) { return run.run_id || run.thread_id || run.submitted_at || run.timestamp_source; } -function selectAutomationFromButton(event, automationId, onSelectAutomation) { - event.stopPropagation(); - onSelectAutomation(automationId); -} - function RecentRunRow({ run, onOpenRun }) { const t = useT(); const canOpen = Boolean(run.chat_path); @@ -303,9 +290,8 @@ export function AutomationsList({ return html` onSelectAutomation(automation.automation_id)} className=${cn( - "cursor-pointer border-b border-[var(--v2-panel-border)] last:border-0 hover:bg-white/[0.03]", + "border-b border-[var(--v2-panel-border)] last:border-0 hover:bg-white/[0.03]", selected && "bg-[var(--v2-accent-soft)]/30" )} > @@ -313,12 +299,7 @@ export function AutomationsList({