From 5fa5a1d9784ca4103e5094b16effc5c58f93207d Mon Sep 17 00:00:00 2001 From: Chum Yin Date: Sun, 29 Mar 2026 20:40:19 +0800 Subject: [PATCH 1/4] feat: add structured developer tracing --- Cargo.lock | 86 ++++++++++++++- Cargo.toml | 2 + crates/app/Cargo.toml | 1 + crates/app/src/acp/manager.rs | 70 +++++++++++- crates/app/src/channel/mod.rs | 39 ++++++- crates/app/src/lib.rs | 1 + crates/app/src/observability.rs | 102 ++++++++++++++++++ .../src/provider/request_failover_runtime.rs | 46 +++++++- .../src/provider/request_session_runtime.rs | 21 +++- crates/app/src/provider/runtime_binding.rs | 17 +++ crates/app/src/tools/mod.rs | 34 +++++- crates/daemon/Cargo.toml | 2 + crates/daemon/src/lib.rs | 2 + crates/daemon/src/main.rs | 7 ++ crates/daemon/src/observability.rs | 99 +++++++++++++++++ 15 files changed, 517 insertions(+), 12 deletions(-) create mode 100644 crates/app/src/observability.rs create mode 100644 crates/daemon/src/observability.rs diff --git a/Cargo.lock b/Cargo.lock index c13cf5de9..6e67545ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1971,6 +1971,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2117,6 +2123,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", + "tracing", "unicode-normalization", "unicode-segmentation", "wait-timeout", @@ -2175,6 +2182,8 @@ dependencies = [ "time", "tokio", "toml", + "tracing", + "tracing-subscriber", "wat", ] @@ -2250,6 +2259,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -2323,6 +2341,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3271,6 +3298,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -3377,7 +3413,6 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", "windows-sys 0.59.0", ] @@ -3768,6 +3803,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -3903,6 +3981,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index a3ce1a841..a6c2f43ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ wasmtime = { version = "43.0.0", default-features = false, features = ["std", "r rusqlite = { version = "0.39", features = ["bundled"] } axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } which = "8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } [profile.release] lto = "thin" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 2abd81af0..a3ab83a32 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -87,6 +87,7 @@ prost = { version = "0.14", optional = true } rustls = { version = "0.23", default-features = false, features = ["ring"], optional = true } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"], optional = true } libc = "0.2" +tracing.workspace = true [dev-dependencies] axum.workspace = true diff --git a/crates/app/src/acp/manager.rs b/crates/app/src/acp/manager.rs index c860af652..953e05da1 100644 --- a/crates/app/src/acp/manager.rs +++ b/crates/app/src/acp/manager.rs @@ -7,10 +7,10 @@ use crate::CliResult; use crate::config::LoongClawConfig; use super::backend::{ - ACP_SESSION_METADATA_ACTIVATION_ORIGIN, AcpAbortController, AcpConfigPatch, AcpDoctorReport, - AcpRoutingOrigin, AcpSessionBootstrap, AcpSessionHandle, AcpSessionMetadata, AcpSessionMode, - AcpSessionState, AcpSessionStatus, AcpTurnEventSink, AcpTurnRequest, AcpTurnResult, - BufferedAcpTurnEventSink, CompositeAcpTurnEventSink, + ACP_SESSION_METADATA_ACTIVATION_ORIGIN, ACP_TURN_METADATA_TRACE_ID, AcpAbortController, + AcpConfigPatch, AcpDoctorReport, AcpRoutingOrigin, AcpSessionBootstrap, AcpSessionHandle, + AcpSessionMetadata, AcpSessionMode, AcpSessionState, AcpSessionStatus, AcpTurnEventSink, + AcpTurnRequest, AcpTurnResult, BufferedAcpTurnEventSink, CompositeAcpTurnEventSink, }; use super::binding::AcpSessionBindingScope; use super::merge_turn_events; @@ -115,9 +115,25 @@ impl AcpSessionManager { self.cleanup_idle_sessions(config).await?; let selection = resolve_acp_backend_selection(config); + tracing::debug!( + target: "loongclaw.acp", + session_key = %bootstrap.session_key, + backend_id = %selection.id, + conversation_id = ?bootstrap.conversation_id.as_deref(), + mode = ?bootstrap.mode, + binding = ?AcpSessionBindingScope::from_bootstrap(bootstrap), + "ensuring ACP session" + ); if let Some(existing) = self.resolve_existing_session(config, selection.id.as_str(), bootstrap)? { + tracing::debug!( + target: "loongclaw.acp", + session_key = %existing.session_key, + backend_id = %existing.backend_id, + state = ?existing.state, + "reused ACP session" + ); return Ok(existing); } @@ -136,6 +152,13 @@ impl AcpSessionManager { .get(ACP_SESSION_METADATA_ACTIVATION_ORIGIN) .and_then(|value| AcpRoutingOrigin::parse(value)); self.store.upsert(metadata.clone())?; + tracing::debug!( + target: "loongclaw.acp", + session_key = %metadata.session_key, + backend_id = %metadata.backend_id, + activation_origin = ?metadata.activation_origin.map(AcpRoutingOrigin::as_str), + "created ACP session" + ); Ok(metadata) } @@ -156,11 +179,25 @@ impl AcpSessionManager { request: &AcpTurnRequest, sink: Option<&dyn AcpTurnEventSink>, ) -> CliResult { + let started_at = std::time::Instant::now(); let actor_key = actor_key_for_bootstrap(bootstrap); let _turn_queue_guard = self.acquire_turn_queue_guard(actor_key.clone()).await?; self.cleanup_idle_sessions(config).await?; let mut metadata = self.ensure_session(config, bootstrap).await?; + let trace_id = request + .metadata + .get(ACP_TURN_METADATA_TRACE_ID) + .map(String::as_str); + tracing::debug!( + target: "loongclaw.acp", + session_key = %bootstrap.session_key, + backend_id = %metadata.backend_id, + trace_id = ?trace_id, + input_len = request.input.chars().count(), + sink_enabled = sink.is_some(), + "starting ACP turn" + ); let backend = resolve_acp_backend(Some(metadata.backend_id.as_str()))?; metadata.state = AcpSessionState::Busy; metadata.clear_error(); @@ -209,11 +246,27 @@ impl AcpSessionManager { Ok(mut result) => { self.record_turn_completion(turn_started_ms, true)?; let streamed_events = buffered_sink.snapshot()?; + let duration_ms = started_at.elapsed().as_millis(); + let reported_event_count = result.events.len(); + let streamed_event_count = streamed_events.len(); result.events = merge_turn_events(&result.events, &streamed_events); metadata.state = result.state; metadata.clear_error(); metadata.touch(); self.store.upsert(metadata)?; + tracing::debug!( + target: "loongclaw.acp", + session_key = %bootstrap.session_key, + backend_id = %handle.backend_id, + trace_id = ?trace_id, + state = ?result.state, + stop_reason = ?result.stop_reason, + reported_event_count, + streamed_event_count, + merged_event_count = result.events.len(), + duration_ms, + "ACP turn completed" + ); Ok(result) } Err(error) => { @@ -222,6 +275,15 @@ impl AcpSessionManager { metadata.state = AcpSessionState::Error; metadata.set_error(error.clone()); self.store.upsert(metadata)?; + tracing::warn!( + target: "loongclaw.acp", + session_key = %bootstrap.session_key, + backend_id = %handle.backend_id, + trace_id = ?trace_id, + duration_ms = started_at.elapsed().as_millis(), + error = %crate::observability::summarize_error(error.as_str()), + "ACP turn failed" + ); Err(error) } } diff --git a/crates/app/src/channel/mod.rs b/crates/app/src/channel/mod.rs index e60899f30..203e30241 100644 --- a/crates/app/src/channel/mod.rs +++ b/crates/app/src/channel/mod.rs @@ -4241,16 +4241,51 @@ pub(super) async fn process_inbound_with_provider( kernel_ctx: &KernelContext, feedback_policy: ChannelTurnFeedbackPolicy, ) -> CliResult { + let started_at = std::time::Instant::now(); let turn_config = reload_channel_turn_config(config, resolved_path)?; let runtime = DefaultConversationRuntime::from_config_or_env(&turn_config)?; - process_inbound_with_runtime_and_feedback( + let result = process_inbound_with_runtime_and_feedback( &turn_config, &runtime, message, ConversationRuntimeBinding::kernel(kernel_ctx), feedback_policy, ) - .await + .await; + let duration_ms = started_at.elapsed().as_millis(); + match &result { + Ok(reply) => { + tracing::debug!( + target: "loongclaw.channel", + platform = %message.session.platform.as_str(), + conversation_id = %message.session.conversation_id, + configured_account_id = ?message.session.configured_account_id.as_deref(), + account_id = ?message.session.account_id.as_deref(), + source_message_id = ?message.delivery.source_message_id.as_deref(), + ack_cursor = ?message.delivery.ack_cursor.as_deref(), + text_len = message.text.chars().count(), + reply_len = reply.chars().count(), + duration_ms, + "channel inbound processed" + ); + } + Err(error) => { + tracing::warn!( + target: "loongclaw.channel", + platform = %message.session.platform.as_str(), + conversation_id = %message.session.conversation_id, + configured_account_id = ?message.session.configured_account_id.as_deref(), + account_id = ?message.session.account_id.as_deref(), + source_message_id = ?message.delivery.source_message_id.as_deref(), + ack_cursor = ?message.delivery.ack_cursor.as_deref(), + text_len = message.text.chars().count(), + duration_ms, + error = %crate::observability::summarize_error(error), + "channel inbound failed" + ); + } + } + result } #[cfg(any( diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index e09dc1b21..dae1d6113 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -10,6 +10,7 @@ pub mod crypto; pub mod feishu; pub mod memory; pub mod migration; +pub(crate) mod observability; pub mod presentation; pub mod prompt; pub mod provider; diff --git a/crates/app/src/observability.rs b/crates/app/src/observability.rs new file mode 100644 index 000000000..290d76d03 --- /dev/null +++ b/crates/app/src/observability.rs @@ -0,0 +1,102 @@ +use serde_json::Value; + +const MAX_LOGGED_JSON_KEYS: usize = 8; +const MAX_ERROR_CHARS: usize = 240; + +pub(crate) fn json_value_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +pub(crate) fn top_level_json_keys(value: &Value) -> Vec { + let Value::Object(map) = value else { + return Vec::new(); + }; + + let mut keys = map + .keys() + .take(MAX_LOGGED_JSON_KEYS) + .cloned() + .collect::>(); + if map.len() > MAX_LOGGED_JSON_KEYS { + keys.push(format!("+{}", map.len() - MAX_LOGGED_JSON_KEYS)); + } + keys +} + +pub(crate) fn summarize_error(error: &str) -> String { + let compact = error.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= MAX_ERROR_CHARS { + return compact; + } + + let truncated = compact + .chars() + .take(MAX_ERROR_CHARS.saturating_sub(3)) + .collect::(); + format!("{truncated}...") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{json_value_kind, summarize_error, top_level_json_keys}; + + #[test] + fn json_value_kind_labels_common_shapes() { + assert_eq!(json_value_kind(&json!(null)), "null"); + assert_eq!(json_value_kind(&json!(true)), "bool"); + assert_eq!(json_value_kind(&json!(1)), "number"); + assert_eq!(json_value_kind(&json!("hello")), "string"); + assert_eq!(json_value_kind(&json!([1, 2, 3])), "array"); + assert_eq!(json_value_kind(&json!({"command": "pwd"})), "object"); + } + + #[test] + fn top_level_json_keys_limits_output() { + let value = json!({ + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6, + "g": 7, + "h": 8, + "i": 9 + }); + + assert_eq!( + top_level_json_keys(&value), + vec![ + "a".to_owned(), + "b".to_owned(), + "c".to_owned(), + "d".to_owned(), + "e".to_owned(), + "f".to_owned(), + "g".to_owned(), + "h".to_owned(), + "+1".to_owned() + ] + ); + } + + #[test] + fn summarize_error_collapses_whitespace_and_truncates() { + let repeated = "detail ".repeat(64); + let summary = summarize_error(&format!("line one\nline two\t{repeated}")); + + assert!(!summary.contains('\n')); + assert!(!summary.contains('\t')); + assert!(summary.ends_with("...")); + assert!(summary.chars().count() <= 240); + } +} diff --git a/crates/app/src/provider/request_failover_runtime.rs b/crates/app/src/provider/request_failover_runtime.rs index 90b1509c2..aa6b966a9 100644 --- a/crates/app/src/provider/request_failover_runtime.rs +++ b/crates/app/src/provider/request_failover_runtime.rs @@ -34,6 +34,15 @@ where let ordered_profiles = prioritize_provider_auth_profiles_by_health(auth_profiles, profile_state_policy); + tracing::debug!( + target: "loongclaw.provider", + provider_id = %provider.kind.profile().id, + binding = %binding.as_str(), + model_candidate_count = model_candidates.len(), + auth_profile_count = ordered_profiles.len(), + auto_model_mode, + "dispatching provider request across model candidates" + ); let mut last_error = None; let mut last_error_snapshot = None; for (model_index, model) in model_candidates.iter().enumerate() { @@ -44,6 +53,18 @@ where if let Some(policy) = profile_state_policy { mark_provider_profile_success(policy, profile); } + tracing::debug!( + target: "loongclaw.provider", + provider_id = %provider.kind.profile().id, + binding = %binding.as_str(), + model = %model, + auth_profile_id = %profile.id, + candidate_index = model_index + 1, + candidate_count = model_candidates.len(), + profile_index = profile_index + 1, + profile_count = ordered_profiles.len(), + "provider request succeeded" + ); return Ok(value); } Err(model_error) => { @@ -54,6 +75,8 @@ where snapshot, .. } = model_error; + let exhausted = profile_index + 1 >= ordered_profiles.len() + && model_index + 1 >= model_candidates.len(); record_provider_failover_audit_event( binding, provider, @@ -62,12 +85,31 @@ where auto_model_mode, model_index, model_candidates.len(), - profile_index + 1 >= ordered_profiles.len() - && model_index + 1 >= model_candidates.len(), + exhausted, ); if let Some(policy) = profile_state_policy { mark_provider_profile_failure(policy, profile, reason); } + tracing::warn!( + target: "loongclaw.provider", + provider_id = %provider.kind.profile().id, + binding = %binding.as_str(), + model = %snapshot.model, + auth_profile_id = %profile.id, + reason = %snapshot.reason.as_str(), + stage = %snapshot.stage.as_str(), + attempt = snapshot.attempt, + max_attempts = snapshot.max_attempts, + status_code = ?snapshot.status_code, + try_next_model, + candidate_index = model_index + 1, + candidate_count = model_candidates.len(), + profile_index = profile_index + 1, + profile_count = ordered_profiles.len(), + exhausted, + error = %crate::observability::summarize_error(message.as_str()), + "provider request attempt failed" + ); last_error = Some(message); last_error_snapshot = Some(snapshot); diff --git a/crates/app/src/provider/request_session_runtime.rs b/crates/app/src/provider/request_session_runtime.rs index 57d5f3981..d3c6f05cb 100644 --- a/crates/app/src/provider/request_session_runtime.rs +++ b/crates/app/src/provider/request_session_runtime.rs @@ -87,6 +87,14 @@ pub(super) async fn prepare_provider_request_session( classify_profile_failure_reason_from_message(error.as_str()), ); } + tracing::warn!( + target: "loongclaw.provider", + provider_id = %config.provider.kind.profile().id, + auth_profile_id = %profile.id, + auto_model_mode, + error = %crate::observability::summarize_error(error.as_str()), + "provider model catalog resolution failed for auth profile" + ); last_error = Some(error); } } @@ -108,7 +116,7 @@ pub(super) async fn prepare_provider_request_session( .await? }; - Ok(ProviderRequestSession { + let session = ProviderRequestSession { endpoint, headers, request_policy, @@ -119,7 +127,16 @@ pub(super) async fn prepare_provider_request_session( auto_model_mode, model_candidate_cooldown_policy, auth_context, - }) + }; + tracing::debug!( + target: "loongclaw.provider", + provider_id = %config.provider.kind.profile().id, + auth_profile_count = session.auth_profiles.len(), + model_candidate_count = session.model_candidates.len(), + auto_model_mode = session.auto_model_mode, + "prepared provider request session" + ); + Ok(session) } fn build_model_candidate_cooldown_policy( diff --git a/crates/app/src/provider/runtime_binding.rs b/crates/app/src/provider/runtime_binding.rs index 53f3c9fd9..f00de9ae4 100644 --- a/crates/app/src/provider/runtime_binding.rs +++ b/crates/app/src/provider/runtime_binding.rs @@ -23,7 +23,24 @@ impl<'a> ProviderRuntimeBinding<'a> { } } + pub const fn as_str(self) -> &'static str { + match self { + Self::Kernel(_) => "kernel", + Self::Direct => "direct", + } + } + pub const fn is_kernel_bound(self) -> bool { matches!(self, Self::Kernel(_)) } } + +#[cfg(test)] +mod tests { + use super::ProviderRuntimeBinding; + + #[test] + fn provider_runtime_binding_labels_are_stable() { + assert_eq!(ProviderRuntimeBinding::direct().as_str(), "direct"); + } +} diff --git a/crates/app/src/tools/mod.rs b/crates/app/src/tools/mod.rs index 3883c1f42..36ce1d635 100644 --- a/crates/app/src/tools/mod.rs +++ b/crates/app/src/tools/mod.rs @@ -648,6 +648,9 @@ pub fn execute_tool_core_with_config( request: ToolCoreRequest, config: &runtime_config::ToolRuntimeConfig, ) -> Result { + let requested_tool_name = request.tool_name.clone(); + let payload_kind = crate::observability::json_value_kind(&request.payload); + let payload_keys = crate::observability::top_level_json_keys(&request.payload); if !trusted_internal_tool_payload_enabled() && payload_uses_reserved_internal_tool_context(&request.payload) { @@ -664,11 +667,40 @@ pub fn execute_tool_core_with_config( let effective_config = trusted_runtime_narrowing_from_payload(&request.payload)? .map(|narrowing| config.narrowed(&narrowing)); let config = effective_config.as_ref().unwrap_or(config); - match canonical_name { + let started_at = std::time::Instant::now(); + let result = match canonical_name { "tool.search" => execute_tool_search_tool_with_config(request, config), "tool.invoke" => execute_tool_invoke_tool_with_config(request, config), _ => execute_discoverable_tool_core_with_config(request, config), + }; + let duration_ms = started_at.elapsed().as_millis(); + match &result { + Ok(outcome) => { + tracing::debug!( + target: "loongclaw.tools", + requested_tool_name = %requested_tool_name, + canonical_tool_name = %canonical_name, + payload_kind, + payload_keys = ?payload_keys, + status = %outcome.status, + duration_ms, + "tool execution completed" + ); + } + Err(error) => { + tracing::warn!( + target: "loongclaw.tools", + requested_tool_name = %requested_tool_name, + canonical_tool_name = %canonical_name, + payload_kind, + payload_keys = ?payload_keys, + duration_ms, + error = %crate::observability::summarize_error(error), + "tool execution failed" + ); + } } + result } fn trusted_runtime_narrowing_from_payload( diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 7c7804671..5d18d6bf1 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -70,6 +70,8 @@ rand.workspace = true sha2.workspace = true ed25519-dalek.workspace = true dunce = "1" +tracing.workspace = true +tracing-subscriber.workspace = true [[bin]] name = "loongclaw" diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 3e05f55c5..d97e0c2b3 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -85,6 +85,7 @@ mod memory_context_benchmark; pub mod migrate_cli; pub mod migration; pub mod next_actions; +mod observability; pub mod onboard_cli; mod onboard_finalize; mod onboard_preflight; @@ -106,6 +107,7 @@ pub mod supervisor; pub use loongclaw_spec::programmatic::{ acquire_programmatic_circuit_slot, record_programmatic_circuit_outcome, }; +pub use observability::init_tracing; #[allow( clippy::expect_used, diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index cdba1a178..1c01f7931 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -37,7 +37,9 @@ impl Drop for StdinGuard { #[tokio::main] async fn main() { let _stdin_guard = StdinGuard; + init_tracing(); let cli = Cli::parse(); + tracing::debug!(target: "loongclaw.daemon", command = ?cli.command, "parsed CLI command"); let result = match cli.command.unwrap_or_else(resolve_default_entry_command) { Commands::Welcome => run_welcome_cli(), Commands::Demo => run_demo().await, @@ -846,6 +848,11 @@ async fn main() { } }; if let Err(error) = result { + tracing::error!( + target: "loongclaw.daemon", + error = %error, + "CLI command failed" + ); #[allow(clippy::print_stderr)] { eprintln!("error: {error}"); diff --git a/crates/daemon/src/observability.rs b/crates/daemon/src/observability.rs new file mode 100644 index 000000000..8f1c003c8 --- /dev/null +++ b/crates/daemon/src/observability.rs @@ -0,0 +1,99 @@ +use std::io::{self, IsTerminal}; + +use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt::format::FmtSpan; +use tracing_subscriber::util::SubscriberInitExt; + +const DEFAULT_LOG_FILTER: &str = "warn"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LogFormat { + Compact, + Pretty, + Json, +} + +impl LogFormat { + fn parse(raw: Option<&str>) -> Self { + match raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("compact") + .to_ascii_lowercase() + .as_str() + { + "pretty" => Self::Pretty, + "json" => Self::Json, + _ => Self::Compact, + } + } +} + +fn resolved_log_directive(loongclaw_log: Option<&str>, rust_log: Option<&str>) -> String { + loongclaw_log + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| rust_log.map(str::trim).filter(|value| !value.is_empty())) + .unwrap_or(DEFAULT_LOG_FILTER) + .to_owned() +} + +fn build_env_filter(raw: &str) -> EnvFilter { + EnvFilter::try_new(raw).unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)) +} + +pub fn init_tracing() { + let log_format = LogFormat::parse(std::env::var("LOONGCLAW_LOG_FORMAT").ok().as_deref()); + let directive = resolved_log_directive( + std::env::var("LOONGCLAW_LOG").ok().as_deref(), + std::env::var("RUST_LOG").ok().as_deref(), + ); + let env_filter = build_env_filter(directive.as_str()); + let use_ansi = log_format != LogFormat::Json && io::stderr().is_terminal(); + let base = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(io::stderr) + .with_target(true) + .with_span_events(FmtSpan::CLOSE) + .with_ansi(use_ansi); + + let _ = match log_format { + LogFormat::Compact => base.compact().finish().try_init(), + LogFormat::Pretty => base.pretty().finish().try_init(), + LogFormat::Json => base.json().flatten_event(true).finish().try_init(), + }; +} + +#[cfg(test)] +mod tests { + use super::{LogFormat, build_env_filter, resolved_log_directive}; + + #[test] + fn resolved_log_directive_prefers_loongclaw_log() { + assert_eq!( + resolved_log_directive(Some("loongclaw_app=debug"), Some("warn")), + "loongclaw_app=debug" + ); + } + + #[test] + fn resolved_log_directive_falls_back_to_rust_log_then_default() { + assert_eq!(resolved_log_directive(None, Some("info")), "info"); + assert_eq!(resolved_log_directive(None, None), "warn"); + } + + #[test] + fn parse_log_format_accepts_known_variants() { + assert_eq!(LogFormat::parse(Some("pretty")), LogFormat::Pretty); + assert_eq!(LogFormat::parse(Some("json")), LogFormat::Json); + assert_eq!(LogFormat::parse(Some("compact")), LogFormat::Compact); + assert_eq!(LogFormat::parse(Some("unknown")), LogFormat::Compact); + } + + #[test] + fn build_env_filter_falls_back_on_invalid_directive() { + let filter = build_env_filter("[broken"); + let rendered = filter.to_string(); + assert_eq!(rendered, "warn"); + } +} From 05353917e5527884f2de26cea92a4b938893c6aa Mon Sep 17 00:00:00 2001 From: Chum Yin Date: Wed, 8 Apr 2026 12:50:58 +0800 Subject: [PATCH 2/4] Unblock governed session-lineage export for replay and evaluation LoongClaw already persisted turns, session events, terminal outcomes, approval requests, and delegate lineage, but operators still lacked one stable artifact that assembled those records into a trajectory export. This change adds an app-layer runtime trajectory contract, exposes the missing persisted turn and terminal-outcome seams needed to build it, and wires a daemon-side `runtime-trajectory` CLI for export/show. Integration coverage now exercises session-only export, lineage export, artifact round-trip, and text rendering. Constraint: Reuse the existing sqlite/session persistence model instead of introducing a new storage backend or daemon-local schema knowledge Rejected: Implement raw sqlite queries only in daemon | would bypass reusable app-layer seams and harden storage coupling in the wrong layer Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep `runtime-trajectory` read-only until replay or learning workflows have their own explicit governance and policy surfaces Tested: cargo fmt --all -- --check; cargo clippy --workspace --all-targets --all-features --locked -- -D warnings; cargo test -p loongclaw-daemon runtime_trajectory --all-features --locked -- --test-threads=1; ./scripts/check_architecture_boundaries.sh; ./scripts/check_dep_graph.sh Tested: cargo test --workspace --locked (reproduces pre-existing failure in `channel::registry::tests::discord_status_splits_config_backed_send_and_stub_serve`) Tested: cargo test --workspace --all-features --locked (reproduces the same pre-existing `discord_status_splits_config_backed_send_and_stub_serve` failure) Not-tested: A completely green full-workspace test run after the unrelated pre-existing Discord channel registry failure is fixed --- crates/app/src/memory/mod.rs | 13 +- crates/app/src/memory/sqlite.rs | 72 +++ crates/app/src/session/mod.rs | 3 + crates/app/src/session/repository.rs | 46 ++ crates/app/src/session/trajectory.rs | 507 ++++++++++++++++++ crates/daemon/src/lib.rs | 6 + crates/daemon/src/main.rs | 3 + crates/daemon/src/runtime_trajectory_cli.rs | 250 +++++++++ crates/daemon/tests/integration/cli_tests.rs | 77 +++ crates/daemon/tests/integration/mod.rs | 1 + .../integration/runtime_trajectory_cli.rs | 359 +++++++++++++ docs/product-specs/index.md | 2 + docs/product-specs/runtime-trajectory.md | 34 ++ 13 files changed, 1372 insertions(+), 1 deletion(-) create mode 100644 crates/app/src/session/trajectory.rs create mode 100644 crates/daemon/src/runtime_trajectory_cli.rs create mode 100644 crates/daemon/tests/integration/runtime_trajectory_cli.rs create mode 100644 docs/product-specs/runtime-trajectory.md diff --git a/crates/app/src/memory/mod.rs b/crates/app/src/memory/mod.rs index 3eec5a81d..1cbc07ec3 100644 --- a/crates/app/src/memory/mod.rs +++ b/crates/app/src/memory/mod.rs @@ -58,7 +58,10 @@ pub use protocol::{ decode_window_turn_count, decode_window_turns, encode_stage_envelope_payload, }; #[cfg(feature = "memory-sqlite")] -pub use sqlite::{ConversationTurn, SqliteBootstrapDiagnostics, SqliteContextLoadDiagnostics}; +pub use sqlite::{ + ConversationTurn, PersistedConversationTurnRecord, SqliteBootstrapDiagnostics, + SqliteContextLoadDiagnostics, +}; pub use stage::{ DerivedMemoryKind, MemoryRetrievalRequest, MemoryStageFamily, StageDiagnostics, StageEnvelope, StageOutcome, builtin_post_turn_stage_families, builtin_pre_assembly_stage_families, @@ -236,6 +239,14 @@ pub fn window_direct_extended( ) } +#[cfg(feature = "memory-sqlite")] +pub fn session_turn_records_direct( + session_id: &str, + config: &runtime_config::MemoryRuntimeConfig, +) -> Result, String> { + sqlite::session_turn_records_direct(session_id, config) +} + #[cfg(feature = "memory-sqlite")] pub fn ensure_memory_db_ready( path: Option, diff --git a/crates/app/src/memory/sqlite.rs b/crates/app/src/memory/sqlite.rs index a45c47e5a..ac116dc87 100644 --- a/crates/app/src/memory/sqlite.rs +++ b/crates/app/src/memory/sqlite.rs @@ -25,6 +25,15 @@ pub struct ConversationTurn { pub ts: i64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersistedConversationTurnRecord { + pub row_id: i64, + pub session_turn_index: i64, + pub role: String, + pub content: String, + pub ts: i64, +} + #[derive(Debug, Clone, Default)] pub(super) struct PromptWindowTurn { pub role: String, @@ -124,6 +133,11 @@ const SQL_QUERY_RECENT_TURNS_NO_ID: &str = "SELECT role, content, ts, session_tu WHERE session_id = ?1 ORDER BY id DESC LIMIT ?2"; +const SQL_QUERY_ALL_TURN_RECORDS_FOR_SESSION: &str = + "SELECT id, session_turn_index, role, content, ts + FROM turns + WHERE session_id = ?1 + ORDER BY id ASC"; const SQL_QUERY_RECENT_PROMPT_TURNS: &str = "SELECT role, content FROM turns WHERE session_id = ?1 @@ -648,6 +662,20 @@ pub(super) fn window_direct_with_options( load_window_internal(session_id, limit, allow_extended_limit, config).map(|window| window.turns) } +pub(super) fn session_turn_records_direct( + session_id: &str, + config: &MemoryRuntimeConfig, +) -> Result, String> { + let session_id = normalize_required_str( + session_id, + "memory.session_turn_records requires payload.session_id", + )?; + let runtime = acquire_memory_runtime(config)?; + runtime.with_connection("memory.session_turn_records", |conn| { + query_all_turn_records(conn, session_id) + }) +} + pub(super) fn load_context_snapshot( session_id: &str, config: &MemoryRuntimeConfig, @@ -1872,6 +1900,50 @@ fn query_recent_turns( Ok((turns, turn_count)) } +fn query_all_turn_records( + conn: &Connection, + session_id: &str, +) -> Result, String> { + let mut stmt = prepare_cached_sqlite_statement( + conn, + SQL_QUERY_ALL_TURN_RECORDS_FOR_SESSION, + "prepare session turn record query failed", + )?; + let mut rows = stmt + .query(rusqlite::params![session_id]) + .map_err(|error| format!("query session turn records failed: {error}"))?; + let mut turns = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|error| format!("read session turn record row failed: {error}"))? + { + let row_id = row + .get(0) + .map_err(|error| format!("decode session turn record row id failed: {error}"))?; + let session_turn_index = row.get(1).map_err(|error| { + format!("decode session turn record session turn index failed: {error}") + })?; + let role = row + .get(2) + .map_err(|error| format!("decode session turn record role failed: {error}"))?; + let content = row + .get(3) + .map_err(|error| format!("decode session turn record content failed: {error}"))?; + let ts = row + .get(4) + .map_err(|error| format!("decode session turn record timestamp failed: {error}"))?; + let record = PersistedConversationTurnRecord { + row_id, + session_turn_index, + role, + content, + ts, + }; + turns.push(record); + } + Ok(turns) +} + #[cfg(test)] fn query_recent_turns_with_boundary_id( conn: &Connection, diff --git a/crates/app/src/session/mod.rs b/crates/app/src/session/mod.rs index 8b22cd84e..06f8a7534 100644 --- a/crates/app/src/session/mod.rs +++ b/crates/app/src/session/mod.rs @@ -4,6 +4,9 @@ pub mod recovery; #[cfg(feature = "memory-sqlite")] pub mod repository; +#[cfg(feature = "memory-sqlite")] +pub mod trajectory; + #[allow(dead_code)] pub(crate) const DELEGATE_CANCEL_REQUESTED_EVENT_KIND: &str = "delegate_cancel_requested"; #[allow(dead_code)] diff --git a/crates/app/src/session/repository.rs b/crates/app/src/session/repository.rs index a55932382..170edc21b 100644 --- a/crates/app/src/session/repository.rs +++ b/crates/app/src/session/repository.rs @@ -308,6 +308,8 @@ pub struct SessionRepository { } impl SessionRepository { + const ALL_EVENTS_PAGE_LIMIT: usize = 256; + pub fn new(config: &MemoryRuntimeConfig) -> Result { let db_path = memory::ensure_memory_db_ready(config.sqlite_path.clone(), config)?; Ok(Self { db_path }) @@ -850,6 +852,12 @@ impl SessionRepository { Self::list_recent_events_with_conn(&conn, &session_id, limit) } + pub fn list_all_events(&self, session_id: &str) -> Result, String> { + let session_id = normalize_required_text(session_id, "session_id")?; + let conn = self.open_connection()?; + Self::drain_events_after_with_conn(&conn, &session_id, 0, Self::ALL_EVENTS_PAGE_LIMIT) + } + pub fn list_events_after( &self, session_id: &str, @@ -1497,6 +1505,44 @@ impl SessionRepository { }) } + pub fn upsert_session_terminal_outcome( + &self, + session_id: &str, + status: &str, + payload_json: Value, + ) -> Result { + let session_id = normalize_required_text(session_id, "session_id")?; + let status = normalize_required_text(status, "status")?; + if self + .load_session_summary_with_legacy_fallback(&session_id)? + .is_none() + { + return Err(format!("session `{session_id}` not found")); + } + + let encoded_payload = serde_json::to_string(&payload_json) + .map_err(|error| format!("encode session terminal outcome payload failed: {error}"))?; + let recorded_at = unix_ts_now(); + let conn = self.open_connection()?; + conn.execute( + "INSERT INTO session_terminal_outcomes(session_id, status, payload_json, recorded_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(session_id) DO UPDATE SET + status = excluded.status, + payload_json = excluded.payload_json, + recorded_at = excluded.recorded_at", + params![session_id, status, encoded_payload, recorded_at], + ) + .map_err(|error| format!("upsert session terminal outcome failed: {error}"))?; + + Ok(SessionTerminalOutcomeRecord { + session_id, + status, + payload_json, + recorded_at, + }) + } + fn open_connection(&self) -> Result { Connection::open(&self.db_path) .map_err(|error| format!("open session repository sqlite db failed: {error}")) diff --git a/crates/app/src/session/trajectory.rs b/crates/app/src/session/trajectory.rs new file mode 100644 index 000000000..322a2cdcb --- /dev/null +++ b/crates/app/src/session/trajectory.rs @@ -0,0 +1,507 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::memory::runtime_config::MemoryRuntimeConfig; +use crate::memory::{ + CanonicalMemoryRecord, PersistedConversationTurnRecord, + canonical_memory_record_from_persisted_turn, session_turn_records_direct, +}; + +use super::repository::{ + ApprovalRequestRecord, SessionEventRecord, SessionRepository, SessionSummaryRecord, + SessionTerminalOutcomeRecord, +}; + +pub const RUNTIME_TRAJECTORY_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 1; +pub const RUNTIME_TRAJECTORY_ARTIFACT_SURFACE: &str = "runtime_trajectory"; +pub const RUNTIME_TRAJECTORY_ARTIFACT_PURPOSE: &str = "session_lineage_export"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrajectoryExportMode { + SessionOnly, + Lineage, +} + +impl RuntimeTrajectoryExportMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::SessionOnly => "session_only", + Self::Lineage => "lineage", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeTrajectoryArtifactSchema { + pub version: u32, + pub surface: String, + pub purpose: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeTrajectoryStatistics { + pub session_count: usize, + pub turn_count: usize, + pub terminal_outcome_count: usize, + pub session_event_count: usize, + pub approval_request_count: usize, + pub canonical_kind_counts: BTreeMap, + pub conversation_event_name_counts: BTreeMap, + pub session_event_kind_counts: BTreeMap, + pub approval_status_counts: BTreeMap, + pub tool_intent_status_counts: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeTrajectoryCanonicalRecord { + pub scope: String, + pub kind: String, + pub role: Option, + pub content: String, + pub metadata: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeTrajectoryTurnRecord { + pub row_id: i64, + pub session_turn_index: i64, + pub role: String, + pub content: String, + pub ts: i64, + pub canonical_record: RuntimeTrajectoryCanonicalRecord, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeTrajectorySessionSummary { + pub session_id: String, + pub kind: String, + pub parent_session_id: Option, + pub label: Option, + pub state: String, + pub created_at: i64, + pub updated_at: i64, + pub archived_at: Option, + pub turn_count: usize, + pub last_turn_at: Option, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeTrajectorySessionEvent { + pub id: i64, + pub event_kind: String, + pub actor_session_id: Option, + pub payload_json: Value, + pub ts: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeTrajectoryTerminalOutcome { + pub status: String, + pub payload_json: Value, + pub recorded_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeTrajectoryApprovalRequest { + pub approval_request_id: String, + pub turn_id: String, + pub tool_call_id: String, + pub tool_name: String, + pub approval_key: String, + pub status: String, + pub decision: Option, + pub request_payload_json: Value, + pub governance_snapshot_json: Value, + pub requested_at: i64, + pub resolved_at: Option, + pub resolved_by_session_id: Option, + pub executed_at: Option, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeTrajectorySession { + pub summary: RuntimeTrajectorySessionSummary, + pub lineage_depth: usize, + pub turns: Vec, + pub session_events: Vec, + pub terminal_outcome: Option, + pub approval_requests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeTrajectoryArtifactDocument { + pub schema: RuntimeTrajectoryArtifactSchema, + pub exported_at: String, + pub requested_session_id: String, + pub root_session_id: String, + pub export_mode: RuntimeTrajectoryExportMode, + pub sessions: Vec, + pub statistics: RuntimeTrajectoryStatistics, +} + +pub fn export_runtime_trajectory( + requested_session_id: &str, + export_mode: RuntimeTrajectoryExportMode, + memory_config: &MemoryRuntimeConfig, + exported_at: &str, +) -> Result { + let requested_session_id = normalize_required_text( + requested_session_id, + "runtime trajectory export requires session_id", + )?; + let exported_at = normalize_required_text( + exported_at, + "runtime trajectory export requires exported_at", + )?; + let repo = SessionRepository::new(memory_config)?; + let requested_summary = repo + .load_session_summary_with_legacy_fallback(requested_session_id.as_str())? + .ok_or_else(|| { + format!("runtime trajectory export session `{requested_session_id}` was not found") + })?; + let root_session_id = resolve_root_session_id(&repo, requested_session_id.as_str())?; + let session_summaries = + collect_export_session_summaries(&repo, &requested_summary, &root_session_id, export_mode)?; + let sessions = collect_export_sessions(&repo, memory_config, session_summaries.as_slice())?; + let statistics = build_runtime_trajectory_statistics(sessions.as_slice()); + + Ok(RuntimeTrajectoryArtifactDocument { + schema: RuntimeTrajectoryArtifactSchema { + version: RUNTIME_TRAJECTORY_ARTIFACT_JSON_SCHEMA_VERSION, + surface: RUNTIME_TRAJECTORY_ARTIFACT_SURFACE.to_owned(), + purpose: RUNTIME_TRAJECTORY_ARTIFACT_PURPOSE.to_owned(), + }, + exported_at, + requested_session_id, + root_session_id, + export_mode, + sessions, + statistics, + }) +} + +fn normalize_required_text(raw: &str, error_message: &str) -> Result { + let normalized = raw.trim(); + if normalized.is_empty() { + return Err(error_message.to_owned()); + } + Ok(normalized.to_owned()) +} + +fn resolve_root_session_id( + repo: &SessionRepository, + requested_session_id: &str, +) -> Result { + let lineage_root = repo.lineage_root_session_id(requested_session_id)?; + let root_session_id = lineage_root.unwrap_or_else(|| requested_session_id.to_owned()); + Ok(root_session_id) +} + +fn collect_export_session_summaries( + repo: &SessionRepository, + requested_summary: &SessionSummaryRecord, + root_session_id: &str, + export_mode: RuntimeTrajectoryExportMode, +) -> Result, String> { + let mut session_summaries = if export_mode == RuntimeTrajectoryExportMode::Lineage { + repo.list_visible_sessions(root_session_id)? + } else { + vec![requested_summary.clone()] + }; + + sort_runtime_trajectory_sessions(repo, &mut session_summaries)?; + Ok(session_summaries) +} + +fn sort_runtime_trajectory_sessions( + repo: &SessionRepository, + sessions: &mut [SessionSummaryRecord], +) -> Result<(), String> { + let mut depth_by_session_id = BTreeMap::new(); + for session in sessions.iter() { + let depth = repo.session_lineage_depth(session.session_id.as_str())?; + let session_id = session.session_id.clone(); + depth_by_session_id.insert(session_id, depth); + } + + sessions.sort_by(|left, right| { + let left_depth = depth_by_session_id + .get(left.session_id.as_str()) + .copied() + .unwrap_or_default(); + let right_depth = depth_by_session_id + .get(right.session_id.as_str()) + .copied() + .unwrap_or_default(); + left_depth + .cmp(&right_depth) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + Ok(()) +} + +fn collect_export_sessions( + repo: &SessionRepository, + memory_config: &MemoryRuntimeConfig, + session_summaries: &[SessionSummaryRecord], +) -> Result, String> { + let mut sessions = Vec::with_capacity(session_summaries.len()); + for session_summary in session_summaries { + let session = build_runtime_trajectory_session(repo, memory_config, session_summary)?; + sessions.push(session); + } + Ok(sessions) +} + +fn build_runtime_trajectory_session( + repo: &SessionRepository, + memory_config: &MemoryRuntimeConfig, + session_summary: &SessionSummaryRecord, +) -> Result { + let session_id = session_summary.session_id.as_str(); + let lineage_depth = repo.session_lineage_depth(session_id)?; + let turns = session_turn_records_direct(session_id, memory_config)? + .into_iter() + .map(|turn| runtime_trajectory_turn_record(session_id, turn)) + .collect::>(); + let session_events = repo + .list_all_events(session_id)? + .iter() + .map(runtime_trajectory_session_event) + .collect::>(); + let terminal_outcome = repo + .load_terminal_outcome(session_id)? + .as_ref() + .map(runtime_trajectory_terminal_outcome); + let approval_requests = repo + .list_approval_requests_for_session(session_id, None)? + .iter() + .map(runtime_trajectory_approval_request) + .collect::>(); + + Ok(RuntimeTrajectorySession { + summary: runtime_trajectory_session_summary(session_summary), + lineage_depth, + turns, + session_events, + terminal_outcome, + approval_requests, + }) +} + +fn runtime_trajectory_turn_record( + session_id: &str, + turn: PersistedConversationTurnRecord, +) -> RuntimeTrajectoryTurnRecord { + let canonical_record = canonical_memory_record_from_persisted_turn( + session_id, + turn.role.as_str(), + turn.content.as_str(), + ); + let canonical_record = runtime_trajectory_canonical_record(&canonical_record); + + RuntimeTrajectoryTurnRecord { + row_id: turn.row_id, + session_turn_index: turn.session_turn_index, + role: turn.role, + content: turn.content, + ts: turn.ts, + canonical_record, + } +} + +fn runtime_trajectory_canonical_record( + record: &CanonicalMemoryRecord, +) -> RuntimeTrajectoryCanonicalRecord { + RuntimeTrajectoryCanonicalRecord { + scope: record.scope.as_str().to_owned(), + kind: record.kind.as_str().to_owned(), + role: record.role.clone(), + content: record.content.clone(), + metadata: record.metadata.clone(), + } +} + +fn runtime_trajectory_session_summary( + summary: &SessionSummaryRecord, +) -> RuntimeTrajectorySessionSummary { + RuntimeTrajectorySessionSummary { + session_id: summary.session_id.clone(), + kind: summary.kind.as_str().to_owned(), + parent_session_id: summary.parent_session_id.clone(), + label: summary.label.clone(), + state: summary.state.as_str().to_owned(), + created_at: summary.created_at, + updated_at: summary.updated_at, + archived_at: summary.archived_at, + turn_count: summary.turn_count, + last_turn_at: summary.last_turn_at, + last_error: summary.last_error.clone(), + } +} + +fn runtime_trajectory_session_event(event: &SessionEventRecord) -> RuntimeTrajectorySessionEvent { + RuntimeTrajectorySessionEvent { + id: event.id, + event_kind: event.event_kind.clone(), + actor_session_id: event.actor_session_id.clone(), + payload_json: event.payload_json.clone(), + ts: event.ts, + } +} + +fn runtime_trajectory_terminal_outcome( + outcome: &SessionTerminalOutcomeRecord, +) -> RuntimeTrajectoryTerminalOutcome { + RuntimeTrajectoryTerminalOutcome { + status: outcome.status.clone(), + payload_json: outcome.payload_json.clone(), + recorded_at: outcome.recorded_at, + } +} + +fn runtime_trajectory_approval_request( + request: &ApprovalRequestRecord, +) -> RuntimeTrajectoryApprovalRequest { + let decision = request + .decision + .map(|decision| decision.as_str().to_owned()); + let status = request.status.as_str().to_owned(); + RuntimeTrajectoryApprovalRequest { + approval_request_id: request.approval_request_id.clone(), + turn_id: request.turn_id.clone(), + tool_call_id: request.tool_call_id.clone(), + tool_name: request.tool_name.clone(), + approval_key: request.approval_key.clone(), + status, + decision, + request_payload_json: request.request_payload_json.clone(), + governance_snapshot_json: request.governance_snapshot_json.clone(), + requested_at: request.requested_at, + resolved_at: request.resolved_at, + resolved_by_session_id: request.resolved_by_session_id.clone(), + executed_at: request.executed_at, + last_error: request.last_error.clone(), + } +} + +fn build_runtime_trajectory_statistics( + sessions: &[RuntimeTrajectorySession], +) -> RuntimeTrajectoryStatistics { + let session_count = sessions.len(); + let mut turn_count = 0usize; + let mut terminal_outcome_count = 0usize; + let mut session_event_count = 0usize; + let mut approval_request_count = 0usize; + let mut canonical_kind_counts = BTreeMap::new(); + let mut conversation_event_name_counts = BTreeMap::new(); + let mut session_event_kind_counts = BTreeMap::new(); + let mut approval_status_counts = BTreeMap::new(); + let mut tool_intent_status_counts = BTreeMap::new(); + + for session in sessions { + turn_count += session.turns.len(); + session_event_count += session.session_events.len(); + approval_request_count += session.approval_requests.len(); + if session.terminal_outcome.is_some() { + terminal_outcome_count += 1; + } + + for turn in &session.turns { + let kind = turn.canonical_record.kind.clone(); + let current_count = canonical_kind_counts + .get(kind.as_str()) + .copied() + .unwrap_or_default(); + let next_count = current_count + 1; + canonical_kind_counts.insert(kind, next_count); + record_conversation_event_counts( + turn, + &mut conversation_event_name_counts, + &mut tool_intent_status_counts, + ); + } + + for event in &session.session_events { + let event_kind = event.event_kind.clone(); + let current_count = session_event_kind_counts + .get(event_kind.as_str()) + .copied() + .unwrap_or_default(); + let next_count = current_count + 1; + session_event_kind_counts.insert(event_kind, next_count); + } + + for approval_request in &session.approval_requests { + let status = approval_request.status.clone(); + let current_count = approval_status_counts + .get(status.as_str()) + .copied() + .unwrap_or_default(); + let next_count = current_count + 1; + approval_status_counts.insert(status, next_count); + } + } + + RuntimeTrajectoryStatistics { + session_count, + turn_count, + terminal_outcome_count, + session_event_count, + approval_request_count, + canonical_kind_counts, + conversation_event_name_counts, + session_event_kind_counts, + approval_status_counts, + tool_intent_status_counts, + } +} + +fn record_conversation_event_counts( + turn: &RuntimeTrajectoryTurnRecord, + conversation_event_name_counts: &mut BTreeMap, + tool_intent_status_counts: &mut BTreeMap, +) { + if turn.canonical_record.kind != "conversation_event" { + return; + } + + let metadata = &turn.canonical_record.metadata; + let Some(event_name) = metadata.get("event").and_then(Value::as_str) else { + return; + }; + + let event_name = event_name.to_owned(); + let current_event_count = conversation_event_name_counts + .get(event_name.as_str()) + .copied() + .unwrap_or_default(); + let next_event_count = current_event_count + 1; + conversation_event_name_counts.insert(event_name, next_event_count); + + let Some(payload) = metadata.get("payload").and_then(Value::as_object) else { + return; + }; + let Some(intent_outcomes) = payload.get("intent_outcomes").and_then(Value::as_array) else { + return; + }; + + for intent_outcome in intent_outcomes { + let Some(status) = intent_outcome.get("status").and_then(Value::as_str) else { + continue; + }; + let status = status.to_owned(); + let current_status_count = tool_intent_status_counts + .get(status.as_str()) + .copied() + .unwrap_or_default(); + let next_status_count = current_status_count + 1; + tool_intent_status_counts.insert(status, next_status_count); + } +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index d97e0c2b3..519b26523 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -100,6 +100,7 @@ mod provider_route_diagnostics; pub mod runtime_capability_cli; pub mod runtime_experiment_cli; pub mod runtime_restore_cli; +pub mod runtime_trajectory_cli; pub mod skills_cli; pub mod source_presentation; pub mod supervisor; @@ -538,6 +539,11 @@ pub enum Commands { #[command(subcommand)] command: runtime_capability_cli::RuntimeCapabilityCommands, }, + /// Export and inspect one unified runtime trajectory artifact from local session state + RuntimeTrajectory { + #[command(subcommand)] + command: runtime_trajectory_cli::RuntimeTrajectoryCommands, + }, /// List available conversation context engines and selected runtime engine ListContextEngines { #[arg(long)] diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 1c01f7931..251b78d48 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -286,6 +286,9 @@ async fn main() { Commands::RuntimeCapability { command } => { runtime_capability_cli::run_runtime_capability_cli(command) } + Commands::RuntimeTrajectory { command } => { + runtime_trajectory_cli::run_runtime_trajectory_cli(command) + } Commands::ListContextEngines { config, json } => { run_list_context_engines_cli(config.as_deref(), json) } diff --git a/crates/daemon/src/runtime_trajectory_cli.rs b/crates/daemon/src/runtime_trajectory_cli.rs new file mode 100644 index 000000000..f3894d845 --- /dev/null +++ b/crates/daemon/src/runtime_trajectory_cli.rs @@ -0,0 +1,250 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use clap::{Args, Subcommand}; +use loongclaw_spec::CliResult; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryApprovalRequest; +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryArtifactDocument; +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryArtifactSchema; +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryCanonicalRecord; +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryExportMode; +pub use loongclaw_app::session::trajectory::RuntimeTrajectorySession; +pub use loongclaw_app::session::trajectory::RuntimeTrajectorySessionEvent; +pub use loongclaw_app::session::trajectory::RuntimeTrajectorySessionSummary; +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryStatistics; +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryTerminalOutcome; +pub use loongclaw_app::session::trajectory::RuntimeTrajectoryTurnRecord; + +#[derive(Subcommand, Debug, Clone, PartialEq, Eq)] +pub enum RuntimeTrajectoryCommands { + /// Export one persisted session trajectory artifact from local runtime state + Export(RuntimeTrajectoryExportCommandOptions), + /// Load and render one persisted runtime trajectory artifact + Show(RuntimeTrajectoryShowCommandOptions), +} + +#[derive(Args, Debug, Clone, PartialEq, Eq)] +pub struct RuntimeTrajectoryExportCommandOptions { + #[arg(long)] + pub config: Option, + #[arg(long)] + pub session: String, + #[arg(long)] + pub output: Option, + #[arg(long, default_value_t = false)] + pub lineage: bool, + #[arg(long, default_value_t = false)] + pub json: bool, +} + +#[derive(Args, Debug, Clone, PartialEq, Eq)] +pub struct RuntimeTrajectoryShowCommandOptions { + #[arg(long)] + pub artifact: String, + #[arg(long, default_value_t = false)] + pub json: bool, +} + +pub fn run_runtime_trajectory_cli(command: RuntimeTrajectoryCommands) -> CliResult<()> { + match command { + RuntimeTrajectoryCommands::Export(options) => { + let as_json = options.json; + let artifact = execute_runtime_trajectory_export_command(options)?; + emit_runtime_trajectory_artifact(&artifact, as_json) + } + RuntimeTrajectoryCommands::Show(options) => { + let as_json = options.json; + let artifact = execute_runtime_trajectory_show_command(options)?; + emit_runtime_trajectory_artifact(&artifact, as_json) + } + } +} + +pub fn execute_runtime_trajectory_export_command( + options: RuntimeTrajectoryExportCommandOptions, +) -> CliResult { + let session_id = normalized_required_session_id(options.session.as_str())?; + let export_mode = export_mode_from_flag(options.lineage); + let (_, config) = crate::mvp::config::load(options.config.as_deref())?; + let memory_config = + crate::mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let exported_at = now_rfc3339()?; + let artifact = crate::mvp::session::trajectory::export_runtime_trajectory( + session_id.as_str(), + export_mode, + &memory_config, + exported_at.as_str(), + )?; + + if let Some(output) = options.output.as_deref() { + persist_runtime_trajectory_artifact(output, &artifact)?; + } + + Ok(artifact) +} + +pub fn execute_runtime_trajectory_show_command( + options: RuntimeTrajectoryShowCommandOptions, +) -> CliResult { + let artifact_path = Path::new(&options.artifact); + load_runtime_trajectory_artifact(artifact_path) +} + +fn emit_runtime_trajectory_artifact( + artifact: &RuntimeTrajectoryArtifactDocument, + as_json: bool, +) -> CliResult<()> { + if as_json { + let pretty = serde_json::to_string_pretty(artifact) + .map_err(|error| format!("serialize runtime trajectory artifact failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + let rendered = render_runtime_trajectory_text(artifact); + println!("{rendered}"); + Ok(()) +} + +fn normalized_required_session_id(raw: &str) -> CliResult { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("runtime-trajectory export requires --session".to_owned()); + } + Ok(trimmed.to_owned()) +} + +fn export_mode_from_flag(lineage: bool) -> RuntimeTrajectoryExportMode { + if lineage { + return RuntimeTrajectoryExportMode::Lineage; + } + + RuntimeTrajectoryExportMode::SessionOnly +} + +fn now_rfc3339() -> CliResult { + let timestamp = OffsetDateTime::now_utc(); + let formatted = timestamp + .format(&Rfc3339) + .map_err(|error| format!("format trajectory export timestamp failed: {error}"))?; + Ok(formatted) +} + +fn persist_runtime_trajectory_artifact( + output: &str, + artifact: &RuntimeTrajectoryArtifactDocument, +) -> CliResult<()> { + let output_path = Path::new(output); + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "create runtime trajectory artifact directory {} failed: {error}", + parent.display() + ) + })?; + } + + let pretty = serde_json::to_string_pretty(artifact) + .map_err(|error| format!("serialize runtime trajectory artifact failed: {error}"))?; + fs::write(output_path, pretty).map_err(|error| { + format!( + "write runtime trajectory artifact {} failed: {error}", + output_path.display() + ) + })?; + Ok(()) +} + +fn load_runtime_trajectory_artifact(path: &Path) -> CliResult { + let raw = fs::read_to_string(path).map_err(|error| { + format!( + "read runtime trajectory artifact {} failed: {error}", + path.display() + ) + })?; + let artifact = + serde_json::from_str::(&raw).map_err(|error| { + format!( + "decode runtime trajectory artifact {} failed: {error}", + path.display() + ) + })?; + Ok(artifact) +} + +pub fn render_runtime_trajectory_text(artifact: &RuntimeTrajectoryArtifactDocument) -> String { + let mut lines = Vec::new(); + let stats = &artifact.statistics; + let kind_rollup = format_equals_rollup(&stats.canonical_kind_counts); + let conversation_event_rollup = format_equals_rollup(&stats.conversation_event_name_counts); + let tool_intent_rollup = format_equals_rollup(&stats.tool_intent_status_counts); + + let header = format!( + "runtime trajectory export requested_session_id={} root_session_id={} export_mode={} exported_at={}", + artifact.requested_session_id, + artifact.root_session_id, + artifact.export_mode.as_str(), + artifact.exported_at, + ); + lines.push(header); + + let summary = format!( + "sessions={} turns={} terminal_outcomes={} session_events={} approval_requests={}", + stats.session_count, + stats.turn_count, + stats.terminal_outcome_count, + stats.session_event_count, + stats.approval_request_count, + ); + lines.push(summary); + + let kind_counts = format!("canonical_kind_counts={kind_rollup}"); + lines.push(kind_counts); + let conversation_event_counts = + format!("conversation_event_name_counts={conversation_event_rollup}"); + lines.push(conversation_event_counts); + let tool_intent_counts = format!("tool_intent_status_counts={tool_intent_rollup}"); + lines.push(tool_intent_counts); + + for session in &artifact.sessions { + let terminal_state = terminal_state_label(session.terminal_outcome.is_some()); + let session_line = format!( + "- session_id={} kind={} state={} depth={} turns={} events={} approvals={} terminal_outcome={}", + session.summary.session_id, + session.summary.kind, + session.summary.state, + session.lineage_depth, + session.turns.len(), + session.session_events.len(), + session.approval_requests.len(), + terminal_state, + ); + lines.push(session_line); + } + + lines.join("\n") +} + +fn terminal_state_label(has_terminal_outcome: bool) -> &'static str { + if has_terminal_outcome { + "present" + } else { + "absent" + } +} + +fn format_equals_rollup(entries: &BTreeMap) -> String { + if entries.is_empty() { + return "-".to_owned(); + } + + let mut parts = Vec::with_capacity(entries.len()); + for (key, value) in entries { + let part = format!("{key}={value}"); + parts.push(part); + } + parts.join(",") +} diff --git a/crates/daemon/tests/integration/cli_tests.rs b/crates/daemon/tests/integration/cli_tests.rs index 49372451d..5bb76eec0 100644 --- a/crates/daemon/tests/integration/cli_tests.rs +++ b/crates/daemon/tests/integration/cli_tests.rs @@ -167,6 +167,83 @@ fn safe_lane_summary_cli_rejects_zero_limit() { assert!(error.contains(">= 1")); } +#[test] +fn runtime_trajectory_export_help_mentions_export_and_lineage() { + let help = render_cli_help(["runtime-trajectory", "export"]); + + assert!( + help.contains("trajectory"), + "runtime-trajectory export help should mention trajectory export: {help}" + ); + assert!( + help.contains("--session "), + "runtime-trajectory export help should require a session id: {help}" + ); + assert!( + help.contains("--lineage"), + "runtime-trajectory export help should explain lineage export: {help}" + ); +} + +#[test] +fn runtime_trajectory_cli_parses_export_flags() { + let cli = try_parse_cli([ + "loongclaw", + "runtime-trajectory", + "export", + "--config", + "/tmp/loongclaw.toml", + "--session", + "root-session", + "--lineage", + "--output", + "/tmp/runtime-trajectory.json", + "--json", + ]) + .expect("`runtime-trajectory export` should parse"); + + match cli.command { + Some(Commands::RuntimeTrajectory { + command: + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryCommands::Export(options), + }) => { + assert_eq!(options.config.as_deref(), Some("/tmp/loongclaw.toml")); + assert_eq!(options.session, "root-session"); + assert!(options.lineage); + assert_eq!( + options.output.as_deref(), + Some("/tmp/runtime-trajectory.json") + ); + assert!(options.json); + } + other => panic!("unexpected command parsed: {other:?}"), + } +} + +#[test] +fn runtime_trajectory_cli_parses_show_flags() { + let cli = try_parse_cli([ + "loongclaw", + "runtime-trajectory", + "show", + "--artifact", + "/tmp/runtime-trajectory.json", + "--json", + ]) + .expect("`runtime-trajectory show` should parse"); + + match cli.command { + Some(Commands::RuntimeTrajectory { + command: + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryCommands::Show(options), + }) => { + assert_eq!(options.artifact, "/tmp/runtime-trajectory.json"); + assert!(options.json); + } + other => panic!("unexpected command parsed: {other:?}"), + } +} + #[test] fn onboard_cli_accepts_generic_api_key_flag() { let cli = try_parse_cli([ diff --git a/crates/daemon/tests/integration/mod.rs b/crates/daemon/tests/integration/mod.rs index e6e5026e4..e9d673e12 100644 --- a/crates/daemon/tests/integration/mod.rs +++ b/crates/daemon/tests/integration/mod.rs @@ -97,6 +97,7 @@ mod runtime_capability_cli; mod runtime_experiment_cli; mod runtime_restore_cli; mod runtime_snapshot_cli; +mod runtime_trajectory_cli; mod skills_cli; mod spec_runtime; mod spec_runtime_bridge; diff --git a/crates/daemon/tests/integration/runtime_trajectory_cli.rs b/crates/daemon/tests/integration/runtime_trajectory_cli.rs new file mode 100644 index 000000000..c657c9a0c --- /dev/null +++ b/crates/daemon/tests/integration/runtime_trajectory_cli.rs @@ -0,0 +1,359 @@ +#![allow( + clippy::disallowed_methods, + clippy::multiple_unsafe_ops_per_block, + clippy::undocumented_unsafe_blocks +)] + +use super::*; +use serde_json::{Value, json}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let temp_dir = std::env::temp_dir(); + let canonical_temp_dir = dunce::canonicalize(&temp_dir).unwrap_or(temp_dir); + canonical_temp_dir.join(format!("{prefix}-{nanos}")) +} + +fn write_runtime_trajectory_config(root: &Path) -> PathBuf { + fs::create_dir_all(root).expect("create fixture root"); + + let mut config = mvp::config::LoongClawConfig::default(); + let sqlite_path = root.join("memory.sqlite3"); + let sqlite_path_text = sqlite_path.display().to_string(); + config.memory.sqlite_path = sqlite_path_text; + + let config_path = root.join("loongclaw.toml"); + let config_path_text = config_path.to_string_lossy().to_string(); + mvp::config::write(Some(config_path_text.as_str()), &config, true) + .expect("write config fixture"); + config_path +} + +fn load_memory_runtime_config( + config_path: &Path, +) -> mvp::memory::runtime_config::MemoryRuntimeConfig { + let config_path_text = config_path + .to_str() + .expect("config path should be valid utf-8"); + let (_, config) = mvp::config::load(Some(config_path_text)).expect("load config fixture"); + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory) +} + +fn append_structured_conversation_event_turn( + session_id: &str, + event_name: &str, + payload: Value, + memory_config: &mvp::memory::runtime_config::MemoryRuntimeConfig, +) { + let content = json!({ + "_loongclaw_internal": true, + "type": "conversation_event", + "event": event_name, + "payload": payload, + }) + .to_string(); + mvp::memory::append_turn_direct(session_id, "assistant", &content, memory_config) + .expect("append structured conversation event turn"); +} + +#[test] +fn runtime_trajectory_export_session_only_keeps_requested_session_and_true_root_metadata() { + let root = unique_temp_dir("runtime-trajectory-session-only"); + let config_path = write_runtime_trajectory_config(&root); + let memory_config = load_memory_runtime_config(&config_path); + let repo = mvp::session::repository::SessionRepository::new(&memory_config).expect("repo"); + + repo.ensure_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Ready, + }) + .expect("ensure root session"); + repo.ensure_session(mvp::session::repository::NewSessionRecord { + session_id: "child-session".to_owned(), + kind: mvp::session::repository::SessionKind::DelegateChild, + parent_session_id: Some("root-session".to_owned()), + label: Some("Child".to_owned()), + state: mvp::session::repository::SessionState::Running, + }) + .expect("ensure child session"); + mvp::memory::append_turn_direct("root-session", "user", "root turn", &memory_config) + .expect("append root turn"); + mvp::memory::append_turn_direct("child-session", "assistant", "child turn", &memory_config) + .expect("append child turn"); + + let artifact = + loongclaw_daemon::runtime_trajectory_cli::execute_runtime_trajectory_export_command( + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryExportCommandOptions { + config: Some(config_path.display().to_string()), + session: "child-session".to_owned(), + output: None, + lineage: false, + json: false, + }, + ) + .expect("session-only export should succeed"); + + assert_eq!(artifact.requested_session_id, "child-session"); + assert_eq!(artifact.root_session_id, "root-session"); + assert_eq!( + artifact.export_mode, + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryExportMode::SessionOnly + ); + assert_eq!(artifact.sessions.len(), 1); + assert_eq!(artifact.sessions[0].summary.session_id, "child-session"); +} + +#[test] +fn runtime_trajectory_export_lineage_includes_events_terminal_outcomes_and_approval_requests() { + let root = unique_temp_dir("runtime-trajectory-lineage"); + let config_path = write_runtime_trajectory_config(&root); + let memory_config = load_memory_runtime_config(&config_path); + let repo = mvp::session::repository::SessionRepository::new(&memory_config).expect("repo"); + + repo.ensure_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Ready, + }) + .expect("ensure root session"); + repo.ensure_session(mvp::session::repository::NewSessionRecord { + session_id: "child-session".to_owned(), + kind: mvp::session::repository::SessionKind::DelegateChild, + parent_session_id: Some("root-session".to_owned()), + label: Some("Child".to_owned()), + state: mvp::session::repository::SessionState::Completed, + }) + .expect("ensure child session"); + + mvp::memory::append_turn_direct("root-session", "user", "root turn", &memory_config) + .expect("append root turn"); + append_structured_conversation_event_turn( + "root-session", + "delegate_completed", + json!({ + "child_session_id": "child-session", + }), + &memory_config, + ); + append_structured_conversation_event_turn( + "root-session", + "fast_lane_tool_batch", + json!({ + "intent_outcomes": [ + { + "tool_call_id": "call-1", + "tool_name": "delegate_async", + "status": "needs_approval", + "detail": "approval required" + }, + { + "tool_call_id": "call-2", + "tool_name": "file.read", + "status": "completed", + "detail": null + } + ] + }), + &memory_config, + ); + mvp::memory::append_turn_direct("child-session", "assistant", "child turn", &memory_config) + .expect("append child turn"); + repo.append_event(mvp::session::repository::NewSessionEvent { + session_id: "root-session".to_owned(), + event_kind: "delegate_started".to_owned(), + actor_session_id: Some("root-session".to_owned()), + payload_json: json!({ + "child_session_id": "child-session", + }), + }) + .expect("append session event"); + repo.upsert_session_terminal_outcome("child-session", "completed", json!({"ok": true})) + .expect("store terminal outcome"); + repo.ensure_approval_request(mvp::session::repository::NewApprovalRequestRecord { + approval_request_id: "apr-runtime-trajectory".to_owned(), + session_id: "child-session".to_owned(), + turn_id: "turn-1".to_owned(), + tool_call_id: "call-1".to_owned(), + tool_name: "delegate_async".to_owned(), + approval_key: "tool:delegate_async".to_owned(), + request_payload_json: json!({ + "tool_name": "delegate_async", + "args_json": { + "task": "research" + }, + }), + governance_snapshot_json: json!({ + "reason": "governed_tool_requires_approval", + }), + }) + .expect("store approval request"); + + let artifact = + loongclaw_daemon::runtime_trajectory_cli::execute_runtime_trajectory_export_command( + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryExportCommandOptions { + config: Some(config_path.display().to_string()), + session: "child-session".to_owned(), + output: None, + lineage: true, + json: false, + }, + ) + .expect("lineage export should succeed"); + + assert_eq!(artifact.root_session_id, "root-session"); + assert_eq!(artifact.sessions.len(), 2); + assert_eq!(artifact.sessions[0].summary.session_id, "root-session"); + assert_eq!(artifact.sessions[1].summary.session_id, "child-session"); + assert_eq!(artifact.statistics.session_count, 2); + assert_eq!(artifact.statistics.turn_count, 4); + assert_eq!(artifact.statistics.session_event_count, 1); + assert_eq!(artifact.statistics.approval_request_count, 1); + assert_eq!( + artifact.statistics.canonical_kind_counts["conversation_event"], + 2 + ); + assert_eq!( + artifact.statistics.conversation_event_name_counts["delegate_completed"], + 1 + ); + assert_eq!( + artifact.statistics.conversation_event_name_counts["fast_lane_tool_batch"], + 1 + ); + assert_eq!( + artifact.statistics.tool_intent_status_counts["completed"], + 1 + ); + assert_eq!( + artifact.statistics.tool_intent_status_counts["needs_approval"], + 1 + ); + assert!(artifact.sessions[1].terminal_outcome.is_some()); + assert_eq!(artifact.sessions[1].approval_requests.len(), 1); +} + +#[test] +fn runtime_trajectory_show_round_trips_exported_artifact() { + let root = unique_temp_dir("runtime-trajectory-show"); + let config_path = write_runtime_trajectory_config(&root); + let memory_config = load_memory_runtime_config(&config_path); + let repo = mvp::session::repository::SessionRepository::new(&memory_config).expect("repo"); + + repo.ensure_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Ready, + }) + .expect("ensure root session"); + mvp::memory::append_turn_direct("root-session", "user", "root turn", &memory_config) + .expect("append root turn"); + + let artifact_path = root.join("artifacts/runtime-trajectory.json"); + let exported = + loongclaw_daemon::runtime_trajectory_cli::execute_runtime_trajectory_export_command( + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryExportCommandOptions { + config: Some(config_path.display().to_string()), + session: "root-session".to_owned(), + output: Some(artifact_path.display().to_string()), + lineage: false, + json: false, + }, + ) + .expect("export artifact"); + let shown = loongclaw_daemon::runtime_trajectory_cli::execute_runtime_trajectory_show_command( + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryShowCommandOptions { + artifact: artifact_path.display().to_string(), + json: false, + }, + ) + .expect("show artifact"); + + assert_eq!(shown.requested_session_id, "root-session"); + assert_eq!(shown.statistics.turn_count, exported.statistics.turn_count); + assert_eq!( + shown.statistics.tool_intent_status_counts, + exported.statistics.tool_intent_status_counts + ); + assert_eq!(shown.sessions[0].summary.session_id, "root-session"); +} + +#[test] +fn runtime_trajectory_render_text_surfaces_rollups() { + let root = unique_temp_dir("runtime-trajectory-render"); + let config_path = write_runtime_trajectory_config(&root); + let memory_config = load_memory_runtime_config(&config_path); + let repo = mvp::session::repository::SessionRepository::new(&memory_config).expect("repo"); + + repo.ensure_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Ready, + }) + .expect("ensure root session"); + mvp::memory::append_turn_direct("root-session", "user", "root turn", &memory_config) + .expect("append root turn"); + append_structured_conversation_event_turn( + "root-session", + "fast_lane_tool_batch", + json!({ + "intent_outcomes": [ + { + "tool_call_id": "call-1", + "tool_name": "delegate_async", + "status": "needs_approval", + "detail": "approval required" + } + ] + }), + &memory_config, + ); + + let artifact = + loongclaw_daemon::runtime_trajectory_cli::execute_runtime_trajectory_export_command( + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryExportCommandOptions { + config: Some(config_path.display().to_string()), + session: "root-session".to_owned(), + output: None, + lineage: false, + json: false, + }, + ) + .expect("render export should succeed"); + + let rendered = + loongclaw_daemon::runtime_trajectory_cli::render_runtime_trajectory_text(&artifact); + + assert!( + rendered.contains("runtime trajectory export requested_session_id=root-session"), + "rendered text should start with the export headline: {rendered}" + ); + assert!( + rendered.contains("canonical_kind_counts=conversation_event=1,user_turn=1"), + "rendered text should include canonical kind rollups: {rendered}" + ); + assert!( + rendered.contains("conversation_event_name_counts=fast_lane_tool_batch=1"), + "rendered text should include conversation event rollups: {rendered}" + ); + assert!( + rendered.contains("tool_intent_status_counts=needs_approval=1"), + "rendered text should include tool intent status rollups: {rendered}" + ); +} diff --git a/docs/product-specs/index.md b/docs/product-specs/index.md index 59928ac06..2bc38fa64 100644 --- a/docs/product-specs/index.md +++ b/docs/product-specs/index.md @@ -17,6 +17,7 @@ Product specs describe **what** the product does from the user's perspective, no - [Channel Setup](channel-setup.md) - [Tool Surface](tool-surface.md) - [Runtime Experiment](runtime-experiment.md) +- [Runtime Trajectory](runtime-trajectory.md) - [Runtime Capability](runtime-capability.md) - [Web UI](web-ui.md) - [Prompt And Personality](prompt-and-personality.md) @@ -27,6 +28,7 @@ Product specs describe **what** the product does from the user's perspective, no - `Installation`, `Onboarding`, `One-Shot Ask`, `Doctor`, `Browser Automation`, `Tool Surface`, and `Channel Setup` define the shipped first-run and support journey for the current MVP. - `Runtime Experiment` defines the shipped local experiment-record surface layered on top of runtime snapshot and restore artifacts. +- `Runtime Trajectory` defines the shipped local trajectory-export surface layered on top of persisted session, event, and approval state. - `Runtime Capability` defines the shipped local capability-candidate review surface layered on top of runtime experiment artifacts. - `Browser Automation Companion` and `Web UI` are expectation-setting specs for the next user-facing surfaces. They should not be documented as generally available before the implementation exists. diff --git a/docs/product-specs/runtime-trajectory.md b/docs/product-specs/runtime-trajectory.md new file mode 100644 index 000000000..b7a989c77 --- /dev/null +++ b/docs/product-specs/runtime-trajectory.md @@ -0,0 +1,34 @@ +# Runtime Trajectory + +## User Story + +As a LoongClaw operator, I want to export one persisted session or lineage +trajectory into a stable artifact so that I can replay runtime behavior, +inspect delegate subtrees, and feed governed evaluation or learning workflows. + +## Acceptance Criteria + +- [ ] LoongClaw exposes a `runtime-trajectory` command family with `export` and + `show` subcommands. +- [ ] `runtime-trajectory export` can export one selected session without + mutating runtime state. +- [ ] `runtime-trajectory export --lineage` can export the selected session's + lineage-root tree, including delegate descendants that are visible from + that root session. +- [ ] Exported artifacts include persisted turns, canonicalized turn records, + session events, approval requests, terminal outcomes, and aggregate + counts. +- [ ] Exported artifacts record both the requested session id and the resolved + root session id even when only one selected session is exported. +- [ ] `runtime-trajectory show` round-trips a persisted artifact as JSON and + renders an operator-oriented text summary when JSON output is not + requested. +- [ ] Product docs describe `runtime-trajectory` as a read-only export layer + for replay, evaluation, and future governed learning loops rather than an + automatic optimizer or mutation surface. + +## Out of Scope + +- Automatic promotion or training triggered from trajectory export +- Background export daemons or continuous recording beyond existing persistence +- Rewriting session persistence schemas as part of the first export slice From b1fe5d2017c4f965ce1b884c60051886f8443c6d Mon Sep 17 00:00:00 2001 From: Chum Yin Date: Wed, 8 Apr 2026 13:19:56 +0800 Subject: [PATCH 3/4] Make verification deterministic across env-sensitive channel and migrate flows The runtime trajectory slice was ready, but full-workspace verification still wasn't trustworthy because two tests were sensitive to machine-local state: Discord channel readiness could silently flip when DISCORD_BOT_TOKEN was set, and migrate CLI UX assertions could be pre-empted by parsing an unrelated existing config before required flag validation ran. This follow-up makes the Discord registry assertion hermetic with the existing ScopedEnv guard and moves migrate required-flag validation ahead of config loading. It also adds regression coverage for missing input/output behavior across apply, discover, and rollback modes so future UX regressions fail before runtime/tool execution begins. Constraint: Preserve existing runtime behavior and fix only the test/CLI ordering seams that made verification nondeterministic Rejected: Change Discord channel readiness semantics to ignore configured env defaults | would break legitimate config-backed readiness and hide real operator state Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep CLI-required-flag validation ahead of config loading for UX-critical migrate modes so local config drift cannot mask argument errors Tested: cargo fmt --all -- --check; cargo clippy --workspace --all-targets --all-features --locked -- -D warnings; cargo test -p loongclaw-app channel::registry::tests::discord_status_splits_config_backed_send_and_stub_serve --locked -- --test-threads=1; cargo test -p loongclaw-daemon integration::migrate_cli::migrate_cli_ux_apply_mode_reports_flag_level_output_requirement --locked -- --exact --test-threads=1; cargo test --workspace --locked; cargo test --workspace --all-features --locked Not-tested: none --- crates/app/src/channel/registry.rs | 3 ++ crates/daemon/src/migrate_cli.rs | 33 +++++++++++++ .../daemon/tests/integration/migrate_cli.rs | 48 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/crates/app/src/channel/registry.rs b/crates/app/src/channel/registry.rs index 6cafda652..01ea6aab6 100644 --- a/crates/app/src/channel/registry.rs +++ b/crates/app/src/channel/registry.rs @@ -9432,6 +9432,9 @@ mod tests { #[test] fn discord_status_splits_config_backed_send_and_stub_serve() { + let mut env = crate::test_support::ScopedEnv::new(); + env.remove(crate::config::DISCORD_BOT_TOKEN_ENV); + let mut config = LoongClawConfig::default(); config.discord.enabled = true; diff --git a/crates/daemon/src/migrate_cli.rs b/crates/daemon/src/migrate_cli.rs index 66210e8e3..58c422469 100644 --- a/crates/daemon/src/migrate_cli.rs +++ b/crates/daemon/src/migrate_cli.rs @@ -62,6 +62,7 @@ pub fn run_migrate_cli(options: MigrateCommandOptions) -> CliResult<()> { } async fn run_migrate_cli_async(options: MigrateCommandOptions) -> CliResult<()> { + validate_migrate_cli_required_flags(&options)?; let config = load_migrate_cli_runtime_config(&options)?; let kernel_ctx = mvp::context::bootstrap_kernel_context_with_config( "daemon-migrate-cli", @@ -81,6 +82,38 @@ async fn run_migrate_cli_async(options: MigrateCommandOptions) -> CliResult<()> render_migrate_tool_outcome(&options, outcome) } +fn validate_migrate_cli_required_flags(options: &MigrateCommandOptions) -> CliResult<()> { + let mode_id = options.mode.as_id(); + let requires_output = matches!( + options.mode, + MigrateMode::Apply | MigrateMode::ApplySelected | MigrateMode::RollbackLastApply + ); + let requires_input = !matches!(options.mode, MigrateMode::RollbackLastApply); + + let output_is_present = required_cli_flag_is_present(options.output.as_deref()); + let input_is_present = required_cli_flag_is_present(options.input.as_deref()); + + if requires_output && !output_is_present { + let error = format!("`--output` is required for `loongclaw migrate --mode {mode_id}`"); + return Err(error); + } + + if requires_input && !input_is_present { + let error = format!("`--input` is required for `loongclaw migrate --mode {mode_id}`"); + return Err(error); + } + + Ok(()) +} + +fn required_cli_flag_is_present(raw: Option<&str>) -> bool { + let Some(raw) = raw else { + return false; + }; + let normalized = raw.trim(); + !normalized.is_empty() +} + fn block_on_migrate_cli(future: F) -> CliResult<()> where F: Future>, diff --git a/crates/daemon/tests/integration/migrate_cli.rs b/crates/daemon/tests/integration/migrate_cli.rs index b5b9d78c2..c90eda0fc 100644 --- a/crates/daemon/tests/integration/migrate_cli.rs +++ b/crates/daemon/tests/integration/migrate_cli.rs @@ -370,6 +370,54 @@ fn migrate_cli_ux_apply_mode_reports_flag_level_output_requirement() { ); } +#[test] +fn migrate_cli_ux_rollback_mode_reports_flag_level_output_requirement() { + let error = loongclaw_daemon::migrate_cli::run_migrate_cli( + loongclaw_daemon::migrate_cli::MigrateCommandOptions { + input: None, + output: None, + source: None, + mode: loongclaw_daemon::migrate_cli::MigrateMode::RollbackLastApply, + json: false, + source_id: None, + safe_profile_merge: false, + primary_source_id: None, + apply_external_skills_plan: false, + force: false, + }, + ) + .expect_err("rollback mode without --output should fail"); + + assert_eq!( + error, + "`--output` is required for `loongclaw migrate --mode rollback_last_apply`" + ); +} + +#[test] +fn migrate_cli_ux_discover_mode_reports_flag_level_input_requirement() { + let error = loongclaw_daemon::migrate_cli::run_migrate_cli( + loongclaw_daemon::migrate_cli::MigrateCommandOptions { + input: None, + output: None, + source: None, + mode: loongclaw_daemon::migrate_cli::MigrateMode::Discover, + json: false, + source_id: None, + safe_profile_merge: false, + primary_source_id: None, + apply_external_skills_plan: false, + force: false, + }, + ) + .expect_err("discover mode without --input should fail"); + + assert_eq!( + error, + "`--input` is required for `loongclaw migrate --mode discover`" + ); +} + #[test] fn migrate_cli_ux_help_mentions_mode_specific_required_flags() { let output = std::process::Command::new(env!("CARGO_BIN_EXE_loongclaw")) From 6e22531b0ac31b05289078f279f66c41e7de6bb1 Mon Sep 17 00:00:00 2001 From: Chum Yin Date: Wed, 8 Apr 2026 13:41:35 +0800 Subject: [PATCH 4/4] Keep ACPX MCP-proxy verification deterministic under all-features The runtime trajectory work uncovered one more verification-only problem: ACPX MCP-proxy tests depended on ambient process environment stability while other tests were free to mutate environment variables under the shared test process. That made the all-features test pass non-deterministic even though the runtime code path itself was correct. This change serializes the affected ACPX tests behind the existing ScopedEnv lock so PATH- and runtime-probe-sensitive assertions do not race unrelated environment-mutating tests. Constraint: Preserve the production ACPX runtime behavior and only harden the test harness Rejected: Add more ad-hoc sleeps/retries inside the test body | would hide the shared-environment race instead of removing it Confidence: high Scope-risk: narrow Reversibility: clean Directive: Any ACPX/browser/runtime test that depends on process-global env or PATH should acquire the shared ScopedEnv lock before probing external commands Tested: cargo fmt --all -- --check; cargo clippy --workspace --all-targets --all-features --locked -- -D warnings; cargo test -p loongclaw-app acp::acpx::tests::runtime_backend_uses_agent_proxy_when_mcp_servers_requested --all-features --locked -- --exact --test-threads=1; cargo test --workspace --locked; cargo test --workspace --all-features --locked; ./scripts/check_architecture_boundaries.sh; ./scripts/check_dep_graph.sh Not-tested: No additional non-Unix ACPX coverage because the guarded tests are already `#[cfg(unix)]` --- crates/app/src/acp/acpx.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/app/src/acp/acpx.rs b/crates/app/src/acp/acpx.rs index a9846a2c5..750180491 100644 --- a/crates/app/src/acp/acpx.rs +++ b/crates/app/src/acp/acpx.rs @@ -1998,6 +1998,7 @@ mod tests { #[tokio::test] #[cfg(unix)] async fn doctor_accepts_fake_version_command() { + let _env = crate::test_support::ScopedEnv::new(); let temp_dir = unique_temp_dir("loongclaw-acpx-probe"); let script_path = temp_dir.join("fake-acpx"); write_executable_script_atomically(&script_path, "#!/bin/sh\necho 'acpx 0.1.16'\n") @@ -2069,6 +2070,7 @@ mod tests { #[tokio::test] #[cfg(unix)] async fn runtime_backend_uses_agent_proxy_when_mcp_servers_requested() { + let _env = crate::test_support::ScopedEnv::new(); let temp_dir = unique_temp_dir("loongclaw-acpx-mcp-proxy"); let log_path = temp_dir.join("calls.log"); let script_path = write_fake_acpx_script(