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/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( 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/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/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/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/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/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/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..519b26523 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; @@ -99,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; @@ -106,6 +108,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, @@ -536,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 cdba1a178..251b78d48 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, @@ -284,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) } @@ -846,6 +851,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/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/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"); + } +} 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/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")) 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