diff --git a/.gitignore b/.gitignore index f7509e3c5..ccb7d1f11 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /.docs /.loongclaw.local.toml /.env.local +/.tmp-web-*.log .worktrees/ .idea .vscode diff --git a/crates/app/src/chat/cli_input.rs b/crates/app/src/chat/cli_input.rs index 5b63edab3..7ef5be184 100644 --- a/crates/app/src/chat/cli_input.rs +++ b/crates/app/src/chat/cli_input.rs @@ -1,6 +1,7 @@ +#[cfg(unix)] use std::fs::OpenOptions; +#[cfg(unix)] use std::io::Read; - #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; @@ -11,6 +12,7 @@ use tokio::io::{self as tokio_io, AsyncBufReadExt, BufReader}; use crate::CliResult; +#[cfg(any(unix, test))] pub(super) fn extract_cli_input_line_from_buffer( buffer: &mut Vec, ) -> CliResult> { @@ -26,6 +28,7 @@ pub(super) fn extract_cli_input_line_from_buffer( Ok(Some(line)) } +#[cfg(any(unix, test))] pub(super) fn finalize_cli_input_buffer(buffer: &mut Vec) -> CliResult> { if buffer.is_empty() { return Ok(None); @@ -39,6 +42,7 @@ pub(super) fn finalize_cli_input_buffer(buffer: &mut Vec) -> CliResult) -> Vec { if bytes.last() == Some(&b'\n') { bytes.pop(); diff --git a/crates/app/src/config/mod.rs b/crates/app/src/config/mod.rs index 7dd9c45a8..f4c5d7d34 100644 --- a/crates/app/src/config/mod.rs +++ b/crates/app/src/config/mod.rs @@ -89,13 +89,14 @@ pub(crate) use provider::{ #[allow(unused_imports)] pub use provider::{ ModelCatalogProbeRecovery, PROVIDER_DESCRIPTOR_SCHEMA_VERSION, ProviderAuthScheme, - ProviderConfig, ProviderDescriptorAuth, ProviderDescriptorDocument, ProviderDescriptorFeature, - ProviderDescriptorHeader, ProviderDescriptorRegionEndpoint, ProviderDescriptorRegionVariant, - ProviderDescriptorSchema, ProviderFeatureFamily, ProviderKind, ProviderProfileConfig, - ProviderProfileHealthModeConfig, ProviderProfileStateBackendKind, ProviderProtocolFamily, - ProviderReasoningExtraBodyModeConfig, ProviderToolSchemaModeConfig, ProviderTransportFallback, - ProviderTransportPolicy, ProviderTransportReadiness, ProviderTransportReadinessLevel, - ProviderWireApi, ReasoningEffort, parse_provider_kind_id, + ProviderCatalogEntry, ProviderConfig, ProviderDescriptorAuth, ProviderDescriptorDocument, + ProviderDescriptorFeature, ProviderDescriptorHeader, ProviderDescriptorRegionEndpoint, + ProviderDescriptorRegionVariant, ProviderDescriptorSchema, ProviderFeatureFamily, ProviderKind, + ProviderProfileConfig, ProviderProfileHealthModeConfig, ProviderProfileStateBackendKind, + ProviderProtocolFamily, ProviderReasoningExtraBodyModeConfig, ProviderToolSchemaModeConfig, + ProviderTransportFallback, ProviderTransportPolicy, ProviderTransportReadiness, + ProviderTransportReadinessLevel, ProviderWireApi, ReasoningEffort, parse_provider_kind_id, + provider_catalog_entries, }; #[cfg(test)] pub(crate) use runtime::inject_test_config_write_failure; diff --git a/crates/app/src/config/provider.rs b/crates/app/src/config/provider.rs index 48458ebd8..b493f3e74 100644 --- a/crates/app/src/config/provider.rs +++ b/crates/app/src/config/provider.rs @@ -49,6 +49,21 @@ pub struct ProviderProfile { pub feature_family: ProviderFeatureFamily, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderCatalogEntry { + pub kind: String, + pub display_name: String, + pub default_base_url: String, + pub default_chat_path: String, + pub default_models_path: Option, + pub auth_scheme: String, + pub protocol_family: String, + pub feature_family: String, + pub is_coding_variant: bool, + pub aliases: Vec, + pub configuration_hint: Option, +} + impl ProviderProfile { pub fn alternative_auth_configuration_hint(self) -> Option<&'static str> { let kind = self.kind; @@ -2018,7 +2033,7 @@ impl ProviderConfig { || contains_template_placeholder(resolved_base_url.as_str()) } - pub fn configuration_hint(&self) -> Option { + pub fn kind_route_mismatch_hint(&self) -> Option { if self.kind == ProviderKind::Byteplus && self.uses_byteplus_coding_plan_path() { return Some( "byteplus uses the standard ModelArk path and should not target `/api/coding` or `/api/coding/v3`; switch to `kind = \"byteplus_coding\"` for the dedicated OpenAI-compatible Coding Plan endpoint" @@ -2049,6 +2064,14 @@ impl ProviderConfig { .to_owned(), ); } + + None + } + + pub fn configuration_hint(&self) -> Option { + if let Some(hint) = self.kind_route_mismatch_hint() { + return Some(hint); + } if let Some(hint) = self.opencode_configuration_hint() { return Some(hint); } @@ -2569,6 +2592,28 @@ fn is_provider_managed_oauth_access_token_env_name(env_name: &str) -> bool { }) } +fn default_models_path_for_kind(kind: ProviderKind) -> Option { + let profile = kind.profile(); + if let Some(path) = profile.models_path { + return Some(path.to_owned()); + } + + let provider = ProviderConfig::fresh_for_kind(kind); + let base_url = provider.resolved_base_url(); + let models_endpoint = provider.models_endpoint(); + models_endpoint + .strip_prefix(base_url.as_str()) + .map(str::to_owned) +} + +pub fn provider_catalog_entries() -> Vec { + ProviderKind::all_sorted() + .iter() + .copied() + .map(ProviderKind::catalog_entry) + .collect() +} + fn maybe_normalize_custom_chat_path(kind: ProviderKind, base_url: &str, path: &str) -> String { let normalized = normalize_api_path(path); if kind != ProviderKind::Custom { @@ -2771,6 +2816,37 @@ impl ProviderKind { self.profile().feature_family } + pub const fn is_coding_variant(self) -> bool { + matches!( + self, + ProviderKind::BailianCoding + | ProviderKind::ByteplusCoding + | ProviderKind::KimiCoding + | ProviderKind::VolcengineCoding + ) + } + + pub fn catalog_entry(self) -> ProviderCatalogEntry { + let profile = self.profile(); + ProviderCatalogEntry { + kind: self.as_str().to_owned(), + display_name: self.display_name().to_owned(), + default_base_url: profile.base_url.to_owned(), + default_chat_path: profile.chat_completions_path.to_owned(), + default_models_path: default_models_path_for_kind(self), + auth_scheme: self.auth_scheme().as_str().to_owned(), + protocol_family: self.protocol_family().as_str().to_owned(), + feature_family: self.feature_family().as_str().to_owned(), + is_coding_variant: self.is_coding_variant(), + aliases: profile + .aliases + .iter() + .map(|alias| (*alias).to_owned()) + .collect(), + configuration_hint: self.configuration_hint().map(str::to_owned), + } + } + pub fn default_headers(self) -> &'static [(&'static str, &'static str)] { self.profile().default_headers } diff --git a/crates/app/src/context.rs b/crates/app/src/context.rs index 32b798365..08b76ee65 100644 --- a/crates/app/src/context.rs +++ b/crates/app/src/context.rs @@ -38,11 +38,25 @@ impl KernelContext { } } -/// Bootstrap a minimal in-memory kernel suitable for tests. +/// Bootstrap a minimal in-memory kernel with ephemeral audit retention. +/// +/// This is a convenience entry point for MVP and development flows where +/// durable audit storage is not required. It registers a default pack manifest +/// with `InvokeTool`, `MemoryRead`, and `MemoryWrite` capabilities, then +/// issues a long-lived token for the given `agent_id`. /// -/// Registers a default pack manifest with the MVP tool, memory, filesystem, -/// and public-web capabilities, then issues a long-lived token for the given -/// `agent_id`. +/// Production-facing runtime entrypoints should prefer +/// [`bootstrap_kernel_context_with_config`] so audit retention follows config. +pub fn bootstrap_kernel_context(agent_id: &str, ttl_s: u64) -> Result { + bootstrap_kernel_context_with_audit_sink( + agent_id, + ttl_s, + Arc::new(InMemoryAuditSink::default()) as Arc, + &LoongClawConfig::default(), + ) +} + +/// Bootstrap a minimal in-memory kernel suitable for tests. /// /// Production-facing runtime entrypoints should prefer /// `bootstrap_kernel_context_with_config` so audit retention follows config. diff --git a/crates/app/src/conversation/runtime.rs b/crates/app/src/conversation/runtime.rs index 7b0e47d13..d539cea42 100644 --- a/crates/app/src/conversation/runtime.rs +++ b/crates/app/src/conversation/runtime.rs @@ -1380,6 +1380,21 @@ pub trait ConversationRuntime: Send + Sync { binding: ConversationRuntimeBinding<'_>, ) -> CliResult; + async fn request_turn_with_event_sink( + &self, + config: &LoongClawConfig, + session_id: &str, + turn_id: &str, + messages: &[Value], + tool_view: &ToolView, + event_sink: Option<&dyn crate::acp::AcpTurnEventSink>, + binding: ConversationRuntimeBinding<'_>, + ) -> CliResult { + let _ = event_sink; + self.request_turn(config, session_id, turn_id, messages, tool_view, binding) + .await + } + async fn request_turn_streaming( &self, config: &LoongClawConfig, @@ -1592,6 +1607,22 @@ where messages: &[Value], tool_view: &ToolView, binding: ConversationRuntimeBinding<'_>, + ) -> CliResult { + self.request_turn_with_event_sink( + config, session_id, turn_id, messages, tool_view, None, binding, + ) + .await + } + + async fn request_turn_with_event_sink( + &self, + config: &LoongClawConfig, + session_id: &str, + turn_id: &str, + messages: &[Value], + tool_view: &ToolView, + event_sink: Option<&dyn crate::acp::AcpTurnEventSink>, + binding: ConversationRuntimeBinding<'_>, ) -> CliResult { provider::request_turn_in_view( config, @@ -1599,6 +1630,7 @@ where turn_id, messages, tool_view, + event_sink, provider_runtime_binding(binding), ) .await diff --git a/crates/app/src/conversation/turn_coordinator.rs b/crates/app/src/conversation/turn_coordinator.rs index 56a0b201c..441e05965 100644 --- a/crates/app/src/conversation/turn_coordinator.rs +++ b/crates/app/src/conversation/turn_coordinator.rs @@ -791,6 +791,7 @@ impl ProviderTurnContinuePhase { turn_loop_policy: &ProviderTurnLoopPolicy, turn_loop_state: &mut ProviderTurnLoopState, remaining_provider_rounds: usize, + acp_event_sink: Option<&dyn AcpTurnEventSink>, binding: ConversationRuntimeBinding<'_>, observer: Option<&ConversationTurnObserverHandle>, ) -> ResolvedProviderTurn { @@ -804,6 +805,7 @@ impl ProviderTurnContinuePhase { turn_loop_policy, turn_loop_state, remaining_provider_rounds, + acp_event_sink, binding, self.ingress.as_ref(), observer, @@ -2024,6 +2026,7 @@ impl ConversationTurnCoordinator { preparation.turn_id.as_str(), &preparation.session.messages, &tool_view, + acp_options.event_sink, binding, observer.as_ref(), ) @@ -2036,6 +2039,7 @@ impl ConversationTurnCoordinator { &preparation, provider_turn_result, error_mode, + acp_options.event_sink, binding, ingress, observer.as_ref(), @@ -2161,6 +2165,7 @@ impl ConversationTurnCoordinator { &preparation, Ok(approval_turn), error_mode, + None, binding, None, observer, @@ -2824,6 +2829,7 @@ async fn request_provider_turn_with_observer( turn_id: &str, messages: &[Value], tool_view: &crate::tools::ToolView, + acp_event_sink: Option<&dyn AcpTurnEventSink>, binding: ConversationRuntimeBinding<'_>, observer: Option<&ConversationTurnObserverHandle>, ) -> CliResult { @@ -2839,7 +2845,15 @@ async fn request_provider_turn_with_observer( } runtime - .request_turn(config, session_id, turn_id, messages, tool_view, binding) + .request_turn_with_event_sink( + config, + session_id, + turn_id, + messages, + tool_view, + acp_event_sink, + binding, + ) .await } @@ -2851,6 +2865,7 @@ async fn resolve_provider_turn( preparation: &ProviderTurnPreparation, result: CliResult, error_mode: ProviderErrorMode, + acp_event_sink: Option<&dyn AcpTurnEventSink>, binding: ConversationRuntimeBinding<'_>, ingress: Option<&ConversationIngressContext>, observer: Option<&ConversationTurnObserverHandle>, @@ -2901,6 +2916,7 @@ async fn resolve_provider_turn( .max_discovery_followup_rounds .saturating_add(1) .max(1), + acp_event_sink, binding, observer, ) @@ -3025,6 +3041,7 @@ async fn resolve_provider_turn_reply( turn_loop_policy: &ProviderTurnLoopPolicy, turn_loop_state: &mut ProviderTurnLoopState, remaining_provider_rounds: usize, + acp_event_sink: Option<&dyn AcpTurnEventSink>, binding: ConversationRuntimeBinding<'_>, ingress: Option<&ConversationIngressContext>, observer: Option<&ConversationTurnObserverHandle>, @@ -3240,6 +3257,7 @@ async fn resolve_provider_turn_reply( followup_preparation.turn_id.as_str(), &followup_preparation.session.messages, &followup_tool_view, + acp_event_sink, binding, observer, ) diff --git a/crates/app/src/memory/mod.rs b/crates/app/src/memory/mod.rs index 46dec2196..9d4854626 100644 --- a/crates/app/src/memory/mod.rs +++ b/crates/app/src/memory/mod.rs @@ -60,7 +60,12 @@ pub use protocol::{ #[cfg(feature = "memory-sqlite")] pub(crate) use sqlite::CanonicalMemorySearchHit; #[cfg(feature = "memory-sqlite")] -pub use sqlite::{ConversationTurn, SqliteBootstrapDiagnostics, SqliteContextLoadDiagnostics}; +pub use sqlite::ConversationSessionSummary; +#[cfg(feature = "memory-sqlite")] +pub use sqlite::{ + ConversationTurn, SqliteBootstrapDiagnostics, SqliteContextLoadDiagnostics, + clear_session_direct, list_recent_sessions_direct, +}; pub use stage::{ DerivedMemoryKind, MemoryAuthority, MemoryContextProvenance, MemoryProvenanceSourceKind, MemoryRecallMode, MemoryRecordStatus, MemoryRetrievalRequest, MemoryStageFamily, diff --git a/crates/app/src/memory/sqlite.rs b/crates/app/src/memory/sqlite.rs index 012a82170..13ea19c51 100644 --- a/crates/app/src/memory/sqlite.rs +++ b/crates/app/src/memory/sqlite.rs @@ -26,6 +26,13 @@ pub struct ConversationTurn { pub ts: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationSessionSummary { + pub session_id: String, + pub turn_count: usize, + pub latest_turn_ts: i64, +} + #[derive(Debug, Clone, Default)] pub(super) struct PromptWindowTurn { pub role: String, @@ -831,6 +838,52 @@ pub(super) fn window_direct_with_options( load_window_internal(session_id, limit, allow_extended_limit, config).map(|window| window.turns) } +pub fn list_recent_sessions_direct( + limit: usize, + config: &MemoryRuntimeConfig, +) -> Result, String> { + let runtime = acquire_memory_runtime(config)?; + let bounded_limit = limit.clamp(1, 200) as i64; + runtime.with_connection("memory.list_recent_sessions", |conn| { + let mut statement = prepare_cached_sqlite_statement( + conn, + "SELECT state.session_id, + state.turn_count, + COALESCE(MAX(turns.ts), 0) AS latest_turn_ts + FROM memory_session_state state + LEFT JOIN turns ON turns.session_id = state.session_id + GROUP BY state.session_id, state.turn_count + ORDER BY latest_turn_ts DESC, state.session_id ASC + LIMIT ?1", + "prepare list recent sessions statement failed", + )?; + + let rows = statement + .query_map(rusqlite::params![bounded_limit], |row| { + Ok(ConversationSessionSummary { + session_id: row.get::<_, String>(0)?, + turn_count: row.get::<_, i64>(1)? as usize, + latest_turn_ts: row.get::<_, i64>(2)?, + }) + }) + .map_err(|error| format!("query recent sessions failed: {error}"))?; + + rows.collect::, _>>() + .map_err(|error| format!("decode recent sessions failed: {error}")) + }) +} + +pub fn clear_session_direct(session_id: &str, config: &MemoryRuntimeConfig) -> Result<(), String> { + let request = MemoryCoreRequest { + operation: MEMORY_OP_CLEAR_SESSION.to_owned(), + payload: json!({ + "session_id": session_id, + }), + }; + let _ = clear_session(request, config)?; + Ok(()) +} + pub(super) fn load_context_snapshot( session_id: &str, config: &MemoryRuntimeConfig, diff --git a/crates/app/src/provider/mod.rs b/crates/app/src/provider/mod.rs index 2d47ec984..6e456c936 100644 --- a/crates/app/src/provider/mod.rs +++ b/crates/app/src/provider/mod.rs @@ -239,6 +239,7 @@ pub async fn request_turn( session_id: &str, turn_id: &str, messages: &[Value], + event_sink: Option<&dyn crate::acp::AcpTurnEventSink>, binding: ProviderRuntimeBinding<'_>, ) -> CliResult { request_turn_in_view( @@ -247,6 +248,7 @@ pub async fn request_turn( turn_id, messages, &crate::tools::runtime_tool_view(), + event_sink, binding, ) .await @@ -258,6 +260,7 @@ pub async fn request_turn_in_view( turn_id: &str, messages: &[Value], tool_view: &crate::tools::ToolView, + event_sink: Option<&dyn crate::acp::AcpTurnEventSink>, binding: ProviderRuntimeBinding<'_>, ) -> CliResult { let session = prepare_provider_request_session(config).await?; @@ -287,6 +290,7 @@ pub async fn request_turn_in_view( model, auto_model_mode, tool_definitions.as_slice(), + event_sink, auth_profile, &session.request_policy, &session.client, diff --git a/crates/app/src/provider/request_dispatch_runtime.rs b/crates/app/src/provider/request_dispatch_runtime.rs index 5eee39121..112d8295a 100644 --- a/crates/app/src/provider/request_dispatch_runtime.rs +++ b/crates/app/src/provider/request_dispatch_runtime.rs @@ -1,646 +1,699 @@ -use std::sync::atomic::{AtomicBool, Ordering}; - -use serde_json::Value; - -use crate::config::{LoongClawConfig, ProviderConfig}; - -use super::auth_profile_runtime::{ProviderAuthProfile, auth_profile_supports_scheme}; -use super::capability_profile_runtime::ProviderCapabilityProfile; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde_json::Value; + +use crate::config::{LoongClawConfig, ProviderConfig}; + +use super::auth_profile_runtime::{ProviderAuthProfile, auth_profile_supports_scheme}; +use super::capability_profile_runtime::ProviderCapabilityProfile; use super::contracts::{ - ProviderApiError, provider_runtime_contract_for_route, should_disable_tool_schema_for_error, + ProviderApiError, ProviderTransportMode, provider_runtime_contract_for_route, + should_disable_tool_schema_for_error, }; -use super::failover::{ - ModelRequestError, ProviderFailoverReason, ProviderFailoverStage, build_model_request_error, -}; -use super::policy; -use super::request_executor::{ - ModelRequestRuntime, StreamingModelRequestRuntime, execute_model_request, - execute_streaming_turn_request, -}; -use super::request_payload_runtime::{ - build_completion_request_body_with_capability, build_turn_request_body_with_capability, -}; -use super::shape; -use super::transport_profile_runtime::resolve_provider_request_transport_profile; - -#[allow(clippy::too_many_arguments)] -pub(super) async fn request_completion_with_model( - config: &LoongClawConfig, - messages: &[Value], - model: String, - auto_model_mode: bool, - auth_profile: ProviderAuthProfile, - request_policy: &policy::ProviderRequestPolicy, - client: &reqwest::Client, - auth_context: &super::transport::RequestAuthContext, -) -> Result { - request_completion_with_provider( - config, - &config.provider, - messages, - model.as_str(), - auto_model_mode, - &auth_profile, - auth_context, - request_policy, - client, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -pub(super) async fn request_turn_with_model( - config: &LoongClawConfig, - session_id: &str, - turn_id: &str, - messages: &[Value], - model: String, - auto_model_mode: bool, - tool_definitions: &[Value], - auth_profile: ProviderAuthProfile, - request_policy: &policy::ProviderRequestPolicy, - client: &reqwest::Client, - auth_context: &super::transport::RequestAuthContext, -) -> Result { - request_turn_with_provider( - config, - &config.provider, - session_id, - turn_id, - messages, - model.as_str(), - auto_model_mode, - tool_definitions, - &auth_profile, - auth_context, - request_policy, - client, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -async fn request_completion_with_provider( - base_config: &LoongClawConfig, - request_provider: &ProviderConfig, - messages: &[Value], - model: &str, - auto_model_mode: bool, - auth_profile: &ProviderAuthProfile, - auth_context: &super::transport::RequestAuthContext, - request_policy: &policy::ProviderRequestPolicy, - client: &reqwest::Client, -) -> Result { - let mut current_provider = request_provider.clone(); - loop { - let transport_profile = resolve_request_transport_profile(¤t_provider, model) - .map_err(|error| { - build_model_request_error( - error, - auto_model_mode, - ProviderFailoverReason::ModelMismatch, - ProviderFailoverStage::ModelCandidateRejected, - model, - 1, - 1, - None, - None, - ) - })?; - let runtime_contract = provider_runtime_contract_for_route( - ¤t_provider, - transport_profile.transport_mode, - transport_profile.feature_family, - ); - let capability_profile = - ProviderCapabilityProfile::from_provider(¤t_provider, runtime_contract); - let request_model = transport_profile.request_model; - let capability = capability_profile.resolve_for_model(request_model.as_str()); - let request_auth_scheme = transport_profile.auth_scheme; - - ensure_auth_profile_supports_route( - auth_profile, - request_auth_scheme, - request_model.as_str(), - auto_model_mode, - )?; - - let request_headers = - super::transport::build_request_headers_without_provider_auth_for_transport( - ¤t_provider, - transport_profile.default_user_agent, - transport_profile.default_headers, - ) - .map_err(|error| { - build_model_request_error( - error, - auto_model_mode, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - request_model.as_str(), - 1, - request_policy.max_attempts, - None, - None, - ) - })?; - let mut request_config = base_config.clone(); - request_config.provider = current_provider.clone(); - let runtime = ModelRequestRuntime { - provider: ¤t_provider, - model: request_model.as_str(), - runtime_contract, - capability, - auto_model_mode, - auth_profile, - request_auth_scheme, - endpoint: transport_profile.endpoint.as_str(), - headers: &request_headers, - request_policy, - client, - auth_context, - }; - - match execute_model_request( - runtime, - |payload_mode| { - build_completion_request_body_with_capability( - &request_config, - messages, - request_model.as_str(), - payload_mode, - runtime_contract, - capability, - ) - }, - shape::extract_message_content, - "choices[0].message.content", - |_| false, - ) - .await - { - Err(error) - if should_retry_with_chat_completions_fallback( - ¤t_provider, - transport_profile.transport_mode, - &error, - ) => - { - if let Some(fallback_provider) = current_provider.responses_fallback_provider() { - current_provider = fallback_provider; - continue; - } - return Err(error); - } - result => return result, - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn request_turn_with_provider( - base_config: &LoongClawConfig, - request_provider: &ProviderConfig, - session_id: &str, - turn_id: &str, - messages: &[Value], - model: &str, - auto_model_mode: bool, - tool_definitions: &[Value], - auth_profile: &ProviderAuthProfile, - auth_context: &super::transport::RequestAuthContext, - request_policy: &policy::ProviderRequestPolicy, - client: &reqwest::Client, -) -> Result { - let mut current_provider = request_provider.clone(); - loop { - let transport_profile = resolve_request_transport_profile(¤t_provider, model) - .map_err(|error| { - build_model_request_error( - error, - auto_model_mode, - ProviderFailoverReason::ModelMismatch, - ProviderFailoverStage::ModelCandidateRejected, - model, - 1, - 1, - None, - None, - ) - })?; - let runtime_contract = provider_runtime_contract_for_route( - ¤t_provider, - transport_profile.transport_mode, - transport_profile.feature_family, - ); - let capability_profile = - ProviderCapabilityProfile::from_provider(¤t_provider, runtime_contract); - let request_model = transport_profile.request_model; - let capability = capability_profile.resolve_for_model(request_model.as_str()); - let request_auth_scheme = transport_profile.auth_scheme; - - ensure_auth_profile_supports_route( - auth_profile, - request_auth_scheme, - request_model.as_str(), - auto_model_mode, - )?; - - let include_tool_schema = - AtomicBool::new(capability.turn_tool_schema_enabled() && !tool_definitions.is_empty()); - let request_headers = - super::transport::build_request_headers_without_provider_auth_for_transport( - ¤t_provider, - transport_profile.default_user_agent, - transport_profile.default_headers, +use super::failover::{ + ModelRequestError, ProviderFailoverReason, ProviderFailoverStage, build_model_request_error, +}; +use super::policy; +use super::request_executor::{ + ModelRequestRuntime, StreamingModelRequestRuntime, execute_model_request, + execute_openai_streaming_turn_request, execute_streaming_turn_request, +}; +use super::request_payload_runtime::{ + build_completion_request_body_with_capability, build_turn_request_body_with_capability, +}; +use super::shape; +use super::transport_profile_runtime::resolve_provider_request_transport_profile; +use crate::acp::AcpTurnEventSink; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn request_completion_with_model( + config: &LoongClawConfig, + messages: &[Value], + model: String, + auto_model_mode: bool, + auth_profile: ProviderAuthProfile, + request_policy: &policy::ProviderRequestPolicy, + client: &reqwest::Client, + auth_context: &super::transport::RequestAuthContext, +) -> Result { + request_completion_with_provider( + config, + &config.provider, + messages, + model.as_str(), + auto_model_mode, + &auth_profile, + auth_context, + request_policy, + client, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn request_turn_with_model( + config: &LoongClawConfig, + session_id: &str, + turn_id: &str, + messages: &[Value], + model: String, + auto_model_mode: bool, + tool_definitions: &[Value], + event_sink: Option<&dyn AcpTurnEventSink>, + auth_profile: ProviderAuthProfile, + request_policy: &policy::ProviderRequestPolicy, + client: &reqwest::Client, + auth_context: &super::transport::RequestAuthContext, +) -> Result { + request_turn_with_provider( + config, + &config.provider, + session_id, + turn_id, + messages, + model.as_str(), + auto_model_mode, + tool_definitions, + event_sink, + &auth_profile, + auth_context, + request_policy, + client, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn request_completion_with_provider( + base_config: &LoongClawConfig, + request_provider: &ProviderConfig, + messages: &[Value], + model: &str, + auto_model_mode: bool, + auth_profile: &ProviderAuthProfile, + auth_context: &super::transport::RequestAuthContext, + request_policy: &policy::ProviderRequestPolicy, + client: &reqwest::Client, +) -> Result { + let mut current_provider = request_provider.clone(); + loop { + let transport_profile = resolve_request_transport_profile(¤t_provider, model) + .map_err(|error| { + build_model_request_error( + error, + auto_model_mode, + ProviderFailoverReason::ModelMismatch, + ProviderFailoverStage::ModelCandidateRejected, + model, + 1, + 1, + None, + None, + ) + })?; + let runtime_contract = provider_runtime_contract_for_route( + ¤t_provider, + transport_profile.transport_mode, + transport_profile.feature_family, + ); + let capability_profile = + ProviderCapabilityProfile::from_provider(¤t_provider, runtime_contract); + let request_model = transport_profile.request_model; + let capability = capability_profile.resolve_for_model(request_model.as_str()); + let request_auth_scheme = transport_profile.auth_scheme; + + ensure_auth_profile_supports_route( + auth_profile, + request_auth_scheme, + request_model.as_str(), + auto_model_mode, + )?; + + let request_headers = + super::transport::build_request_headers_without_provider_auth_for_transport( + ¤t_provider, + transport_profile.default_user_agent, + transport_profile.default_headers, + ) + .map_err(|error| { + build_model_request_error( + error, + auto_model_mode, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + request_model.as_str(), + 1, + request_policy.max_attempts, + None, + None, + ) + })?; + let mut request_config = base_config.clone(); + request_config.provider = current_provider.clone(); + let runtime = ModelRequestRuntime { + provider: ¤t_provider, + model: request_model.as_str(), + runtime_contract, + capability, + auto_model_mode, + auth_profile, + request_auth_scheme, + endpoint: transport_profile.endpoint.as_str(), + headers: &request_headers, + request_policy, + client, + auth_context, + }; + + match execute_model_request( + runtime, + |payload_mode| { + build_completion_request_body_with_capability( + &request_config, + messages, + request_model.as_str(), + payload_mode, + runtime_contract, + capability, + ) + }, + shape::extract_message_content, + "choices[0].message.content", + |_| false, + ) + .await + { + Err(error) + if should_retry_with_chat_completions_fallback( + ¤t_provider, + transport_profile.transport_mode, + &error, + ) => + { + if let Some(fallback_provider) = current_provider.responses_fallback_provider() { + current_provider = fallback_provider; + continue; + } + return Err(error); + } + result => return result, + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn request_turn_with_provider( + base_config: &LoongClawConfig, + request_provider: &ProviderConfig, + session_id: &str, + turn_id: &str, + messages: &[Value], + model: &str, + auto_model_mode: bool, + tool_definitions: &[Value], + event_sink: Option<&dyn AcpTurnEventSink>, + auth_profile: &ProviderAuthProfile, + auth_context: &super::transport::RequestAuthContext, + request_policy: &policy::ProviderRequestPolicy, + client: &reqwest::Client, +) -> Result { + let mut current_provider = request_provider.clone(); + loop { + let transport_profile = resolve_request_transport_profile(¤t_provider, model) + .map_err(|error| { + build_model_request_error( + error, + auto_model_mode, + ProviderFailoverReason::ModelMismatch, + ProviderFailoverStage::ModelCandidateRejected, + model, + 1, + 1, + None, + None, + ) + })?; + let runtime_contract = provider_runtime_contract_for_route( + ¤t_provider, + transport_profile.transport_mode, + transport_profile.feature_family, + ); + let capability_profile = + ProviderCapabilityProfile::from_provider(¤t_provider, runtime_contract); + let request_model = transport_profile.request_model; + let capability = capability_profile.resolve_for_model(request_model.as_str()); + let request_auth_scheme = transport_profile.auth_scheme; + + ensure_auth_profile_supports_route( + auth_profile, + request_auth_scheme, + request_model.as_str(), + auto_model_mode, + )?; + + let include_tool_schema = + AtomicBool::new(capability.turn_tool_schema_enabled() && !tool_definitions.is_empty()); + let request_headers = + super::transport::build_request_headers_without_provider_auth_for_transport( + ¤t_provider, + transport_profile.default_user_agent, + transport_profile.default_headers, + ) + .map_err(|error| { + build_model_request_error( + error, + auto_model_mode, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + request_model.as_str(), + 1, + request_policy.max_attempts, + None, + None, + ) + })?; + let mut request_config = base_config.clone(); + request_config.provider = current_provider.clone(); + let runtime = ModelRequestRuntime { + provider: ¤t_provider, + model: request_model.as_str(), + runtime_contract, + capability, + auto_model_mode, + auth_profile, + request_auth_scheme, + endpoint: transport_profile.endpoint.as_str(), + headers: &request_headers, + request_policy, + client, + auth_context, + }; + + // Prefer native provider streaming when the caller can consume incremental events. + // Unsupported providers stay on the existing buffered turn path below. + if let Some(event_sink) = event_sink + && matches!( + runtime_contract.transport_mode, + ProviderTransportMode::OpenAiChatCompletions + ) + { + match execute_openai_streaming_turn_request( + runtime, + |payload_mode| { + build_turn_request_body_with_capability( + &request_config, + messages, + model, + payload_mode, + runtime_contract, + capability, + include_tool_schema.load(Ordering::Relaxed), + tool_definitions, + true, + ) + }, + messages, + session_id, + turn_id, + event_sink, ) - .map_err(|error| { - build_model_request_error( - error, - auto_model_mode, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - request_model.as_str(), - 1, - request_policy.max_attempts, - None, - None, - ) - })?; - let mut request_config = base_config.clone(); - request_config.provider = current_provider.clone(); - let runtime = ModelRequestRuntime { - provider: ¤t_provider, - model: request_model.as_str(), - runtime_contract, - capability, - auto_model_mode, - auth_profile, - request_auth_scheme, - endpoint: transport_profile.endpoint.as_str(), - headers: &request_headers, - request_policy, - client, - auth_context, - }; - - match execute_model_request( - runtime, - |payload_mode| { - build_turn_request_body_with_capability( - &request_config, - messages, - request_model.as_str(), - payload_mode, - runtime_contract, - capability, - include_tool_schema.load(Ordering::Relaxed), - tool_definitions, - false, - ) - }, - |body| { - shape::extract_provider_turn_with_scope_and_messages( - body, - Some(session_id), - Some(turn_id), - messages, - ) - }, - "choices[0].message", - |api_error| { - if include_tool_schema.load(Ordering::Relaxed) - && capability.tool_schema_downgrade_on_unsupported() - && should_disable_tool_schema_for_error(api_error, runtime_contract) - { - include_tool_schema.store(false, Ordering::Relaxed); - return true; - } - false - }, - ) - .await - { - Err(error) - if should_retry_with_chat_completions_fallback( - ¤t_provider, - transport_profile.transport_mode, - &error, - ) => + .await { - if let Some(fallback_provider) = current_provider.responses_fallback_provider() { - current_provider = fallback_provider; - continue; - } - return Err(error); - } - result => return result, - } - } -} - -#[allow(clippy::too_many_arguments)] -pub(super) async fn request_turn_streaming( - base_config: &LoongClawConfig, - request_provider: &ProviderConfig, - session_id: &str, - turn_id: &str, - messages: &[Value], - model: &str, - auto_model_mode: bool, - tool_definitions: &[Value], - auth_profile: &ProviderAuthProfile, - auth_context: &super::transport::RequestAuthContext, - request_policy: &policy::ProviderRequestPolicy, - client: &reqwest::Client, - on_token: super::request_executor::StreamingTokenCallback, -) -> Result { - let mut current_provider = request_provider.clone(); - loop { - let transport_profile = resolve_request_transport_profile(¤t_provider, model) - .map_err(|error| { - build_model_request_error( - error, - auto_model_mode, - ProviderFailoverReason::ModelMismatch, - ProviderFailoverStage::ModelCandidateRejected, - model, - 1, - 1, - None, - None, - ) - })?; - let runtime_contract = provider_runtime_contract_for_route( - ¤t_provider, - transport_profile.transport_mode, - transport_profile.feature_family, - ); - let capability_profile = - ProviderCapabilityProfile::from_provider(¤t_provider, runtime_contract); - let request_model = transport_profile.request_model; - let capability = capability_profile.resolve_for_model(request_model.as_str()); - let request_auth_scheme = transport_profile.auth_scheme; - - ensure_auth_profile_supports_route( - auth_profile, - request_auth_scheme, - request_model.as_str(), - auto_model_mode, - )?; - - let include_tool_schema = - AtomicBool::new(capability.turn_tool_schema_enabled() && !tool_definitions.is_empty()); - let request_headers = - super::transport::build_request_headers_without_provider_auth_for_transport( - ¤t_provider, - transport_profile.default_user_agent, - transport_profile.default_headers, - ) - .map_err(|error| { - build_model_request_error( - error, - auto_model_mode, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - request_model.as_str(), - 1, - request_policy.max_attempts, - None, - None, - ) - })?; - let mut request_config = base_config.clone(); - request_config.provider = current_provider.clone(); - let runtime = StreamingModelRequestRuntime { - provider: ¤t_provider, - model: request_model.as_str(), - runtime_contract, - capability, - auto_model_mode, - auth_profile, - request_auth_scheme, - endpoint: transport_profile.endpoint.as_str(), - headers: &request_headers, - request_policy, - client, - auth_context, - }; - - match execute_streaming_turn_request( - runtime, - |payload_mode| { - build_turn_request_body_with_capability( - &request_config, - messages, - request_model.as_str(), - payload_mode, - runtime_contract, - capability, - include_tool_schema.load(Ordering::Relaxed), - tool_definitions, - true, - ) - }, - Some(session_id), - Some(turn_id), - messages, - on_token.clone(), - |api_error| { - if include_tool_schema.load(Ordering::Relaxed) - && capability.tool_schema_downgrade_on_unsupported() - && should_disable_tool_schema_for_error(api_error, runtime_contract) + Err(error) + if should_retry_with_chat_completions_fallback( + ¤t_provider, + transport_profile.transport_mode, + &error, + ) => { - include_tool_schema.store(false, Ordering::Relaxed); - return true; - } - false - }, - ) - .await - { - Err(error) - if should_retry_with_chat_completions_fallback( - ¤t_provider, - transport_profile.transport_mode, - &error, - ) => - { - if let Some(fallback_provider) = current_provider.responses_fallback_provider() { - current_provider = fallback_provider; - continue; - } - return Err(error); - } - result => return result, - } - } -} - -#[allow(clippy::too_many_arguments)] -pub(super) async fn request_turn_streaming_with_model( - config: &LoongClawConfig, - session_id: &str, - turn_id: &str, - messages: &[Value], - model: String, - auto_model_mode: bool, - tool_definitions: &[Value], - auth_profile: ProviderAuthProfile, - request_policy: &policy::ProviderRequestPolicy, - client: &reqwest::Client, - auth_context: &super::transport::RequestAuthContext, - on_token: super::request_executor::StreamingTokenCallback, -) -> Result { - request_turn_streaming( - config, - &config.provider, - session_id, - turn_id, - messages, - model.as_str(), - auto_model_mode, - tool_definitions, - &auth_profile, - auth_context, - request_policy, - client, - on_token, - ) - .await -} - -fn resolve_request_transport_profile( - provider: &ProviderConfig, - model: &str, -) -> Result { - resolve_provider_request_transport_profile(provider, model) -} - -fn should_retry_with_chat_completions_fallback( - provider: &ProviderConfig, - transport_mode: super::contracts::ProviderTransportMode, - error: &ModelRequestError, -) -> bool { - if transport_mode != super::contracts::ProviderTransportMode::Responses { - return false; - } - - let Some(status_code) = error.snapshot.status_code else { - return false; - }; - let Some(api_error) = error.api_error.as_ref() else { - return false; - }; - should_fallback_responses_to_chat_completions(provider, status_code, api_error) -} - -#[allow(clippy::result_large_err)] -fn ensure_auth_profile_supports_route( - auth_profile: &ProviderAuthProfile, - request_auth_scheme: crate::config::ProviderAuthScheme, - request_model: &str, - auto_model_mode: bool, -) -> Result<(), ModelRequestError> { - if request_auth_scheme == crate::config::ProviderAuthScheme::Bearer { - return Ok(()); - } - - if auth_profile_supports_scheme(auth_profile, request_auth_scheme) { - return Ok(()); - } - - let missing_secret_kind = match request_auth_scheme { - crate::config::ProviderAuthScheme::Bearer => "bearer", - crate::config::ProviderAuthScheme::XApiKey => "x-api-key", - crate::config::ProviderAuthScheme::XGoogApiKey => "x-goog-api-key", - }; - - let message = format!( - "provider auth profile `{}` cannot satisfy the routed `{}` auth requirement for model `{}`; trying the next available auth profile", - auth_profile.id, missing_secret_kind, request_model - ); - - let error = build_model_request_error( - message, - auto_model_mode, - ProviderFailoverReason::AuthRejected, - ProviderFailoverStage::TransportFailure, - request_model, - 1, - 1, - None, - None, - ); - - Err(error) -} - -fn should_fallback_responses_to_chat_completions( - provider: &ProviderConfig, - status_code: u16, - error: &ProviderApiError, -) -> bool { - if provider.responses_fallback_provider().is_none() { - return false; - } - - let message = error.message.as_deref().unwrap_or_default(); - if message.is_empty() - || message.contains("unauthorized") - || message.contains("forbidden") - || message.contains("invalid api key") - || message.contains("rate limit") - || message.contains("insufficient quota") - { - return false; - } - - let compatibility_status = matches!(status_code, 400 | 404 | 405 | 415 | 422); - let gateway_rejection = matches!(status_code, 500 | 502 | 503 | 504) - && (message.contains("bad gateway") - || message.contains("gateway timeout") - || message.contains("upstream") - || message.contains("proxy") - || message.contains("error code: 502") - || message.contains("error code: 503") - || message.contains("error code: 504")); - if !compatibility_status && !gateway_rejection { - return false; - } - - let mentions_chat_endpoint = - message.contains("/v1/chat/completions") || message.contains("chat/completions"); - let rejects_responses_input = matches!(error.param.as_deref(), Some("input" | "instructions")) - && (message.contains("unknown parameter") - || message.contains("unsupported parameter") - || message.contains("expects") - || message.contains("not supported")); - let requires_messages = error.param.as_deref() == Some("messages") - && (message.contains("required") - || message.contains("missing") - || message.contains("expects")); - let textual_messages_hint = message.contains("expects `messages`") - || message.contains("expects messages") - || message.contains("use `messages`") - || message.contains("use 'messages'") - || message.contains("missing required parameter: `messages`") - || message.contains("requires `messages`") - || message.contains("requires messages") - || message.contains("expected `messages`") - || message.contains("expected messages") - || message.contains("unknown parameter `input`") - || message.contains("unknown parameter: `input`") - || message.contains("unsupported parameter `input`") - || message.contains("unsupported parameter: `input`") - || message.contains("unknown parameter `instructions`") - || message.contains("unknown parameter: `instructions`") - || message.contains("unsupported parameter `instructions`") - || message.contains("unsupported parameter: `instructions`"); - - gateway_rejection - || mentions_chat_endpoint - || rejects_responses_input - || requires_messages - || textual_messages_hint -} + if let Some(fallback_provider) = current_provider.responses_fallback_provider() + { + current_provider = fallback_provider; + continue; + } + return Err(error); + } + result => return result, + } + } + + match execute_model_request( + runtime, + |payload_mode| { + build_turn_request_body_with_capability( + &request_config, + messages, + request_model.as_str(), + payload_mode, + runtime_contract, + capability, + include_tool_schema.load(Ordering::Relaxed), + tool_definitions, + false, + ) + }, + |body| { + shape::extract_provider_turn_with_scope_and_messages( + body, + Some(session_id), + Some(turn_id), + messages, + ) + }, + "choices[0].message", + |api_error| { + if include_tool_schema.load(Ordering::Relaxed) + && capability.tool_schema_downgrade_on_unsupported() + && should_disable_tool_schema_for_error(api_error, runtime_contract) + { + include_tool_schema.store(false, Ordering::Relaxed); + return true; + } + false + }, + ) + .await + { + Err(error) + if should_retry_with_chat_completions_fallback( + ¤t_provider, + transport_profile.transport_mode, + &error, + ) => + { + if let Some(fallback_provider) = current_provider.responses_fallback_provider() { + current_provider = fallback_provider; + continue; + } + return Err(error); + } + result => return result, + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn request_turn_streaming( + base_config: &LoongClawConfig, + request_provider: &ProviderConfig, + session_id: &str, + turn_id: &str, + messages: &[Value], + model: &str, + auto_model_mode: bool, + tool_definitions: &[Value], + auth_profile: &ProviderAuthProfile, + auth_context: &super::transport::RequestAuthContext, + request_policy: &policy::ProviderRequestPolicy, + client: &reqwest::Client, + on_token: super::request_executor::StreamingTokenCallback, +) -> Result { + let mut current_provider = request_provider.clone(); + loop { + let transport_profile = resolve_request_transport_profile(¤t_provider, model) + .map_err(|error| { + build_model_request_error( + error, + auto_model_mode, + ProviderFailoverReason::ModelMismatch, + ProviderFailoverStage::ModelCandidateRejected, + model, + 1, + 1, + None, + None, + ) + })?; + let runtime_contract = provider_runtime_contract_for_route( + ¤t_provider, + transport_profile.transport_mode, + transport_profile.feature_family, + ); + let capability_profile = + ProviderCapabilityProfile::from_provider(¤t_provider, runtime_contract); + let request_model = transport_profile.request_model; + let capability = capability_profile.resolve_for_model(request_model.as_str()); + let request_auth_scheme = transport_profile.auth_scheme; + + ensure_auth_profile_supports_route( + auth_profile, + request_auth_scheme, + request_model.as_str(), + auto_model_mode, + )?; + + let include_tool_schema = + AtomicBool::new(capability.turn_tool_schema_enabled() && !tool_definitions.is_empty()); + let request_headers = + super::transport::build_request_headers_without_provider_auth_for_transport( + ¤t_provider, + transport_profile.default_user_agent, + transport_profile.default_headers, + ) + .map_err(|error| { + build_model_request_error( + error, + auto_model_mode, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + request_model.as_str(), + 1, + request_policy.max_attempts, + None, + None, + ) + })?; + let mut request_config = base_config.clone(); + request_config.provider = current_provider.clone(); + let runtime = StreamingModelRequestRuntime { + provider: ¤t_provider, + model: request_model.as_str(), + runtime_contract, + capability, + auto_model_mode, + auth_profile, + request_auth_scheme, + endpoint: transport_profile.endpoint.as_str(), + headers: &request_headers, + request_policy, + client, + auth_context, + }; + + match execute_streaming_turn_request( + runtime, + |payload_mode| { + build_turn_request_body_with_capability( + &request_config, + messages, + request_model.as_str(), + payload_mode, + runtime_contract, + capability, + include_tool_schema.load(Ordering::Relaxed), + tool_definitions, + true, + ) + }, + Some(session_id), + Some(turn_id), + messages, + on_token.clone(), + |api_error| { + if include_tool_schema.load(Ordering::Relaxed) + && capability.tool_schema_downgrade_on_unsupported() + && should_disable_tool_schema_for_error(api_error, runtime_contract) + { + include_tool_schema.store(false, Ordering::Relaxed); + return true; + } + false + }, + ) + .await + { + Err(error) + if should_retry_with_chat_completions_fallback( + ¤t_provider, + transport_profile.transport_mode, + &error, + ) => + { + if let Some(fallback_provider) = current_provider.responses_fallback_provider() { + current_provider = fallback_provider; + continue; + } + return Err(error); + } + result => return result, + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn request_turn_streaming_with_model( + config: &LoongClawConfig, + session_id: &str, + turn_id: &str, + messages: &[Value], + model: String, + auto_model_mode: bool, + tool_definitions: &[Value], + auth_profile: ProviderAuthProfile, + request_policy: &policy::ProviderRequestPolicy, + client: &reqwest::Client, + auth_context: &super::transport::RequestAuthContext, + on_token: super::request_executor::StreamingTokenCallback, +) -> Result { + request_turn_streaming( + config, + &config.provider, + session_id, + turn_id, + messages, + model.as_str(), + auto_model_mode, + tool_definitions, + &auth_profile, + auth_context, + request_policy, + client, + on_token, + ) + .await +} + +fn resolve_request_transport_profile( + provider: &ProviderConfig, + model: &str, +) -> Result { + resolve_provider_request_transport_profile(provider, model) +} + +fn should_retry_with_chat_completions_fallback( + provider: &ProviderConfig, + transport_mode: super::contracts::ProviderTransportMode, + error: &ModelRequestError, +) -> bool { + if transport_mode != super::contracts::ProviderTransportMode::Responses { + return false; + } + + let Some(status_code) = error.snapshot.status_code else { + return false; + }; + let Some(api_error) = error.api_error.as_ref() else { + return false; + }; + should_fallback_responses_to_chat_completions(provider, status_code, api_error) +} + +#[allow(clippy::result_large_err)] +fn ensure_auth_profile_supports_route( + auth_profile: &ProviderAuthProfile, + request_auth_scheme: crate::config::ProviderAuthScheme, + request_model: &str, + auto_model_mode: bool, +) -> Result<(), ModelRequestError> { + if request_auth_scheme == crate::config::ProviderAuthScheme::Bearer { + return Ok(()); + } + + if auth_profile_supports_scheme(auth_profile, request_auth_scheme) { + return Ok(()); + } + + let missing_secret_kind = match request_auth_scheme { + crate::config::ProviderAuthScheme::Bearer => "bearer", + crate::config::ProviderAuthScheme::XApiKey => "x-api-key", + crate::config::ProviderAuthScheme::XGoogApiKey => "x-goog-api-key", + }; + + let message = format!( + "provider auth profile `{}` cannot satisfy the routed `{}` auth requirement for model `{}`; trying the next available auth profile", + auth_profile.id, missing_secret_kind, request_model + ); + + let error = build_model_request_error( + message, + auto_model_mode, + ProviderFailoverReason::AuthRejected, + ProviderFailoverStage::TransportFailure, + request_model, + 1, + 1, + None, + None, + ); + + Err(error) +} + +fn should_fallback_responses_to_chat_completions( + provider: &ProviderConfig, + status_code: u16, + error: &ProviderApiError, +) -> bool { + if provider.responses_fallback_provider().is_none() { + return false; + } + + let message = error.message.as_deref().unwrap_or_default(); + if message.is_empty() + || message.contains("unauthorized") + || message.contains("forbidden") + || message.contains("invalid api key") + || message.contains("rate limit") + || message.contains("insufficient quota") + { + return false; + } + + let compatibility_status = matches!(status_code, 400 | 404 | 405 | 415 | 422); + let gateway_rejection = matches!(status_code, 500 | 502 | 503 | 504) + && (message.contains("bad gateway") + || message.contains("gateway timeout") + || message.contains("upstream") + || message.contains("proxy") + || message.contains("error code: 502") + || message.contains("error code: 503") + || message.contains("error code: 504")); + if !compatibility_status && !gateway_rejection { + return false; + } + + let mentions_chat_endpoint = + message.contains("/v1/chat/completions") || message.contains("chat/completions"); + let rejects_responses_input = matches!(error.param.as_deref(), Some("input" | "instructions")) + && (message.contains("unknown parameter") + || message.contains("unsupported parameter") + || message.contains("expects") + || message.contains("not supported")); + let requires_messages = error.param.as_deref() == Some("messages") + && (message.contains("required") + || message.contains("missing") + || message.contains("expects")); + let textual_messages_hint = message.contains("expects `messages`") + || message.contains("expects messages") + || message.contains("use `messages`") + || message.contains("use 'messages'") + || message.contains("missing required parameter: `messages`") + || message.contains("requires `messages`") + || message.contains("requires messages") + || message.contains("expected `messages`") + || message.contains("expected messages") + || message.contains("unknown parameter `input`") + || message.contains("unknown parameter: `input`") + || message.contains("unsupported parameter `input`") + || message.contains("unsupported parameter: `input`") + || message.contains("unknown parameter `instructions`") + || message.contains("unknown parameter: `instructions`") + || message.contains("unsupported parameter `instructions`") + || message.contains("unsupported parameter: `instructions`"); + + gateway_rejection + || mentions_chat_endpoint + || rejects_responses_input + || requires_messages + || textual_messages_hint +} diff --git a/crates/app/src/provider/request_executor.rs b/crates/app/src/provider/request_executor.rs index ff91589bf..9029b1eac 100644 --- a/crates/app/src/provider/request_executor.rs +++ b/crates/app/src/provider/request_executor.rs @@ -1,478 +1,541 @@ -use std::collections::VecDeque; -use std::marker::PhantomData; -use std::pin::Pin; -use std::str::from_utf8; -use std::sync::Arc; -use std::task::{Context, Poll}; -use std::time::Duration; - -use bytes::Bytes; -use futures_util::Stream; -use serde_json::Value; -use tokio::time::sleep; - -use crate::config::ProviderAuthScheme; -use crate::config::ProviderConfig; -use crate::conversation::turn_engine::{ProviderTurn, ToolIntent}; - -use super::{ - auth_profile_runtime::ProviderAuthProfile, - contracts::{ - CompletionPayloadMode, ProviderApiError, ProviderCapabilityContract, - ProviderRuntimeContract, adapt_payload_mode_for_error, parse_provider_api_error, - }, - failover::{ - ModelRequestError, ProviderFailoverReason, ProviderFailoverStage, build_model_request_error, - }, - policy, - request_planner::{ - ModelRequestStatusPlan, classify_model_status_failure_reason_with_capability, - plan_model_request_status_with_capability, plan_transport_error_retry, - }, - transport::{self, RequestExecutionError}, -}; - -pub(super) struct ModelRequestRuntime<'a> { - pub(super) provider: &'a ProviderConfig, - pub(super) model: &'a str, - pub(super) runtime_contract: ProviderRuntimeContract, - pub(super) capability: ProviderCapabilityContract, - pub(super) auto_model_mode: bool, - pub(super) auth_profile: &'a ProviderAuthProfile, - pub(super) request_auth_scheme: ProviderAuthScheme, - pub(super) endpoint: &'a str, - pub(super) headers: &'a reqwest::header::HeaderMap, - pub(super) request_policy: &'a policy::ProviderRequestPolicy, - pub(super) client: &'a reqwest::Client, - pub(super) auth_context: &'a transport::RequestAuthContext, -} - -pub(super) struct StreamingModelRequestRuntime<'a> { - pub(super) provider: &'a ProviderConfig, - pub(super) model: &'a str, - pub(super) runtime_contract: ProviderRuntimeContract, - pub(super) capability: ProviderCapabilityContract, - pub(super) auto_model_mode: bool, - pub(super) auth_profile: &'a ProviderAuthProfile, - pub(super) request_auth_scheme: ProviderAuthScheme, - pub(super) endpoint: &'a str, - pub(super) headers: &'a reqwest::header::HeaderMap, - pub(super) request_policy: &'a policy::ProviderRequestPolicy, - pub(super) client: &'a reqwest::Client, - pub(super) auth_context: &'a transport::RequestAuthContext, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ModelStatusOutcome { - Retry { delay_ms: u64, next_backoff_ms: u64 }, - TryNextModel, - Fail { reason: ProviderFailoverReason }, -} - -fn plan_model_status_outcome( - status_code: u16, - response_headers: &reqwest::header::HeaderMap, - api_error: &ProviderApiError, - attempt: usize, - request_policy: &policy::ProviderRequestPolicy, - backoff_ms: u64, - auto_model_mode: bool, - runtime_contract: ProviderRuntimeContract, - capability: ProviderCapabilityContract, -) -> ModelStatusOutcome { - match plan_model_request_status_with_capability( - status_code, - response_headers, - api_error, - attempt, - request_policy, - backoff_ms, - auto_model_mode, - runtime_contract, - capability, - ) { - ModelRequestStatusPlan::Retry { - delay_ms, - next_backoff_ms, - } => ModelStatusOutcome::Retry { - delay_ms, - next_backoff_ms, - }, - ModelRequestStatusPlan::TryNextModel => ModelStatusOutcome::TryNextModel, - ModelRequestStatusPlan::Fail => ModelStatusOutcome::Fail { - reason: classify_model_status_failure_reason_with_capability( - status_code, - api_error, - runtime_contract, - capability, - ), - }, - } -} - -fn render_status_failure_message( - provider: &ProviderConfig, - reason: ProviderFailoverReason, - status_code: u16, - model: &str, - attempt: usize, - max_attempts: usize, - response_body: &Value, -) -> String { - let support_facts = provider.support_facts(); - let auth_support = support_facts.auth; - let region_endpoint_support = support_facts.region_endpoint; - let mut message = format!( - "provider returned status {status_code} for model `{model}` on attempt {attempt}/{max_attempts}: {response_body}" - ); - if matches!(reason, ProviderFailoverReason::AuthRejected) { - if let Some(hint) = auth_support.guidance_hint { - message.push(' '); - message.push_str(hint.as_str()); - } - if let Some(hint) = region_endpoint_support.request_failure_hint { - message.push(' '); - message.push_str(hint.as_str()); - } - } - message -} - -pub(super) async fn execute_model_request( - runtime: ModelRequestRuntime<'_>, - mut build_body: BuildBody, - mut parse_success: ParseSuccess, - missing_shape_fragment: &'static str, - mut pre_status_error: PreStatusError, -) -> Result -where - BuildBody: FnMut(CompletionPayloadMode) -> Value, - ParseSuccess: FnMut(&Value) -> Option, - PreStatusError: FnMut(&ProviderApiError) -> bool, -{ - let mut attempt = 0usize; - let mut backoff_ms = runtime.request_policy.initial_backoff_ms; - let mut payload_mode = - CompletionPayloadMode::default_for_contract(runtime.provider, runtime.runtime_contract); - let mut tried_payload_modes = vec![payload_mode]; - - loop { - attempt += 1; - let body = build_body(payload_mode); - let request_endpoint = - transport::resolve_request_endpoint(runtime.provider, runtime.endpoint, runtime.model); - let request_endpoint = - transport::resolve_request_url(runtime.provider, request_endpoint.as_str(), runtime.auth_context) - .map_err(|error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - let body_bytes = transport::encode_json_request_body(&body).map_err(|error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - let mut headers = runtime.headers.clone(); - transport::apply_json_request_defaults(&mut headers).map_err(|error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; +use std::collections::BTreeMap; +use std::collections::VecDeque; +use std::marker::PhantomData; +use std::pin::Pin; +use std::str::from_utf8; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use bytes::Bytes; +use futures_util::Stream; +use futures_util::StreamExt; +use serde_json::Value; +use tokio::time::sleep; + +use crate::CliResult; +use crate::acp::AcpTurnEventSink; +use crate::config::ProviderAuthScheme; +use crate::config::ProviderConfig; +use crate::conversation::turn_engine::{ProviderTurn, ToolIntent}; + +use super::{ + auth_profile_runtime::ProviderAuthProfile, + contracts::{ + CompletionPayloadMode, ProviderApiError, ProviderCapabilityContract, + ProviderRuntimeContract, adapt_payload_mode_for_error, parse_provider_api_error, + }, + failover::{ + ModelRequestError, ProviderFailoverReason, ProviderFailoverStage, build_model_request_error, + }, + policy, + request_planner::{ + ModelRequestStatusPlan, classify_model_status_failure_reason_with_capability, + plan_model_request_status_with_capability, plan_transport_error_retry, + }, + transport::{self, RequestExecutionError}, +}; + +pub(super) struct ModelRequestRuntime<'a> { + pub(super) provider: &'a ProviderConfig, + pub(super) model: &'a str, + pub(super) runtime_contract: ProviderRuntimeContract, + pub(super) capability: ProviderCapabilityContract, + pub(super) auto_model_mode: bool, + pub(super) auth_profile: &'a ProviderAuthProfile, + pub(super) request_auth_scheme: ProviderAuthScheme, + pub(super) endpoint: &'a str, + pub(super) headers: &'a reqwest::header::HeaderMap, + pub(super) request_policy: &'a policy::ProviderRequestPolicy, + pub(super) client: &'a reqwest::Client, + pub(super) auth_context: &'a transport::RequestAuthContext, +} + +pub(super) struct StreamingModelRequestRuntime<'a> { + pub(super) provider: &'a ProviderConfig, + pub(super) model: &'a str, + pub(super) runtime_contract: ProviderRuntimeContract, + pub(super) capability: ProviderCapabilityContract, + pub(super) auto_model_mode: bool, + pub(super) auth_profile: &'a ProviderAuthProfile, + pub(super) request_auth_scheme: ProviderAuthScheme, + pub(super) endpoint: &'a str, + pub(super) headers: &'a reqwest::header::HeaderMap, + pub(super) request_policy: &'a policy::ProviderRequestPolicy, + pub(super) client: &'a reqwest::Client, + pub(super) auth_context: &'a transport::RequestAuthContext, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelStatusOutcome { + Retry { delay_ms: u64, next_backoff_ms: u64 }, + TryNextModel, + Fail { reason: ProviderFailoverReason }, +} + +fn plan_model_status_outcome( + status_code: u16, + response_headers: &reqwest::header::HeaderMap, + api_error: &ProviderApiError, + attempt: usize, + request_policy: &policy::ProviderRequestPolicy, + backoff_ms: u64, + auto_model_mode: bool, + runtime_contract: ProviderRuntimeContract, + capability: ProviderCapabilityContract, +) -> ModelStatusOutcome { + match plan_model_request_status_with_capability( + status_code, + response_headers, + api_error, + attempt, + request_policy, + backoff_ms, + auto_model_mode, + runtime_contract, + capability, + ) { + ModelRequestStatusPlan::Retry { + delay_ms, + next_backoff_ms, + } => ModelStatusOutcome::Retry { + delay_ms, + next_backoff_ms, + }, + ModelRequestStatusPlan::TryNextModel => ModelStatusOutcome::TryNextModel, + ModelRequestStatusPlan::Fail => ModelStatusOutcome::Fail { + reason: classify_model_status_failure_reason_with_capability( + status_code, + api_error, + runtime_contract, + capability, + ), + }, + } +} + +fn render_status_failure_message( + provider: &ProviderConfig, + reason: ProviderFailoverReason, + status_code: u16, + model: &str, + attempt: usize, + max_attempts: usize, + response_body: &Value, +) -> String { + let support_facts = provider.support_facts(); + let auth_support = support_facts.auth; + let region_endpoint_support = support_facts.region_endpoint; + let mut message = format!( + "provider returned status {status_code} for model `{model}` on attempt {attempt}/{max_attempts}: {response_body}" + ); + if matches!(reason, ProviderFailoverReason::AuthRejected) { + if let Some(hint) = auth_support.guidance_hint { + message.push(' '); + message.push_str(hint.as_str()); + } + if let Some(hint) = region_endpoint_support.request_failure_hint { + message.push(' '); + message.push_str(hint.as_str()); + } + } + message +} + +pub(super) async fn execute_model_request( + runtime: ModelRequestRuntime<'_>, + mut build_body: BuildBody, + mut parse_success: ParseSuccess, + missing_shape_fragment: &'static str, + mut pre_status_error: PreStatusError, +) -> Result +where + BuildBody: FnMut(CompletionPayloadMode) -> Value, + ParseSuccess: FnMut(&Value) -> Option, + PreStatusError: FnMut(&ProviderApiError) -> bool, +{ + let mut attempt = 0usize; + let mut backoff_ms = runtime.request_policy.initial_backoff_ms; + let mut payload_mode = + CompletionPayloadMode::default_for_contract(runtime.provider, runtime.runtime_contract); + let mut tried_payload_modes = vec![payload_mode]; + + loop { + attempt += 1; + let body = build_body(payload_mode); + let request_endpoint = + transport::resolve_request_endpoint(runtime.provider, runtime.endpoint, runtime.model); + let request_endpoint = + transport::resolve_request_url(runtime.provider, request_endpoint.as_str(), runtime.auth_context) + .map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + let body_bytes = transport::encode_json_request_body(&body).map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + let mut headers = runtime.headers.clone(); + transport::apply_json_request_defaults(&mut headers).map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + transport::apply_auth_profile_headers( + &mut headers, + Some(runtime.auth_profile), + runtime.request_auth_scheme, + ) + .map_err( + |error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + }, + )?; + let req = runtime + .client + .post(request_endpoint.as_str()) + .headers(headers) + .body(body_bytes.clone()) + .build() + .map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + + match transport::execute_request( + runtime.client, + req, + Some(body_bytes.as_slice()), + runtime.auth_context, + Some(transport::BedrockService::Runtime), + ) + .await + { + Ok(response) => { + let status = response.status(); + let response_headers = response.headers().clone(); + let response_body = transport::decode_response_body(response) + .await + .map_err(|error| { + build_model_request_error( + format!( + "provider response decode failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::ResponseDecodeFailure, + ProviderFailoverStage::ResponseDecode, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + + if status.is_success() { + let parsed = parse_success(&response_body).ok_or_else(|| { + build_model_request_error( + format!( + "provider response missing {missing_shape_fragment} for model `{model}` on attempt {attempt}/{max_attempts}: {response_body}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::ResponseShapeInvalid, + ProviderFailoverStage::ResponseShapeInvalid, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + return Ok(parsed); + } + + let api_error = parse_provider_api_error(&response_body); + if pre_status_error(&api_error) { + continue; + } + if let Some(next_mode) = adapt_payload_mode_for_error( + payload_mode, + runtime.provider, + runtime.runtime_contract, + &api_error, + ) && !tried_payload_modes.contains(&next_mode) + { + payload_mode = next_mode; + tried_payload_modes.push(next_mode); + continue; + } + + let status_code = status.as_u16(); + match plan_model_status_outcome( + status_code, + &response_headers, + &api_error, + attempt, + runtime.request_policy, + backoff_ms, + runtime.auto_model_mode, + runtime.runtime_contract, + runtime.capability, + ) { + ModelStatusOutcome::Retry { + delay_ms, + next_backoff_ms, + } => { + sleep(Duration::from_millis(delay_ms)).await; + backoff_ms = next_backoff_ms; + continue; + } + ModelStatusOutcome::TryNextModel => { + return Err(build_model_request_error( + format!( + "model `{}` rejected by provider endpoint; trying next candidate. status {status_code}: {response_body}", + runtime.model + ), + true, + ProviderFailoverReason::ModelMismatch, + ProviderFailoverStage::ModelCandidateRejected, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + Some(status_code), + Some(api_error.clone()), + )); + } + ModelStatusOutcome::Fail { reason } => { + return Err(build_model_request_error( + render_status_failure_message( + runtime.provider, + reason, + status_code, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + &response_body, + ), + false, + reason, + ProviderFailoverStage::StatusFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + Some(status_code), + Some(api_error.clone()), + )); + } + } + } + Err(transport::RequestExecutionError::Transport(error)) => { + if let Some((retry_delay_ms, next_backoff_ms)) = + plan_transport_error_retry(attempt, runtime.request_policy, &error, backoff_ms) + { + sleep(Duration::from_millis(retry_delay_ms)).await; + backoff_ms = next_backoff_ms; + continue; + } + let error_message = error.to_string(); + let mut message = format!( + "provider request failed for model `{}` on attempt {attempt}/{max_attempts}: {error_message}", + runtime.model, + max_attempts = runtime.request_policy.max_attempts + ); + if let Some(route_hint) = transport::render_transport_route_hint( + request_endpoint.as_str(), + error_message.as_str(), + error.is_timeout(), + error.is_connect(), + ) { + message.push(' '); + message.push_str(route_hint.as_str()); + } + return Err(build_model_request_error( + message, + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + )); + } + Err(transport::RequestExecutionError::Setup(error)) => { + return Err(build_model_request_error( + format!( + "provider request setup failed for model `{}` on attempt {attempt}/{max_attempts}: {error}", + runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + )); + } + } + } +} + +pub(super) async fn execute_openai_streaming_turn_request( + runtime: ModelRequestRuntime<'_>, + mut build_body: BuildBody, + messages: &[Value], + session_id: &str, + turn_id: &str, + event_sink: &dyn AcpTurnEventSink, +) -> Result +where + BuildBody: FnMut(CompletionPayloadMode) -> Value, +{ + let mut attempt = 0usize; + let mut backoff_ms = runtime.request_policy.initial_backoff_ms; + let mut payload_mode = + CompletionPayloadMode::default_for_contract(runtime.provider, runtime.runtime_contract); + let mut tried_payload_modes = vec![payload_mode]; + + loop { + attempt += 1; + let mut body = build_body(payload_mode); + force_stream_flag(&mut body); + let request_endpoint = + transport::resolve_request_endpoint(runtime.provider, runtime.endpoint, runtime.model); + let request_endpoint = transport::resolve_request_url( + runtime.provider, + request_endpoint.as_str(), + runtime.auth_context, + ) + .map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + let body_bytes = transport::encode_json_request_body(&body).map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + let mut headers = runtime.headers.clone(); + transport::apply_json_request_defaults(&mut headers).map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; transport::apply_auth_profile_headers( &mut headers, Some(runtime.auth_profile), runtime.request_auth_scheme, ) - .map_err( - |error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - }, - )?; - let req = runtime - .client - .post(request_endpoint.as_str()) - .headers(headers) - .body(body_bytes.clone()) - .build() - .map_err(|error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - - match transport::execute_request( - runtime.client, - req, - Some(body_bytes.as_slice()), - runtime.auth_context, - Some(transport::BedrockService::Runtime), - ) - .await - { - Ok(response) => { - let status = response.status(); - let response_headers = response.headers().clone(); - let response_body = transport::decode_response_body(response) - .await - .map_err(|error| { - build_model_request_error( - format!( - "provider response decode failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::ResponseDecodeFailure, - ProviderFailoverStage::ResponseDecode, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - - if status.is_success() { - let parsed = parse_success(&response_body).ok_or_else(|| { - build_model_request_error( - format!( - "provider response missing {missing_shape_fragment} for model `{model}` on attempt {attempt}/{max_attempts}: {response_body}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::ResponseShapeInvalid, - ProviderFailoverStage::ResponseShapeInvalid, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - return Ok(parsed); - } - - let api_error = parse_provider_api_error(&response_body); - if pre_status_error(&api_error) { - continue; - } - if let Some(next_mode) = adapt_payload_mode_for_error( - payload_mode, - runtime.provider, - runtime.runtime_contract, - &api_error, - ) && !tried_payload_modes.contains(&next_mode) - { - payload_mode = next_mode; - tried_payload_modes.push(next_mode); - continue; - } - - let status_code = status.as_u16(); - match plan_model_status_outcome( - status_code, - &response_headers, - &api_error, - attempt, - runtime.request_policy, - backoff_ms, - runtime.auto_model_mode, - runtime.runtime_contract, - runtime.capability, - ) { - ModelStatusOutcome::Retry { - delay_ms, - next_backoff_ms, - } => { - sleep(Duration::from_millis(delay_ms)).await; - backoff_ms = next_backoff_ms; - continue; - } - ModelStatusOutcome::TryNextModel => { - return Err(build_model_request_error( - format!( - "model `{}` rejected by provider endpoint; trying next candidate. status {status_code}: {response_body}", - runtime.model - ), - true, - ProviderFailoverReason::ModelMismatch, - ProviderFailoverStage::ModelCandidateRejected, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - Some(status_code), - Some(api_error.clone()), - )); - } - ModelStatusOutcome::Fail { reason } => { - return Err(build_model_request_error( - render_status_failure_message( - runtime.provider, - reason, - status_code, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - &response_body, - ), - false, - reason, - ProviderFailoverStage::StatusFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - Some(status_code), - Some(api_error.clone()), - )); - } - } - } - Err(transport::RequestExecutionError::Transport(error)) => { - if let Some((retry_delay_ms, next_backoff_ms)) = - plan_transport_error_retry(attempt, runtime.request_policy, &error, backoff_ms) - { - sleep(Duration::from_millis(retry_delay_ms)).await; - backoff_ms = next_backoff_ms; - continue; - } - let error_message = error.to_string(); - let mut message = format!( - "provider request failed for model `{}` on attempt {attempt}/{max_attempts}: {error_message}", - runtime.model, - max_attempts = runtime.request_policy.max_attempts - ); - if let Some(route_hint) = transport::render_transport_route_hint( - request_endpoint.as_str(), - error_message.as_str(), - error.is_timeout(), - error.is_connect(), - ) { - message.push(' '); - message.push_str(route_hint.as_str()); - } - return Err(build_model_request_error( - message, - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - )); - } - Err(transport::RequestExecutionError::Setup(error)) => { - return Err(build_model_request_error( - format!( - "provider request setup failed for model `{}` on attempt {attempt}/{max_attempts}: {error}", - runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - )); - } - } - } -} - -pub(super) async fn execute_streaming_model_request( - runtime: StreamingModelRequestRuntime<'_>, - mut build_body: BuildBody, - parse_stream_item: ParseStreamItem, -) -> Result>, ModelRequestError> -where - T: Unpin, - BuildBody: FnMut(CompletionPayloadMode) -> Value, - ParseStreamItem: FnMut(Value) -> Option + Unpin, -{ - let mut attempt = 0usize; - let mut backoff_ms = runtime.request_policy.initial_backoff_ms; - let mut payload_mode = - CompletionPayloadMode::default_for_contract(runtime.provider, runtime.runtime_contract); - let mut tried_payload_modes = vec![payload_mode]; - - loop { - attempt += 1; - let body = build_body(payload_mode); - let request_endpoint = - transport::resolve_request_endpoint(runtime.provider, runtime.endpoint, runtime.model); - let request_endpoint = transport::resolve_request_url( - runtime.provider, - request_endpoint.as_str(), - runtime.auth_context, - ) .map_err(|error| { build_model_request_error( format!( @@ -490,1023 +553,1441 @@ where None, ) })?; - let body_bytes = transport::encode_json_request_body(&body).map_err(|error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - let mut headers = runtime.headers.clone(); - transport::apply_json_request_defaults(&mut headers).map_err(|error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - transport::apply_auth_profile_headers( - &mut headers, - Some(runtime.auth_profile), - runtime.request_auth_scheme, - ) - .map_err( - |error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - }, - )?; - let req = runtime - .client - .post(request_endpoint.as_str()) - .headers(headers) - .body(body_bytes.clone()) - .build() - .map_err(|error| { - build_model_request_error( - format!( - "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - - match transport::execute_request( - runtime.client, - req, - Some(body_bytes.as_slice()), - runtime.auth_context, - Some(transport::BedrockService::Runtime), - ) - .await - { - Ok(response) => { - let status = response.status(); - let response_headers = response.headers().clone(); - - if status.is_success() { - let byte_stream = transport::decode_streaming_response(response); - let stream = SseByteStreamParser::new(Box::pin(byte_stream), parse_stream_item); - return Ok(stream); - } - - let response_body = transport::decode_response_body(response) - .await - .map_err(|error| { - build_model_request_error( - format!( - "provider response decode failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", - model = runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::ResponseDecodeFailure, - ProviderFailoverStage::ResponseDecode, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - ) - })?; - - let api_error = parse_provider_api_error(&response_body); - if let Some(next_mode) = adapt_payload_mode_for_error( - payload_mode, - runtime.provider, - runtime.runtime_contract, - &api_error, - ) && !tried_payload_modes.contains(&next_mode) - { - payload_mode = next_mode; - tried_payload_modes.push(next_mode); - continue; - } - - let status_code = status.as_u16(); - match plan_model_status_outcome( - status_code, - &response_headers, - &api_error, - attempt, - runtime.request_policy, - backoff_ms, - runtime.auto_model_mode, - runtime.runtime_contract, - runtime.capability, - ) { - ModelStatusOutcome::Retry { - delay_ms, - next_backoff_ms, - } => { - sleep(Duration::from_millis(delay_ms)).await; - backoff_ms = next_backoff_ms; - continue; - } - ModelStatusOutcome::TryNextModel => { - return Err(build_model_request_error( - format!( - "model `{}` rejected by provider endpoint; trying next candidate. status {status_code}: {response_body}", - runtime.model - ), - true, - ProviderFailoverReason::ModelMismatch, - ProviderFailoverStage::ModelCandidateRejected, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - Some(status_code), - Some(api_error.clone()), - )); - } - ModelStatusOutcome::Fail { reason } => { - return Err(build_model_request_error( - render_status_failure_message( - runtime.provider, - reason, - status_code, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - &response_body, - ), - false, - reason, - ProviderFailoverStage::StatusFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - Some(status_code), - Some(api_error.clone()), - )); - } - } - } - Err(transport::RequestExecutionError::Transport(error)) => { - if let Some((retry_delay_ms, next_backoff_ms)) = - plan_transport_error_retry(attempt, runtime.request_policy, &error, backoff_ms) - { - sleep(Duration::from_millis(retry_delay_ms)).await; - backoff_ms = next_backoff_ms; - continue; - } - let error_message = error.to_string(); - let mut message = format!( - "provider request failed for model `{}` on attempt {attempt}/{max_attempts}: {error_message}", - runtime.model, - max_attempts = runtime.request_policy.max_attempts - ); - if let Some(route_hint) = transport::render_transport_route_hint( - request_endpoint.as_str(), - error_message.as_str(), - error.is_timeout(), - error.is_connect(), - ) { - message.push(' '); - message.push_str(route_hint.as_str()); - } - return Err(build_model_request_error( - message, - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - )); - } - Err(transport::RequestExecutionError::Setup(error)) => { - return Err(build_model_request_error( - format!( - "provider request setup failed for model `{}` on attempt {attempt}/{max_attempts}: {error}", - runtime.model, - max_attempts = runtime.request_policy.max_attempts - ), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - runtime.model, - attempt, - runtime.request_policy.max_attempts, - None, - None, - )); - } - } - } -} - -struct SseByteStreamParser { - byte_stream: Pin> + Send>>, - parse_stream_item: ParseStreamItem, - line_buffer: Vec, - event_type: Option, - pending: VecDeque>, - _phantom: PhantomData, -} - -impl SseByteStreamParser -where - ParseStreamItem: FnMut(Value) -> Option, -{ - fn new( - byte_stream: Pin> + Send>>, - parse_stream_item: ParseStreamItem, - ) -> Self { - Self { - byte_stream, - parse_stream_item, - line_buffer: Vec::new(), - event_type: None, - pending: VecDeque::new(), - _phantom: PhantomData, - } - } -} - -impl Stream for SseByteStreamParser -where - T: Unpin, - ParseStreamItem: FnMut(Value) -> Option + Unpin, -{ - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - loop { - if let Some(item) = this.pending.pop_front() { - return Poll::Ready(Some(item)); - } - match this.byte_stream.as_mut().poll_next(cx) { - Poll::Ready(Some(Ok(bytes))) => { - for byte in bytes { - if byte == b'\n' { - let line = std::mem::take(&mut this.line_buffer); - let line_str = match from_utf8(&line) { - Ok(s) => s, - Err(e) => { - this.pending.push_back(Err(build_model_request_error( - format!("invalid UTF-8 in SSE stream: {e}"), - false, - ProviderFailoverReason::ResponseShapeInvalid, - ProviderFailoverStage::ResponseDecode, - "", - 1, - 1, - None, - None, - ))); - continue; - } - }; - let parsed_line = transport::parse_sse_line(line_str); - match parsed_line { - transport::SseLine::EventType { name } => { - this.event_type = Some(name); - } - transport::SseLine::Data { content } => { - let current_event_type = this.event_type.take(); - if !content.is_empty() { - match transport::SseStreamEvent::from_sse_lines( - current_event_type, - &[content], - ) { - Ok(Some(event)) => match event { - transport::SseStreamEvent::Message { - data, .. - } => { - let parse_fn = &mut this.parse_stream_item; - if let Some(item) = parse_fn(data) { - this.pending.push_back(Ok(item)); - } - } - transport::SseStreamEvent::Error { message } => { - this.pending.push_back(Err(build_model_request_error( - message, - false, - ProviderFailoverReason::ResponseShapeInvalid, - ProviderFailoverStage::ResponseDecode, - "", - 1, - 1, - None, - None, - ))); - } - transport::SseStreamEvent::Done => { - return Poll::Ready(None); - } - }, - Ok(None) => {} - Err(error) => { - this.pending - .push_back(Err(build_model_request_error( - format!( - "streaming event parse failed: {error}" - ), - false, - ProviderFailoverReason::ResponseShapeInvalid, - ProviderFailoverStage::ResponseDecode, - "", - 1, - 1, - None, - None, - ))); - } - } - } - } - transport::SseLine::Empty => {} - transport::SseLine::Comment => {} - transport::SseLine::Retry { .. } => {} - } - } else if byte != b'\r' { - this.line_buffer.push(byte); - } - } - } - Poll::Ready(Some(Err(e))) => { - return Poll::Ready(Some(Err(build_model_request_error( - format!("streaming response error: {:?}", e), - false, - ProviderFailoverReason::TransportFailure, - ProviderFailoverStage::TransportFailure, - "", - 1, - 1, - None, - None, - )))); - } - Poll::Ready(None) => { - return Poll::Ready(None); - } - Poll::Pending => { - return Poll::Pending; - } - } - } - } -} - -#[allow(clippy::result_large_err)] -pub(super) async fn execute_streaming_turn_request( - runtime: StreamingModelRequestRuntime<'_>, - build_body: impl FnMut(CompletionPayloadMode) -> Value + Unpin, - session_id: Option<&str>, - turn_id: Option<&str>, - _messages: &[Value], - on_token: StreamingTokenCallback, - mut pre_status_error: PreStatusError, -) -> Result -where - PreStatusError: FnMut(&ProviderApiError) -> bool, -{ - let model_name = runtime.model.to_owned(); - let stream = match execute_streaming_model_request(runtime, build_body, |data: Value| { - let event_type = data.get("type").and_then(|v| v.as_str())?; - if event_type == "content_block_start" { - let content_block = data.get("content_block")?; - if content_block.get("type").and_then(|v| v.as_str()) == Some("tool_use") { - let name = content_block - .get("name") - .and_then(|v| v.as_str())? - .to_owned(); - let id = content_block.get("id").and_then(|v| v.as_str())?.to_owned(); - let index = data.get("index").and_then(|v| v.as_u64())? as usize; - return Some(StreamingEvent::ToolCallStart { index, name, id }); - } - } - if event_type == "content_block_delta" { - let delta = data.get("delta")?; - let delta_type = delta.get("type").and_then(|v| v.as_str())?; - if delta_type == "text_delta" { - let text = delta.get("text").and_then(|v| v.as_str())?; - return Some(StreamingEvent::Text(text.to_owned())); - } - if delta_type == "input_json_delta" { - let partial = delta.get("partial_json").and_then(|v| v.as_str())?; - let index = data.get("index").and_then(|v| v.as_u64())? as usize; - return Some(StreamingEvent::ToolInputPartial { - index, - partial_json: partial.to_owned(), - }); - } - } - if event_type == "message_stop" { - return Some(StreamingEvent::Done); - } - if event_type == "message_start" || event_type == "message_delta" { - return Some(StreamingEvent::Meta(data)); - } - if event_type == "error" { - let message = data - .get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .unwrap_or("unknown streaming error") - .to_owned(); - return Some(StreamingEvent::StreamError(message)); - } - None - }) - .await - { - Ok(stream) => stream, - Err(error) => { - return Err(error); - } - }; - - let mut accumulator = StreamingAccumulator::default(); - futures_util::pin_mut!(stream); - while let Some(item) = futures_util::StreamExt::next(&mut stream).await { - match item { - Ok(StreamingEvent::Text(text)) => { - if let Some(ref callback) = on_token { - callback(StreamingCallbackData::Text { text: text.clone() }); - } - accumulator.text.push_str(&text); - } - Ok(StreamingEvent::ToolCallStart { index, name, id }) => { - if let Some(ref callback) = on_token { - callback(StreamingCallbackData::ToolCallStart { - index, - name: name.clone(), - id: id.clone(), - }); - } - accumulator.tool_calls.insert( - index, - ToolCallInfo { - name, - id, - input: String::new(), - }, - ); - } - Ok(StreamingEvent::ToolInputPartial { - index, - partial_json, - }) => { - if let Some(ref callback) = on_token { - callback(StreamingCallbackData::ToolCallInput { - index, - partial_json: partial_json.clone(), - }); - } - if let Some(tool_call) = accumulator.tool_calls.get_mut(&index) { - tool_call.input.push_str(&partial_json); - } - } - Ok(StreamingEvent::Meta(data)) => { - // Merge message_start and message_delta metadata for raw_meta - if let Some(obj) = data.as_object() { - if !accumulator.meta.is_object() { - accumulator.meta = serde_json::json!({}); - } - if let Some(meta) = accumulator.meta.as_object_mut() { - for (k, v) in obj { - meta.insert(k.clone(), v.clone()); - } - } - } - } - Ok(StreamingEvent::StreamError(message)) => { - accumulator.error = Some(build_model_request_error( - format!("Anthropic streaming error: {message}"), - false, - ProviderFailoverReason::ResponseShapeInvalid, - ProviderFailoverStage::ResponseDecode, - &model_name, - 1, - 1, - None, - None, - )); - } - Ok(StreamingEvent::Done) => { - accumulator.done = true; - } - Err(e) => { - if let Some(api_error) = &e.api_error - && pre_status_error(api_error) - { - return Err(e); - } - accumulator.error = Some(e); - } - } - if accumulator.done || accumulator.error.is_some() { - break; - } - } - - if let Some(error) = accumulator.error { - return Err(error); - } - - if !accumulator.done { - return Err(build_model_request_error( - "streaming response ended without message_stop event".to_owned(), - false, - ProviderFailoverReason::ResponseShapeInvalid, - ProviderFailoverStage::ResponseDecode, - &model_name, - 1, - 1, - None, - None, - )); - } - - let tool_intents = accumulator - .tool_calls - .values() - .map(|tool_call| { - let args_json = serde_json::from_str(&tool_call.input).map_err(|e| { - build_model_request_error( - format!("failed to parse tool call input: {}", e), - false, - ProviderFailoverReason::ResponseShapeInvalid, - ProviderFailoverStage::ResponseDecode, - &model_name, - 1, - 1, - None, - None, - ) - })?; - Ok(ToolIntent { - tool_name: tool_call.name.clone(), - args_json, - source: "provider_tool_call".to_owned(), - session_id: session_id.unwrap_or("").to_owned(), - turn_id: turn_id.unwrap_or("").to_owned(), - tool_call_id: tool_call.id.clone(), - }) - }) - .collect::, _>>()?; - - Ok(ProviderTurn { - assistant_text: accumulator.text, - tool_intents, - raw_meta: accumulator.meta, - }) -} - -#[derive(Clone)] -pub(crate) struct ToolCallInfo { - pub name: String, - pub id: String, - pub input: String, -} - -#[derive(Default)] -pub(crate) struct StreamingAccumulator { - text: String, - tool_calls: std::collections::BTreeMap, - meta: Value, - done: bool, - error: Option, -} - -pub(crate) enum StreamingEvent { - Text(String), - ToolCallStart { - index: usize, - name: String, - id: String, - }, - ToolInputPartial { - index: usize, - partial_json: String, - }, - Meta(Value), - StreamError(String), - Done, -} - -#[derive(Clone)] -pub enum StreamingCallbackData { - Text { - text: String, - }, - ToolCallStart { - index: usize, - name: String, - id: String, - }, - ToolCallInput { - index: usize, - partial_json: String, - }, -} - -pub type StreamingTokenCallback = Option>; - -#[cfg(test)] -mod tests { - use super::*; - use crate::provider::contracts::provider_runtime_contract; - use serde_json::json; - - struct ModelStatusCase { - status_code: u16, - attempt: usize, - auto_model_mode: bool, - api_error: ProviderApiError, - expected: ModelStatusOutcome, - } - - #[test] - fn plan_model_status_outcome_matrix_is_stable() { - let provider = ProviderConfig::default(); - let request_policy = policy::ProviderRequestPolicy::from_config(&provider); - let runtime_contract = provider_runtime_contract(&provider); - let headers = reqwest::header::HeaderMap::new(); - let backoff_ms = request_policy.initial_backoff_ms; - - let cases = vec![ - ModelStatusCase { - status_code: 429, - attempt: 1, - auto_model_mode: true, - api_error: ProviderApiError::default(), - expected: ModelStatusOutcome::Retry { - delay_ms: backoff_ms, - next_backoff_ms: policy::next_backoff_ms( - backoff_ms, - request_policy.max_backoff_ms, - ), - }, - }, - ModelStatusCase { - status_code: 404, - attempt: 1, - auto_model_mode: true, - api_error: ProviderApiError { - code: Some("model_not_found".to_owned()), - ..ProviderApiError::default() - }, - expected: ModelStatusOutcome::TryNextModel, - }, - ModelStatusCase { - status_code: 404, - attempt: 1, - auto_model_mode: false, - api_error: ProviderApiError { - code: Some("model_not_found".to_owned()), - ..ProviderApiError::default() - }, - expected: ModelStatusOutcome::Fail { - reason: ProviderFailoverReason::ModelMismatch, - }, - }, - ModelStatusCase { - status_code: 503, - attempt: request_policy.max_attempts, - auto_model_mode: true, - api_error: ProviderApiError::default(), - expected: ModelStatusOutcome::Fail { - reason: ProviderFailoverReason::ProviderOverloaded, - }, - }, - ModelStatusCase { - status_code: 400, - attempt: 1, - auto_model_mode: true, - api_error: ProviderApiError { - message: Some("unsupported parameter: max_completion_tokens".to_owned()), - ..ProviderApiError::default() - }, - expected: ModelStatusOutcome::Fail { - reason: ProviderFailoverReason::PayloadIncompatible, - }, - }, - ]; - - for case in cases { - let observed = plan_model_status_outcome( - case.status_code, - &headers, - &case.api_error, - case.attempt, - &request_policy, - backoff_ms, - case.auto_model_mode, - runtime_contract, - runtime_contract.capability, - ); - assert_eq!( - observed, case.expected, - "unexpected status outcome for status={}, attempt={}, auto_mode={}, error={:?}", - case.status_code, case.attempt, case.auto_model_mode, case.api_error - ); - } - } - - #[test] - fn render_status_failure_message_includes_auth_guidance_for_auth_rejection() { - let provider = ProviderConfig { - kind: crate::config::ProviderKind::ByteplusCoding, - ..ProviderConfig::default() - }; - - let message = render_status_failure_message( - &provider, - ProviderFailoverReason::AuthRejected, - 401, - "doubao-seed-1-6-thinking-250715", - 1, - 3, - &json!({ - "error": { - "message": "invalid api key" - } - }), - ); - - assert!(message.contains("BytePlus")); - assert!(message.contains("BYTEPLUS_API_KEY")); - assert!(message.contains("Authorization: Bearer ")); - } - - #[test] - fn render_status_failure_message_includes_region_hint_for_auth_rejection() { - let provider = ProviderConfig { - kind: crate::config::ProviderKind::Minimax, - ..ProviderConfig::default() - }; - - let message = render_status_failure_message( - &provider, - ProviderFailoverReason::AuthRejected, - 401, - "MiniMax-M2.7", - 1, - 3, - &json!({ - "error": { - "message": "invalid api key" - } - }), - ); - - assert!(message.contains("provider.base_url")); - assert!(message.contains("https://api.minimax.io")); - assert!(message.contains("https://api.minimaxi.com")); - } - - #[test] - fn render_status_failure_message_ignores_models_endpoint_override_for_request_auth_hint() { - let mut provider = ProviderConfig { - kind: crate::config::ProviderKind::Zai, - ..ProviderConfig::default() - }; - provider.set_models_endpoint(Some("https://open.bigmodel.cn/v1/models".to_owned())); - - let message = render_status_failure_message( - &provider, - ProviderFailoverReason::AuthRejected, - 401, - "glm-4.5", - 1, - 3, - &json!({ - "error": { - "message": "invalid api key" - } - }), - ); - - assert!(message.contains("provider.base_url")); - assert!(!message.contains("provider.models_endpoint")); - assert!(message.contains("https://api.z.ai")); - assert!(message.contains("https://open.bigmodel.cn")); - } - - #[test] - fn sse_stream_event_assembles_anthropic_delta_correctly() { - use crate::provider::transport::SseStreamEvent; - let event_type = Some("content_block_delta".to_owned()); - let data_lines = vec!["{\"type\":\"text_delta\",\"text\":\"Hello\"}".to_owned()]; - let event = SseStreamEvent::from_sse_lines(event_type, &data_lines); - - match event { - Ok(Some(SseStreamEvent::Message { data, event_type })) => { - assert_eq!(event_type.as_deref(), Some("content_block_delta")); - let data: &serde_json::Value = &data; - assert_eq!( - data.get("type") - .and_then(|v: &serde_json::Value| v.as_str()), - Some("text_delta") - ); - assert_eq!( - data.get("text") - .and_then(|v: &serde_json::Value| v.as_str()), - Some("Hello") - ); - } - other => panic!("expected SseStreamEvent::Message, got {:?}", other), - } - } - - #[test] - fn streaming_accumulator_accumulates_text_deltas() { - let mut accumulator = StreamingAccumulator::default(); - - accumulator.text.push_str("Hello"); - assert_eq!(accumulator.text, "Hello"); - assert!(!accumulator.done); - assert!(accumulator.error.is_none()); - - accumulator.text.push_str(" World"); - assert_eq!(accumulator.text, "Hello World"); - - accumulator.done = true; - assert!(accumulator.done); - } - - #[test] - fn streaming_accumulator_accumulates_tool_input_partials() { - let mut accumulator = StreamingAccumulator::default(); - - accumulator.tool_calls.insert( - 0, - ToolCallInfo { - name: "get_weather".to_owned(), - id: "call_123".to_owned(), - input: "{\"location".to_owned(), - }, - ); - accumulator.tool_calls.insert( - 1, - ToolCallInfo { - name: "other_tool".to_owned(), - id: "call_456".to_owned(), - input: "{\"arg".to_owned(), - }, - ); - - assert_eq!(accumulator.tool_calls.len(), 2); - assert_eq!( - accumulator.tool_calls.get(&0).map(|t| &t.name), - Some(&"get_weather".to_owned()) - ); - assert_eq!( - accumulator.tool_calls.get(&1).map(|t| &t.input), - Some(&"{\"arg".to_owned()) - ); - } - - #[test] - fn streaming_event_parsing_text_delta() { - let data = json!({"type": "content_block_delta", "delta": {"type": "text_delta", "text": "Hello"}}); - - let event_type = data.get("type").and_then(|v| v.as_str()); - let delta = data.get("delta"); - let delta_type = delta.and_then(|d| d.get("type")).and_then(|v| v.as_str()); - let text = delta.and_then(|d| d.get("text")).and_then(|v| v.as_str()); - - assert_eq!(event_type, Some("content_block_delta")); - assert_eq!(delta_type, Some("text_delta")); - assert_eq!(text, Some("Hello")); - } - - #[test] - fn streaming_event_parsing_input_json_delta() { - let data = json!({ - "type": "content_block_delta", - "index": 0, - "delta": {"type": "input_json_delta", "partial_json": "{\"location\":\"NYC\"}"} - }); - - let event_type = data.get("type").and_then(|v| v.as_str()); - let delta = data.get("delta"); - let delta_type = delta.and_then(|d| d.get("type")).and_then(|v| v.as_str()); - let partial_json = delta - .and_then(|d| d.get("partial_json")) - .and_then(|v| v.as_str()); - let index = data - .get("index") - .and_then(|v| v.as_u64()) - .map(|v| v as usize); - - assert_eq!(event_type, Some("content_block_delta")); - assert_eq!(delta_type, Some("input_json_delta")); - assert_eq!(partial_json, Some("{\"location\":\"NYC\"}")); - assert_eq!(index, Some(0)); - } - - #[test] - fn streaming_event_parsing_message_delta_stop() { - let data = json!({"type": "message_delta", "delta": {"type": "message_stop"}}); - - let event_type = data.get("type").and_then(|v| v.as_str()); - let delta = data.get("delta"); - let delta_type = delta.and_then(|d| d.get("type")).and_then(|v| v.as_str()); - - assert_eq!(event_type, Some("message_delta")); - assert_eq!(delta_type, Some("message_stop")); - } - - #[test] - fn streaming_event_to_token_event_conversion() { - use crate::acp::StreamingTokenEvent; - use crate::acp::TokenDelta; - - let text_event = StreamingTokenEvent { - event_type: "content_block_delta".to_owned(), - delta: TokenDelta { - text: Some("Hello".to_owned()), - tool_call: None, - }, - index: None, - }; - - let json = serde_json::to_string(&text_event).expect("should serialize"); - assert!(json.contains("Hello")); - assert!(json.contains("content_block_delta")); - } - - #[test] - fn streaming_token_event_serialize_for_cli() { - use crate::acp::StreamingTokenEvent; - use crate::acp::TokenDelta; - use crate::acp::ToolCallDelta; - - let tool_event = StreamingTokenEvent { - event_type: "content_block_delta".to_owned(), - delta: TokenDelta { - text: None, - tool_call: Some(ToolCallDelta { - name: Some("get_weather".to_owned()), - args: Some("{\"location\":\"NYC\"}".to_owned()), - id: Some("call_123".to_owned()), - }), - }, - index: Some(0), - }; - - let json = serde_json::to_string(&tool_event).expect("should serialize"); - assert!(json.contains("get_weather")); - assert!(json.contains("NYC")); - } - - #[test] - fn streaming_accumulator_with_callback() { - let mut accumulator = StreamingAccumulator::default(); - - accumulator.text.push_str("Hello"); - accumulator.text.push_str(" World"); - - let final_text = accumulator.text.clone(); - assert_eq!(final_text, "Hello World"); - } -} + let req = runtime + .client + .post(request_endpoint.as_str()) + .headers(headers) + .body(body_bytes.clone()) + .build() + .map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + + match transport::execute_request( + runtime.client, + req, + Some(body_bytes.as_slice()), + runtime.auth_context, + Some(transport::BedrockService::Runtime), + ) + .await + { + Ok(response) => { + let status = response.status(); + let response_headers = response.headers().clone(); + if status.is_success() { + match decode_openai_streaming_turn( + response, messages, session_id, turn_id, event_sink, + ) + .await + { + Ok(turn) => return Ok(turn), + Err(error) => { + return Err(build_model_request_error( + format!( + "provider streaming decode failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::ResponseDecodeFailure, + ProviderFailoverStage::ResponseDecode, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + )); + } + } + } + + let response_body = transport::decode_response_body(response) + .await + .map_err(|error| { + build_model_request_error( + format!( + "provider response decode failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::ResponseDecodeFailure, + ProviderFailoverStage::ResponseDecode, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + + let api_error = parse_provider_api_error(&response_body); + if let Some(next_mode) = adapt_payload_mode_for_error( + payload_mode, + runtime.provider, + runtime.runtime_contract, + &api_error, + ) && !tried_payload_modes.contains(&next_mode) + { + payload_mode = next_mode; + tried_payload_modes.push(next_mode); + continue; + } + + let status_code = status.as_u16(); + match plan_model_status_outcome( + status_code, + &response_headers, + &api_error, + attempt, + runtime.request_policy, + backoff_ms, + runtime.auto_model_mode, + runtime.runtime_contract, + runtime.capability, + ) { + ModelStatusOutcome::Retry { + delay_ms, + next_backoff_ms, + } => { + sleep(Duration::from_millis(delay_ms)).await; + backoff_ms = next_backoff_ms; + continue; + } + ModelStatusOutcome::TryNextModel => { + return Err(build_model_request_error( + format!( + "model `{}` rejected by provider endpoint; trying next candidate. status {status_code}: {response_body}", + runtime.model + ), + true, + ProviderFailoverReason::ModelMismatch, + ProviderFailoverStage::ModelCandidateRejected, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + Some(status_code), + Some(api_error.clone()), + )); + } + ModelStatusOutcome::Fail { reason } => { + return Err(build_model_request_error( + render_status_failure_message( + runtime.provider, + reason, + status_code, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + &response_body, + ), + false, + reason, + ProviderFailoverStage::StatusFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + Some(status_code), + Some(api_error.clone()), + )); + } + } + } + Err(transport::RequestExecutionError::Transport(error)) => { + if let Some((retry_delay_ms, next_backoff_ms)) = + plan_transport_error_retry(attempt, runtime.request_policy, &error, backoff_ms) + { + sleep(Duration::from_millis(retry_delay_ms)).await; + backoff_ms = next_backoff_ms; + continue; + } + return Err(build_model_request_error( + format!( + "provider request failed for model `{}` on attempt {attempt}/{max_attempts}: {error}", + runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + )); + } + Err(transport::RequestExecutionError::Setup(error)) => { + return Err(build_model_request_error( + format!( + "provider request setup failed for model `{}` on attempt {attempt}/{max_attempts}: {error}", + runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + )); + } + } + } +} + +pub(super) async fn execute_streaming_model_request( + runtime: StreamingModelRequestRuntime<'_>, + mut build_body: BuildBody, + parse_stream_item: ParseStreamItem, +) -> Result>, ModelRequestError> +where + T: Unpin, + BuildBody: FnMut(CompletionPayloadMode) -> Value, + ParseStreamItem: FnMut(Value) -> Option + Unpin, +{ + let mut attempt = 0usize; + let mut backoff_ms = runtime.request_policy.initial_backoff_ms; + let mut payload_mode = + CompletionPayloadMode::default_for_contract(runtime.provider, runtime.runtime_contract); + let mut tried_payload_modes = vec![payload_mode]; + + loop { + attempt += 1; + let body = build_body(payload_mode); + let request_endpoint = + transport::resolve_request_endpoint(runtime.provider, runtime.endpoint, runtime.model); + let request_endpoint = transport::resolve_request_url( + runtime.provider, + request_endpoint.as_str(), + runtime.auth_context, + ) + .map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + let body_bytes = transport::encode_json_request_body(&body).map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + let mut headers = runtime.headers.clone(); + transport::apply_json_request_defaults(&mut headers).map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + transport::apply_auth_profile_headers( + &mut headers, + Some(runtime.auth_profile), + runtime.request_auth_scheme, + ) + .map_err( + |error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + }, + )?; + let req = runtime + .client + .post(request_endpoint.as_str()) + .headers(headers) + .body(body_bytes.clone()) + .build() + .map_err(|error| { + build_model_request_error( + format!( + "provider request setup failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + + match transport::execute_request( + runtime.client, + req, + Some(body_bytes.as_slice()), + runtime.auth_context, + Some(transport::BedrockService::Runtime), + ) + .await + { + Ok(response) => { + let status = response.status(); + let response_headers = response.headers().clone(); + + if status.is_success() { + let byte_stream = transport::decode_streaming_response(response); + let stream = SseByteStreamParser::new(Box::pin(byte_stream), parse_stream_item); + return Ok(stream); + } + + let response_body = transport::decode_response_body(response) + .await + .map_err(|error| { + build_model_request_error( + format!( + "provider response decode failed for model `{model}` on attempt {attempt}/{max_attempts}: {error}", + model = runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::ResponseDecodeFailure, + ProviderFailoverStage::ResponseDecode, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + ) + })?; + + let api_error = parse_provider_api_error(&response_body); + if let Some(next_mode) = adapt_payload_mode_for_error( + payload_mode, + runtime.provider, + runtime.runtime_contract, + &api_error, + ) && !tried_payload_modes.contains(&next_mode) + { + payload_mode = next_mode; + tried_payload_modes.push(next_mode); + continue; + } + + let status_code = status.as_u16(); + match plan_model_status_outcome( + status_code, + &response_headers, + &api_error, + attempt, + runtime.request_policy, + backoff_ms, + runtime.auto_model_mode, + runtime.runtime_contract, + runtime.capability, + ) { + ModelStatusOutcome::Retry { + delay_ms, + next_backoff_ms, + } => { + sleep(Duration::from_millis(delay_ms)).await; + backoff_ms = next_backoff_ms; + continue; + } + ModelStatusOutcome::TryNextModel => { + return Err(build_model_request_error( + format!( + "model `{}` rejected by provider endpoint; trying next candidate. status {status_code}: {response_body}", + runtime.model + ), + true, + ProviderFailoverReason::ModelMismatch, + ProviderFailoverStage::ModelCandidateRejected, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + Some(status_code), + Some(api_error.clone()), + )); + } + ModelStatusOutcome::Fail { reason } => { + return Err(build_model_request_error( + render_status_failure_message( + runtime.provider, + reason, + status_code, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + &response_body, + ), + false, + reason, + ProviderFailoverStage::StatusFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + Some(status_code), + Some(api_error.clone()), + )); + } + } + } + Err(transport::RequestExecutionError::Transport(error)) => { + if let Some((retry_delay_ms, next_backoff_ms)) = + plan_transport_error_retry(attempt, runtime.request_policy, &error, backoff_ms) + { + sleep(Duration::from_millis(retry_delay_ms)).await; + backoff_ms = next_backoff_ms; + continue; + } + let error_message = error.to_string(); + let mut message = format!( + "provider request failed for model `{}` on attempt {attempt}/{max_attempts}: {error_message}", + runtime.model, + max_attempts = runtime.request_policy.max_attempts + ); + if let Some(route_hint) = transport::render_transport_route_hint( + request_endpoint.as_str(), + error_message.as_str(), + error.is_timeout(), + error.is_connect(), + ) { + message.push(' '); + message.push_str(route_hint.as_str()); + } + return Err(build_model_request_error( + message, + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + )); + } + Err(transport::RequestExecutionError::Setup(error)) => { + return Err(build_model_request_error( + format!( + "provider request setup failed for model `{}` on attempt {attempt}/{max_attempts}: {error}", + runtime.model, + max_attempts = runtime.request_policy.max_attempts + ), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + runtime.model, + attempt, + runtime.request_policy.max_attempts, + None, + None, + )); + } + } + } +} + +struct SseByteStreamParser { + byte_stream: Pin> + Send>>, + parse_stream_item: ParseStreamItem, + line_buffer: Vec, + event_type: Option, + pending: VecDeque>, + _phantom: PhantomData, +} + +impl SseByteStreamParser +where + ParseStreamItem: FnMut(Value) -> Option, +{ + fn new( + byte_stream: Pin> + Send>>, + parse_stream_item: ParseStreamItem, + ) -> Self { + Self { + byte_stream, + parse_stream_item, + line_buffer: Vec::new(), + event_type: None, + pending: VecDeque::new(), + _phantom: PhantomData, + } + } +} + +impl Stream for SseByteStreamParser +where + T: Unpin, + ParseStreamItem: FnMut(Value) -> Option + Unpin, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + loop { + if let Some(item) = this.pending.pop_front() { + return Poll::Ready(Some(item)); + } + match this.byte_stream.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => { + for byte in bytes { + if byte == b'\n' { + let line = std::mem::take(&mut this.line_buffer); + let line_str = match from_utf8(&line) { + Ok(s) => s, + Err(e) => { + this.pending.push_back(Err(build_model_request_error( + format!("invalid UTF-8 in SSE stream: {e}"), + false, + ProviderFailoverReason::ResponseShapeInvalid, + ProviderFailoverStage::ResponseDecode, + "", + 1, + 1, + None, + None, + ))); + continue; + } + }; + let parsed_line = transport::parse_sse_line(line_str); + match parsed_line { + transport::SseLine::EventType { name } => { + this.event_type = Some(name); + } + transport::SseLine::Data { content } => { + let current_event_type = this.event_type.take(); + if !content.is_empty() { + match transport::SseStreamEvent::from_sse_lines( + current_event_type, + &[content], + ) { + Ok(Some(event)) => match event { + transport::SseStreamEvent::Message { + data, .. + } => { + let parse_fn = &mut this.parse_stream_item; + if let Some(item) = parse_fn(data) { + this.pending.push_back(Ok(item)); + } + } + transport::SseStreamEvent::Error { message } => { + this.pending.push_back(Err(build_model_request_error( + message, + false, + ProviderFailoverReason::ResponseShapeInvalid, + ProviderFailoverStage::ResponseDecode, + "", + 1, + 1, + None, + None, + ))); + } + transport::SseStreamEvent::Done => { + return Poll::Ready(None); + } + }, + Ok(None) => {} + Err(error) => { + this.pending + .push_back(Err(build_model_request_error( + format!( + "streaming event parse failed: {error}" + ), + false, + ProviderFailoverReason::ResponseShapeInvalid, + ProviderFailoverStage::ResponseDecode, + "", + 1, + 1, + None, + None, + ))); + } + } + } + } + transport::SseLine::Empty => {} + transport::SseLine::Comment => {} + transport::SseLine::Retry { .. } => {} + } + } else if byte != b'\r' { + this.line_buffer.push(byte); + } + } + } + Poll::Ready(Some(Err(e))) => { + return Poll::Ready(Some(Err(build_model_request_error( + format!("streaming response error: {:?}", e), + false, + ProviderFailoverReason::TransportFailure, + ProviderFailoverStage::TransportFailure, + "", + 1, + 1, + None, + None, + )))); + } + Poll::Ready(None) => { + return Poll::Ready(None); + } + Poll::Pending => { + return Poll::Pending; + } + } + } + } +} + +fn force_stream_flag(body: &mut Value) { + if let Some(object) = body.as_object_mut() { + object.insert("stream".to_owned(), Value::Bool(true)); + } +} + +#[derive(Default)] +struct PartialToolCall { + id: Option, + name: String, + arguments: String, +} + +async fn decode_openai_streaming_turn( + response: reqwest::Response, + messages: &[Value], + session_id: &str, + turn_id: &str, + event_sink: &dyn AcpTurnEventSink, +) -> CliResult { + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + + if !content_type.contains("text/event-stream") { + let body = transport::decode_response_body(response).await?; + return super::shape::extract_provider_turn_with_scope_and_messages( + &body, + Some(session_id), + Some(turn_id), + messages, + ) + .ok_or_else(|| "provider response missing choices[0].message".to_owned()); + } + + let mut assistant_text = String::new(); + let mut tool_calls: BTreeMap = BTreeMap::new(); + let mut done_received = false; + let mut pending = String::new(); + let mut stream = response.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|error| format!("read streaming response chunk failed: {error}"))?; + pending.push_str(String::from_utf8_lossy(&chunk).as_ref()); + + while let Some(newline_index) = pending.find('\n') { + let line = pending[..newline_index].trim_end_matches('\r').to_owned(); + pending.drain(..=newline_index); + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with(':') { + continue; + } + let Some(payload) = trimmed.strip_prefix("data:") else { + continue; + }; + let payload = payload.trim(); + if payload == "[DONE]" { + done_received = true; + continue; + } + + let event: Value = serde_json::from_str(payload).map_err(|error| { + format!("decode streaming event failed: {error}; payload={payload}") + })?; + let Some(choice) = event + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + else { + continue; + }; + + if let Some(delta) = choice.get("delta") { + if let Some(content) = delta.get("content").and_then(Value::as_str) + && !content.is_empty() + { + assistant_text.push_str(content); + event_sink.on_event(&serde_json::json!({ + "type": "text", + "content": content, + }))?; + } + + if let Some(tool_call_deltas) = delta.get("tool_calls").and_then(Value::as_array) { + for tool_call_delta in tool_call_deltas { + let index = tool_call_delta + .get("index") + .and_then(Value::as_u64) + .unwrap_or(tool_calls.len() as u64) + as usize; + let partial = tool_calls.entry(index).or_default(); + if partial.id.is_none() { + partial.id = tool_call_delta + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + } + if let Some(function) = tool_call_delta.get("function") { + if let Some(name) = function.get("name").and_then(Value::as_str) { + partial.name.push_str(name); + } + if let Some(arguments) = + function.get("arguments").and_then(Value::as_str) + { + partial.arguments.push_str(arguments); + } + } + } + } + } + } + } + + if !pending.trim().is_empty() { + let trimmed = pending.trim(); + if let Some(payload) = trimmed.strip_prefix("data:") { + let payload = payload.trim(); + if payload != "[DONE]" { + let _event: Value = serde_json::from_str(payload).map_err(|error| { + format!("decode trailing streaming event failed: {error}; payload={payload}") + })?; + } else { + done_received = true; + } + } + } + + if !done_received && assistant_text.is_empty() && tool_calls.is_empty() { + return Err("stream completed without any assistant deltas".to_owned()); + } + + let tool_calls = tool_calls + .into_iter() + .map(|(index, partial)| { + serde_json::json!({ + "index": index, + "id": partial.id.unwrap_or_else(|| format!("call_{index}")), + "type": "function", + "function": { + "name": partial.name, + "arguments": partial.arguments, + } + }) + }) + .collect::>(); + + let mut message = serde_json::json!({ + "role": "assistant", + "content": assistant_text, + }); + if !tool_calls.is_empty() + && let Some(object) = message.as_object_mut() + { + object.insert("tool_calls".to_owned(), Value::Array(tool_calls)); + } + + let body = serde_json::json!({ + "choices": [{ + "message": message + }] + }); + + super::shape::extract_provider_turn_with_scope_and_messages( + &body, + Some(session_id), + Some(turn_id), + messages, + ) + .ok_or_else(|| "provider stream missing reconstructable assistant message".to_owned()) +} + +#[allow(clippy::result_large_err)] +pub(super) async fn execute_streaming_turn_request( + runtime: StreamingModelRequestRuntime<'_>, + build_body: impl FnMut(CompletionPayloadMode) -> Value + Unpin, + session_id: Option<&str>, + turn_id: Option<&str>, + _messages: &[Value], + on_token: StreamingTokenCallback, + mut pre_status_error: PreStatusError, +) -> Result +where + PreStatusError: FnMut(&ProviderApiError) -> bool, +{ + let model_name = runtime.model.to_owned(); + let stream = match execute_streaming_model_request(runtime, build_body, |data: Value| { + let event_type = data.get("type").and_then(|v| v.as_str())?; + if event_type == "content_block_start" { + let content_block = data.get("content_block")?; + if content_block.get("type").and_then(|v| v.as_str()) == Some("tool_use") { + let name = content_block + .get("name") + .and_then(|v| v.as_str())? + .to_owned(); + let id = content_block.get("id").and_then(|v| v.as_str())?.to_owned(); + let index = data.get("index").and_then(|v| v.as_u64())? as usize; + return Some(StreamingEvent::ToolCallStart { index, name, id }); + } + } + if event_type == "content_block_delta" { + let delta = data.get("delta")?; + let delta_type = delta.get("type").and_then(|v| v.as_str())?; + if delta_type == "text_delta" { + let text = delta.get("text").and_then(|v| v.as_str())?; + return Some(StreamingEvent::Text(text.to_owned())); + } + if delta_type == "input_json_delta" { + let partial = delta.get("partial_json").and_then(|v| v.as_str())?; + let index = data.get("index").and_then(|v| v.as_u64())? as usize; + return Some(StreamingEvent::ToolInputPartial { + index, + partial_json: partial.to_owned(), + }); + } + } + if event_type == "message_stop" { + return Some(StreamingEvent::Done); + } + if event_type == "message_start" || event_type == "message_delta" { + return Some(StreamingEvent::Meta(data)); + } + if event_type == "error" { + let message = data + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unknown streaming error") + .to_owned(); + return Some(StreamingEvent::StreamError(message)); + } + None + }) + .await + { + Ok(stream) => stream, + Err(error) => { + return Err(error); + } + }; + + let mut accumulator = StreamingAccumulator::default(); + futures_util::pin_mut!(stream); + while let Some(item) = futures_util::StreamExt::next(&mut stream).await { + match item { + Ok(StreamingEvent::Text(text)) => { + if let Some(ref callback) = on_token { + callback(StreamingCallbackData::Text { text: text.clone() }); + } + accumulator.text.push_str(&text); + } + Ok(StreamingEvent::ToolCallStart { index, name, id }) => { + if let Some(ref callback) = on_token { + callback(StreamingCallbackData::ToolCallStart { + index, + name: name.clone(), + id: id.clone(), + }); + } + accumulator.tool_calls.insert( + index, + ToolCallInfo { + name, + id, + input: String::new(), + }, + ); + } + Ok(StreamingEvent::ToolInputPartial { + index, + partial_json, + }) => { + if let Some(ref callback) = on_token { + callback(StreamingCallbackData::ToolCallInput { + index, + partial_json: partial_json.clone(), + }); + } + if let Some(tool_call) = accumulator.tool_calls.get_mut(&index) { + tool_call.input.push_str(&partial_json); + } + } + Ok(StreamingEvent::Meta(data)) => { + // Merge message_start and message_delta metadata for raw_meta + if let Some(obj) = data.as_object() { + if !accumulator.meta.is_object() { + accumulator.meta = serde_json::json!({}); + } + if let Some(meta) = accumulator.meta.as_object_mut() { + for (k, v) in obj { + meta.insert(k.clone(), v.clone()); + } + } + } + } + Ok(StreamingEvent::StreamError(message)) => { + accumulator.error = Some(build_model_request_error( + format!("Anthropic streaming error: {message}"), + false, + ProviderFailoverReason::ResponseShapeInvalid, + ProviderFailoverStage::ResponseDecode, + &model_name, + 1, + 1, + None, + None, + )); + } + Ok(StreamingEvent::Done) => { + accumulator.done = true; + } + Err(e) => { + if let Some(api_error) = &e.api_error + && pre_status_error(api_error) + { + return Err(e); + } + accumulator.error = Some(e); + } + } + if accumulator.done || accumulator.error.is_some() { + break; + } + } + + if let Some(error) = accumulator.error { + return Err(error); + } + + if !accumulator.done { + return Err(build_model_request_error( + "streaming response ended without message_stop event".to_owned(), + false, + ProviderFailoverReason::ResponseShapeInvalid, + ProviderFailoverStage::ResponseDecode, + &model_name, + 1, + 1, + None, + None, + )); + } + + let tool_intents = accumulator + .tool_calls + .values() + .map(|tool_call| { + let args_json = serde_json::from_str(&tool_call.input).map_err(|e| { + build_model_request_error( + format!("failed to parse tool call input: {}", e), + false, + ProviderFailoverReason::ResponseShapeInvalid, + ProviderFailoverStage::ResponseDecode, + &model_name, + 1, + 1, + None, + None, + ) + })?; + Ok(ToolIntent { + tool_name: tool_call.name.clone(), + args_json, + source: "provider_tool_call".to_owned(), + session_id: session_id.unwrap_or("").to_owned(), + turn_id: turn_id.unwrap_or("").to_owned(), + tool_call_id: tool_call.id.clone(), + }) + }) + .collect::, _>>()?; + + Ok(ProviderTurn { + assistant_text: accumulator.text, + tool_intents, + raw_meta: accumulator.meta, + }) +} + +#[derive(Clone)] +pub(crate) struct ToolCallInfo { + pub name: String, + pub id: String, + pub input: String, +} + +#[derive(Default)] +pub(crate) struct StreamingAccumulator { + text: String, + tool_calls: std::collections::BTreeMap, + meta: Value, + done: bool, + error: Option, +} + +pub(crate) enum StreamingEvent { + Text(String), + ToolCallStart { + index: usize, + name: String, + id: String, + }, + ToolInputPartial { + index: usize, + partial_json: String, + }, + Meta(Value), + StreamError(String), + Done, +} + +#[derive(Clone)] +pub enum StreamingCallbackData { + Text { + text: String, + }, + ToolCallStart { + index: usize, + name: String, + id: String, + }, + ToolCallInput { + index: usize, + partial_json: String, + }, +} + +pub type StreamingTokenCallback = Option>; + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::contracts::provider_runtime_contract; + use serde_json::json; + + struct ModelStatusCase { + status_code: u16, + attempt: usize, + auto_model_mode: bool, + api_error: ProviderApiError, + expected: ModelStatusOutcome, + } + + #[test] + fn plan_model_status_outcome_matrix_is_stable() { + let provider = ProviderConfig::default(); + let request_policy = policy::ProviderRequestPolicy::from_config(&provider); + let runtime_contract = provider_runtime_contract(&provider); + let headers = reqwest::header::HeaderMap::new(); + let backoff_ms = request_policy.initial_backoff_ms; + + let cases = vec![ + ModelStatusCase { + status_code: 429, + attempt: 1, + auto_model_mode: true, + api_error: ProviderApiError::default(), + expected: ModelStatusOutcome::Retry { + delay_ms: backoff_ms, + next_backoff_ms: policy::next_backoff_ms( + backoff_ms, + request_policy.max_backoff_ms, + ), + }, + }, + ModelStatusCase { + status_code: 404, + attempt: 1, + auto_model_mode: true, + api_error: ProviderApiError { + code: Some("model_not_found".to_owned()), + ..ProviderApiError::default() + }, + expected: ModelStatusOutcome::TryNextModel, + }, + ModelStatusCase { + status_code: 404, + attempt: 1, + auto_model_mode: false, + api_error: ProviderApiError { + code: Some("model_not_found".to_owned()), + ..ProviderApiError::default() + }, + expected: ModelStatusOutcome::Fail { + reason: ProviderFailoverReason::ModelMismatch, + }, + }, + ModelStatusCase { + status_code: 503, + attempt: request_policy.max_attempts, + auto_model_mode: true, + api_error: ProviderApiError::default(), + expected: ModelStatusOutcome::Fail { + reason: ProviderFailoverReason::ProviderOverloaded, + }, + }, + ModelStatusCase { + status_code: 400, + attempt: 1, + auto_model_mode: true, + api_error: ProviderApiError { + message: Some("unsupported parameter: max_completion_tokens".to_owned()), + ..ProviderApiError::default() + }, + expected: ModelStatusOutcome::Fail { + reason: ProviderFailoverReason::PayloadIncompatible, + }, + }, + ]; + + for case in cases { + let observed = plan_model_status_outcome( + case.status_code, + &headers, + &case.api_error, + case.attempt, + &request_policy, + backoff_ms, + case.auto_model_mode, + runtime_contract, + runtime_contract.capability, + ); + assert_eq!( + observed, case.expected, + "unexpected status outcome for status={}, attempt={}, auto_mode={}, error={:?}", + case.status_code, case.attempt, case.auto_model_mode, case.api_error + ); + } + } + + #[test] + fn render_status_failure_message_includes_auth_guidance_for_auth_rejection() { + let provider = ProviderConfig { + kind: crate::config::ProviderKind::ByteplusCoding, + ..ProviderConfig::default() + }; + + let message = render_status_failure_message( + &provider, + ProviderFailoverReason::AuthRejected, + 401, + "doubao-seed-1-6-thinking-250715", + 1, + 3, + &json!({ + "error": { + "message": "invalid api key" + } + }), + ); + + assert!(message.contains("BytePlus")); + assert!(message.contains("BYTEPLUS_API_KEY")); + assert!(message.contains("Authorization: Bearer ")); + } + + #[test] + fn render_status_failure_message_includes_region_hint_for_auth_rejection() { + let provider = ProviderConfig { + kind: crate::config::ProviderKind::Minimax, + ..ProviderConfig::default() + }; + + let message = render_status_failure_message( + &provider, + ProviderFailoverReason::AuthRejected, + 401, + "MiniMax-M2.7", + 1, + 3, + &json!({ + "error": { + "message": "invalid api key" + } + }), + ); + + assert!(message.contains("provider.base_url")); + assert!(message.contains("https://api.minimax.io")); + assert!(message.contains("https://api.minimaxi.com")); + } + + #[test] + fn render_status_failure_message_ignores_models_endpoint_override_for_request_auth_hint() { + let mut provider = ProviderConfig { + kind: crate::config::ProviderKind::Zai, + ..ProviderConfig::default() + }; + provider.set_models_endpoint(Some("https://open.bigmodel.cn/v1/models".to_owned())); + + let message = render_status_failure_message( + &provider, + ProviderFailoverReason::AuthRejected, + 401, + "glm-4.5", + 1, + 3, + &json!({ + "error": { + "message": "invalid api key" + } + }), + ); + + assert!(message.contains("provider.base_url")); + assert!(!message.contains("provider.models_endpoint")); + assert!(message.contains("https://api.z.ai")); + assert!(message.contains("https://open.bigmodel.cn")); + } + + #[test] + fn sse_stream_event_assembles_anthropic_delta_correctly() { + use crate::provider::transport::SseStreamEvent; + let event_type = Some("content_block_delta".to_owned()); + let data_lines = vec!["{\"type\":\"text_delta\",\"text\":\"Hello\"}".to_owned()]; + let event = SseStreamEvent::from_sse_lines(event_type, &data_lines); + + match event { + Ok(Some(SseStreamEvent::Message { data, event_type })) => { + assert_eq!(event_type.as_deref(), Some("content_block_delta")); + let data: &serde_json::Value = &data; + assert_eq!( + data.get("type") + .and_then(|v: &serde_json::Value| v.as_str()), + Some("text_delta") + ); + assert_eq!( + data.get("text") + .and_then(|v: &serde_json::Value| v.as_str()), + Some("Hello") + ); + } + other => panic!("expected SseStreamEvent::Message, got {:?}", other), + } + } + + #[test] + fn streaming_accumulator_accumulates_text_deltas() { + let mut accumulator = StreamingAccumulator::default(); + + accumulator.text.push_str("Hello"); + assert_eq!(accumulator.text, "Hello"); + assert!(!accumulator.done); + assert!(accumulator.error.is_none()); + + accumulator.text.push_str(" World"); + assert_eq!(accumulator.text, "Hello World"); + + accumulator.done = true; + assert!(accumulator.done); + } + + #[test] + fn streaming_accumulator_accumulates_tool_input_partials() { + let mut accumulator = StreamingAccumulator::default(); + + accumulator.tool_calls.insert( + 0, + ToolCallInfo { + name: "get_weather".to_owned(), + id: "call_123".to_owned(), + input: "{\"location".to_owned(), + }, + ); + accumulator.tool_calls.insert( + 1, + ToolCallInfo { + name: "other_tool".to_owned(), + id: "call_456".to_owned(), + input: "{\"arg".to_owned(), + }, + ); + + assert_eq!(accumulator.tool_calls.len(), 2); + assert_eq!( + accumulator.tool_calls.get(&0).map(|t| &t.name), + Some(&"get_weather".to_owned()) + ); + assert_eq!( + accumulator.tool_calls.get(&1).map(|t| &t.input), + Some(&"{\"arg".to_owned()) + ); + } + + #[test] + fn streaming_event_parsing_text_delta() { + let data = json!({"type": "content_block_delta", "delta": {"type": "text_delta", "text": "Hello"}}); + + let event_type = data.get("type").and_then(|v| v.as_str()); + let delta = data.get("delta"); + let delta_type = delta.and_then(|d| d.get("type")).and_then(|v| v.as_str()); + let text = delta.and_then(|d| d.get("text")).and_then(|v| v.as_str()); + + assert_eq!(event_type, Some("content_block_delta")); + assert_eq!(delta_type, Some("text_delta")); + assert_eq!(text, Some("Hello")); + } + + #[test] + fn streaming_event_parsing_input_json_delta() { + let data = json!({ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": "{\"location\":\"NYC\"}"} + }); + + let event_type = data.get("type").and_then(|v| v.as_str()); + let delta = data.get("delta"); + let delta_type = delta.and_then(|d| d.get("type")).and_then(|v| v.as_str()); + let partial_json = delta + .and_then(|d| d.get("partial_json")) + .and_then(|v| v.as_str()); + let index = data + .get("index") + .and_then(|v| v.as_u64()) + .map(|v| v as usize); + + assert_eq!(event_type, Some("content_block_delta")); + assert_eq!(delta_type, Some("input_json_delta")); + assert_eq!(partial_json, Some("{\"location\":\"NYC\"}")); + assert_eq!(index, Some(0)); + } + + #[test] + fn streaming_event_parsing_message_delta_stop() { + let data = json!({"type": "message_delta", "delta": {"type": "message_stop"}}); + + let event_type = data.get("type").and_then(|v| v.as_str()); + let delta = data.get("delta"); + let delta_type = delta.and_then(|d| d.get("type")).and_then(|v| v.as_str()); + + assert_eq!(event_type, Some("message_delta")); + assert_eq!(delta_type, Some("message_stop")); + } + + #[test] + fn streaming_event_to_token_event_conversion() { + use crate::acp::StreamingTokenEvent; + use crate::acp::TokenDelta; + + let text_event = StreamingTokenEvent { + event_type: "content_block_delta".to_owned(), + delta: TokenDelta { + text: Some("Hello".to_owned()), + tool_call: None, + }, + index: None, + }; + + let json = serde_json::to_string(&text_event).expect("should serialize"); + assert!(json.contains("Hello")); + assert!(json.contains("content_block_delta")); + } + + #[test] + fn streaming_token_event_serialize_for_cli() { + use crate::acp::StreamingTokenEvent; + use crate::acp::TokenDelta; + use crate::acp::ToolCallDelta; + + let tool_event = StreamingTokenEvent { + event_type: "content_block_delta".to_owned(), + delta: TokenDelta { + text: None, + tool_call: Some(ToolCallDelta { + name: Some("get_weather".to_owned()), + args: Some("{\"location\":\"NYC\"}".to_owned()), + id: Some("call_123".to_owned()), + }), + }, + index: Some(0), + }; + + let json = serde_json::to_string(&tool_event).expect("should serialize"); + assert!(json.contains("get_weather")); + assert!(json.contains("NYC")); + } + + #[test] + fn streaming_accumulator_with_callback() { + let mut accumulator = StreamingAccumulator::default(); + + accumulator.text.push_str("Hello"); + accumulator.text.push_str(" World"); + + let final_text = accumulator.text.clone(); + assert_eq!(final_text, "Hello World"); + } +} diff --git a/crates/app/src/provider/tests.rs b/crates/app/src/provider/tests.rs index 5ca88a858..29a18c134 100644 --- a/crates/app/src/provider/tests.rs +++ b/crates/app/src/provider/tests.rs @@ -387,6 +387,7 @@ async fn request_turn_auto_model_rejects_missing_volcengine_credentials_before_t "role": "user", "content": "ping" })], + None, ProviderRuntimeBinding::direct(), ) .await @@ -2988,6 +2989,7 @@ async fn responses_turn_falls_back_to_chat_completions_for_compatible_endpoints( "role": "user", "content": "turn ping" })], + None, ProviderRuntimeBinding::direct(), ) .await @@ -3057,6 +3059,7 @@ async fn responses_turn_does_not_fallback_for_generic_gateway_failures() { "role": "user", "content": "turn ping" })], + None, ProviderRuntimeBinding::direct(), ) .await diff --git a/crates/daemon/src/command_kind.rs b/crates/daemon/src/command_kind.rs index 24b119248..60c3b7bfe 100644 --- a/crates/daemon/src/command_kind.rs +++ b/crates/daemon/src/command_kind.rs @@ -86,6 +86,7 @@ impl Commands { Self::Gateway { .. } => "gateway", Self::Feishu { .. } => "feishu", Self::Completions { .. } => "completions", + Self::Web { .. } => "web", Self::WorkUnit { .. } => "work_unit", } } diff --git a/crates/daemon/src/doctor_security_cli.rs b/crates/daemon/src/doctor_security_cli.rs index 3eedf85b6..3ce3f8ad0 100644 --- a/crates/daemon/src/doctor_security_cli.rs +++ b/crates/daemon/src/doctor_security_cli.rs @@ -1,3 +1,4 @@ +#[cfg(unix)] use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -1635,6 +1636,7 @@ mod tests { use crate::test_support::ScopedEnv; use loongclaw_contracts::SecretRef; + use std::fs; use std::path::PathBuf; use std::process::Command; use std::sync::MutexGuard; diff --git a/crates/daemon/src/gateway/control.rs b/crates/daemon/src/gateway/control.rs index d833f96c2..5647620fa 100644 --- a/crates/daemon/src/gateway/control.rs +++ b/crates/daemon/src/gateway/control.rs @@ -1,1012 +1,1012 @@ -use std::{ - fs, - fs::OpenOptions, - io::Write, - net::{Ipv4Addr, SocketAddrV4}, - path::{Path, PathBuf}, - sync::{Arc, Mutex}, -}; - -use axum::{ - Json, Router, - extract::{Query, State}, - http::{HeaderMap, StatusCode, header::AUTHORIZATION}, - routing::{get, post}, -}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use tokio::{ - net::TcpListener, - sync::{oneshot, watch}, - task::JoinHandle, -}; - -use crate::mvp::acp::AcpSessionManager; -use crate::mvp::config::LoongClawConfig; -use crate::{ - CliResult, build_channels_cli_json_payload, - collect_runtime_snapshot_cli_state_from_loaded_config, mvp, supervisor::LoadedSupervisorConfig, -}; - -use super::api_acp::{handle_acp_dispatch, handle_acp_observability, handle_acp_status}; -use super::api_events::handle_events; -use super::api_health::handle_health; -use super::api_turn::handle_turn; -use super::event_bus::GatewayEventBus; -use super::read_models::{ - GatewayChannelInventoryReadModel, GatewayOperatorSummaryReadModel, - GatewayRuntimeSnapshotReadModel, build_acp_observability_read_model, - build_acp_session_list_read_model, build_acp_status_read_model, - build_operator_summary_read_model, build_runtime_snapshot_read_model, -}; -use super::state::{ - GatewayControlSurfaceBinding, GatewayStopRequestOutcome, gateway_control_token_path, - load_gateway_owner_status, request_gateway_stop, -}; - -const GATEWAY_CONTROL_TOKEN_FILE_MODE: u32 = 0o600; -const GATEWAY_CONTROL_RUNTIME_DIR_MODE: u32 = 0o700; -const GATEWAY_ACP_SESSION_LIST_DEFAULT_LIMIT: usize = 50; -const GATEWAY_ACP_SESSION_LIST_MAX_LIMIT: usize = 200; - -type GatewayControlJsonResponse = (StatusCode, Json); - -#[derive(Debug, Default, Deserialize)] -struct GatewayAcpSessionsQuery { - limit: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct GatewayAcpStatusQuery { - session: Option, - conversation_id: Option, - route_session_id: Option, -} - -#[derive(Clone)] -pub(crate) struct GatewayControlAppState { - pub(crate) runtime_dir: PathBuf, - pub(crate) config_path: String, - pub(crate) bearer_token: String, - pub(crate) channel_inventory: Arc, - pub(crate) runtime_snapshot: Arc, - pub(crate) event_bus: Option, - pub(crate) acp_manager: Option>, - pub(crate) config: Option, -} - -impl GatewayControlAppState { - /// Minimal state for tests that don't need ACP. - pub fn test_minimal(bearer_token: String) -> Self { - use super::read_models::*; - use serde_json::json; - - let channel_inventory = GatewayChannelInventoryReadModel { - config: String::new(), - schema: GatewayChannelInventorySchema { - version: 1, - primary_channel_view: "channel_surfaces", - catalog_view: "channel_catalog", - legacy_channel_views: &[], - }, - channels: vec![], - catalog_only_channels: vec![], - channel_catalog: vec![], - channel_surfaces: vec![], - }; - let runtime_snapshot = GatewayRuntimeSnapshotReadModel { - config: String::new(), - schema: GatewayRuntimeSnapshotSchema { - version: 1, - surface: "test", - purpose: "test", - }, - provider: json!({}), - context_engine: json!({}), - memory_system: json!({}), - acp: json!({}), - channels: GatewayRuntimeSnapshotChannelsReadModel { - enabled_channel_ids: vec![], - enabled_service_channel_ids: vec![], - inventory: channel_inventory.clone(), - }, - tool_runtime: json!({}), - tools: GatewayRuntimeSnapshotToolsReadModel { - visible_tool_count: 0, - visible_tool_names: vec![], - capability_snapshot_sha256: String::new(), - capability_snapshot: String::new(), - tool_calling: super::read_models::GatewayToolCallingReadModel { - availability: "inactive".to_owned(), - structured_tool_schema_enabled: false, - effective_tool_schema_mode: "enabled_with_downgrade".to_owned(), - active_model: String::new(), - reason: "no runtime-visible tools are enabled".to_owned(), - }, - }, - runtime_plugins: json!({}), - external_skills: json!({}), - }; - Self { - runtime_dir: PathBuf::from("/tmp/test"), - config_path: String::new(), - bearer_token, - channel_inventory: Arc::new(channel_inventory), - runtime_snapshot: Arc::new(runtime_snapshot), - event_bus: None, - acp_manager: None, - config: None, - } - } -} - -struct GatewayControlSurfaceRuntime { - exit_sender: watch::Sender>>, - shutdown_sender: Mutex>>, - join_handle: Mutex>>>, -} - -#[derive(Clone)] -pub struct GatewayControlSurface { - binding: GatewayControlSurfaceBinding, - runtime: Arc, -} - -impl GatewayControlSurface { - pub fn binding(&self) -> &GatewayControlSurfaceBinding { - &self.binding - } - - pub async fn wait_for_unexpected_exit(&self) -> CliResult { - let exit_result = self.wait_for_exit_result().await?; - match exit_result { - Ok(()) => Err("gateway control surface exited unexpectedly".to_owned()), - Err(error) => Err(error), - } - } - - pub async fn shutdown(&self) -> CliResult<()> { - let shutdown_sender = { - let sender_guard = self.runtime.shutdown_sender.lock(); - let mut sender_guard = sender_guard.map_err(|error| { - format!("gateway control surface shutdown lock poisoned: {error}") - })?; - sender_guard.take() - }; - if let Some(shutdown_sender) = shutdown_sender { - let _ = shutdown_sender.send(()); - } - - let join_handle = { - let join_guard = self.runtime.join_handle.lock(); - let mut join_guard = join_guard - .map_err(|error| format!("gateway control surface join lock poisoned: {error}"))?; - join_guard.take() - }; - let Some(join_handle) = join_handle else { - return Ok(()); - }; - - join_handle - .await - .map_err(|error| format!("gateway control surface task failed to join: {error}"))? - } - - async fn wait_for_exit_result(&self) -> CliResult> { - let mut exit_receiver = self.runtime.exit_sender.subscribe(); - let initial_result = exit_receiver.borrow().clone(); - if let Some(initial_result) = initial_result { - return Ok(initial_result); - } - - exit_receiver - .changed() - .await - .map_err(|error| format!("gateway control surface exit watch failed: {error}"))?; - - let exit_result = exit_receiver.borrow().clone(); - exit_result - .ok_or_else(|| "gateway control surface exited without reporting a result".to_owned()) - } -} - -pub async fn start_gateway_control_surface( - runtime_dir: &Path, - loaded_config: &LoadedSupervisorConfig, - acp_manager: Option>, -) -> CliResult { - let channel_inventory = build_gateway_channel_inventory_read_model(loaded_config)?; - let runtime_snapshot = build_gateway_runtime_snapshot_read_model(loaded_config)?; - let bearer_token = new_gateway_control_bearer_token(); - let token_path = gateway_control_token_path(runtime_dir); - - write_gateway_control_token_file(token_path.as_path(), bearer_token.as_str())?; - - let listener_address = gateway_control_listener_address(); - let listener_result = TcpListener::bind(listener_address).await; - let listener = match listener_result { - Ok(listener) => listener, - Err(error) => { - let bind_error = format!("bind gateway control surface failed: {error}"); - let cleanup_result = remove_gateway_control_token_file(token_path.as_path()); - let final_error = merge_gateway_control_errors(bind_error, cleanup_result.err()); - return Err(final_error); - } - }; - - let local_address_result = listener.local_addr(); - let local_address = match local_address_result { - Ok(local_address) => local_address, - Err(error) => { - let address_error = - format!("read gateway control surface local address failed: {error}"); - let cleanup_result = remove_gateway_control_token_file(token_path.as_path()); - let final_error = merge_gateway_control_errors(address_error, cleanup_result.err()); - return Err(final_error); - } - }; - - let bind_address = local_address.ip().to_string(); - let port = local_address.port(); - let binding = GatewayControlSurfaceBinding { - bind_address, - port, - token_path: token_path.clone(), - }; - - let event_bus = if acp_manager.is_some() { - Some(GatewayEventBus::new(256)) - } else { - None - }; - - let app_state = GatewayControlAppState { - runtime_dir: runtime_dir.to_path_buf(), - config_path: loaded_config.resolved_path.display().to_string(), - bearer_token, - channel_inventory: Arc::new(channel_inventory), - runtime_snapshot: Arc::new(runtime_snapshot), - event_bus, - acp_manager, - config: Some(loaded_config.config.clone()), - }; - let app_state = Arc::new(app_state); - let router = build_gateway_control_router(app_state); - - let (shutdown_sender, shutdown_receiver) = oneshot::channel(); - let (exit_sender, _) = watch::channel::>>(None); - let exit_sender_for_task = exit_sender.clone(); - let token_path_for_task = token_path; - let join_handle = tokio::spawn(async move { - let server = axum::serve(listener, router); - let server = server.with_graceful_shutdown(async move { - let _ = shutdown_receiver.await; - }); - let server_result = server - .await - .map_err(|error| format!("gateway control surface server failed: {error}")); - let cleanup_result = remove_gateway_control_token_file(token_path_for_task.as_path()); - let final_result = combine_gateway_control_task_results(server_result, cleanup_result); - let _ = exit_sender_for_task.send(Some(final_result.clone())); - final_result - }); - - let runtime = GatewayControlSurfaceRuntime { - exit_sender, - shutdown_sender: Mutex::new(Some(shutdown_sender)), - join_handle: Mutex::new(Some(join_handle)), - }; - let runtime = Arc::new(runtime); - - Ok(GatewayControlSurface { binding, runtime }) -} - -fn build_gateway_control_router(app_state: Arc) -> Router { - Router::new() - .route("/api/gateway/status", get(handle_gateway_status)) - .route("/api/gateway/channels", get(handle_gateway_channels)) - .route( - "/api/gateway/runtime-snapshot", - get(handle_gateway_runtime_snapshot), - ) - .route( - "/api/gateway/operator-summary", - get(handle_gateway_operator_summary), - ) - .route( - "/api/gateway/acp/sessions", - get(handle_gateway_acp_sessions), - ) - .route("/api/gateway/acp/status", get(handle_gateway_acp_status)) - .route( - "/api/gateway/acp/observability", - get(handle_gateway_acp_observability), - ) - .route("/api/gateway/stop", post(handle_gateway_stop)) - .route("/v1/status", get(handle_gateway_status)) - .route("/v1/channels", get(handle_gateway_channels)) - .route("/v1/runtime/snapshot", get(handle_gateway_runtime_snapshot)) - .route("/v1/acp/status", get(handle_acp_status)) - .route("/v1/acp/observability", get(handle_acp_observability)) - .route("/v1/acp/dispatch", get(handle_acp_dispatch)) - .route("/v1/events", get(handle_events)) - .route("/v1/turn", post(handle_turn)) - .route("/health", get(handle_health)) - .with_state(app_state) -} - -async fn handle_gateway_status( - headers: HeaderMap, - State(app_state): State>, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let status = load_gateway_owner_status(app_state.runtime_dir.as_path()); - let Some(status) = status else { - return json_error( - StatusCode::SERVICE_UNAVAILABLE, - "status_unavailable", - "gateway owner status is unavailable", - ); - }; - - let payload_result = serialize_json_value(&status, "gateway status payload"); - match payload_result { - Ok(payload) => json_response(StatusCode::OK, payload), - Err(error) => json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "serialize_failed", - error.as_str(), - ), - } -} - -async fn handle_gateway_channels( - headers: HeaderMap, - State(app_state): State>, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let payload = serialize_json_value( - app_state.channel_inventory.as_ref(), - "gateway channels payload", - ); - match payload { - Ok(payload) => json_response(StatusCode::OK, payload), - Err(error) => json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "serialize_failed", - error.as_str(), - ), - } -} - -async fn handle_gateway_runtime_snapshot( - headers: HeaderMap, - State(app_state): State>, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let payload = serialize_json_value( - app_state.runtime_snapshot.as_ref(), - "gateway runtime snapshot payload", - ); - match payload { - Ok(payload) => json_response(StatusCode::OK, payload), - Err(error) => json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "serialize_failed", - error.as_str(), - ), - } -} - -async fn handle_gateway_operator_summary( - headers: HeaderMap, - State(app_state): State>, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let status = load_gateway_owner_status(app_state.runtime_dir.as_path()); - let Some(status) = status else { - return json_error( - StatusCode::SERVICE_UNAVAILABLE, - "status_unavailable", - "gateway owner status is unavailable", - ); - }; - - let summary = build_gateway_operator_summary_read_model( - &status, - app_state.channel_inventory.as_ref(), - app_state.runtime_snapshot.as_ref(), - ); - let payload = serialize_json_value(&summary, "gateway operator summary payload"); - match payload { - Ok(payload) => json_response(StatusCode::OK, payload), - Err(error) => json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "serialize_failed", - error.as_str(), - ), - } -} - -async fn handle_gateway_acp_sessions( - headers: HeaderMap, - State(app_state): State>, - Query(query): Query, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let manager = match gateway_control_acp_manager(app_state.as_ref()) { - Ok(manager) => manager, - Err(error) => { - return json_error( - StatusCode::SERVICE_UNAVAILABLE, - "acp_unavailable", - error.as_str(), - ); - } - }; - - let sessions_result = manager.list_sessions(); - let mut sessions = match sessions_result { - Ok(sessions) => sessions, - Err(error) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "acp_sessions_unavailable", - error.as_str(), - ); - } - }; - - sort_gateway_acp_sessions(sessions.as_mut_slice()); - let matched_count = sessions.len(); - let limit = gateway_acp_session_list_limit(query.limit); - sessions.truncate(limit); - - let payload = build_acp_session_list_read_model( - app_state.config_path.as_str(), - matched_count, - sessions.as_slice(), - ); - let payload = match serialize_json_value(&payload, "gateway ACP sessions payload") { - Ok(payload) => payload, - Err(error) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "serialize_failed", - error.as_str(), - ); - } - }; - - json_response(StatusCode::OK, payload) -} - -async fn handle_gateway_acp_status( - headers: HeaderMap, - State(app_state): State>, - Query(query): Query, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let config = match gateway_control_config(app_state.as_ref()) { - Ok(config) => config, - Err(error) => { - return json_error( - StatusCode::SERVICE_UNAVAILABLE, - "acp_unavailable", - error.as_str(), - ); - } - }; - let manager = match gateway_control_acp_manager(app_state.as_ref()) { - Ok(manager) => manager, - Err(error) => { - return json_error( - StatusCode::SERVICE_UNAVAILABLE, - "acp_unavailable", - error.as_str(), - ); - } - }; - - let resolved_session_key = crate::resolve_acp_status_session_key( - config, - query.session.as_deref(), - query.conversation_id.as_deref(), - query.route_session_id.as_deref(), - ); - let resolved_session_key = match resolved_session_key { - Ok(resolved_session_key) => resolved_session_key, - Err(error) if is_gateway_acp_not_found_error(error.as_str()) => { - return json_error(StatusCode::NOT_FOUND, "not_found", error.as_str()); - } - Err(error) => { - return json_error(StatusCode::BAD_REQUEST, "invalid_selector", error.as_str()); - } - }; - - let status_result = manager - .get_status(config, resolved_session_key.as_str()) - .await; - let status = match status_result { - Ok(status) => status, - Err(error) if is_gateway_acp_not_found_error(error.as_str()) => { - return json_error(StatusCode::NOT_FOUND, "not_found", error.as_str()); - } - Err(error) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "acp_status_unavailable", - error.as_str(), - ); - } - }; - - let payload = build_acp_status_read_model( - app_state.config_path.as_str(), - query.session.as_deref(), - query.conversation_id.as_deref(), - query.route_session_id.as_deref(), - resolved_session_key.as_str(), - &status, - ); - let payload = match serialize_json_value(&payload, "gateway ACP status payload") { - Ok(payload) => payload, - Err(error) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "serialize_failed", - error.as_str(), - ); - } - }; - - json_response(StatusCode::OK, payload) -} - -async fn handle_gateway_acp_observability( - headers: HeaderMap, - State(app_state): State>, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let config = match gateway_control_config(app_state.as_ref()) { - Ok(config) => config, - Err(error) => { - return json_error( - StatusCode::SERVICE_UNAVAILABLE, - "acp_unavailable", - error.as_str(), - ); - } - }; - let manager = match gateway_control_acp_manager(app_state.as_ref()) { - Ok(manager) => manager, - Err(error) => { - return json_error( - StatusCode::SERVICE_UNAVAILABLE, - "acp_unavailable", - error.as_str(), - ); - } - }; - - let snapshot_result = manager.observability_snapshot(config).await; - let snapshot = match snapshot_result { - Ok(snapshot) => snapshot, - Err(error) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "acp_observability_unavailable", - error.as_str(), - ); - } - }; - - let payload = build_acp_observability_read_model(app_state.config_path.as_str(), &snapshot); - let payload = match serialize_json_value(&payload, "gateway ACP observability payload") { - Ok(payload) => payload, - Err(error) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "serialize_failed", - error.as_str(), - ); - } - }; - - json_response(StatusCode::OK, payload) -} - -async fn handle_gateway_stop( - headers: HeaderMap, - State(app_state): State>, -) -> GatewayControlJsonResponse { - if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { - return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); - } - - let stop_result = request_gateway_stop(app_state.runtime_dir.as_path()); - let outcome = match stop_result { - Ok(outcome) => outcome, - Err(error) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "stop_failed", - error.as_str(), - ); - } - }; - - let response_status = gateway_stop_outcome_status(outcome); - let response_message = gateway_stop_outcome_message(outcome); - let payload = json!({ - "outcome": gateway_stop_outcome_code(outcome), - "message": response_message, - }); - json_response(response_status, payload) -} - -pub(crate) fn is_gateway_acp_not_found_error(error: &str) -> bool { - let is_session_error = error.starts_with("ACP session `"); - let is_conversation_error = error.starts_with("ACP conversation `"); - let is_route_error = error.starts_with("ACP route session `"); - let has_registration_marker = error.contains(" is not registered"); - let is_lookup_error = is_session_error || is_conversation_error || is_route_error; - is_lookup_error && has_registration_marker -} - -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - let mut result = 0u8; - for (x, y) in a.iter().zip(b.iter()) { - result |= x ^ y; - } - result == 0 -} - -pub(crate) fn authorize_request_from_state( - headers: &HeaderMap, - app_state: &GatewayControlAppState, -) -> CliResult<()> { - authorize_request(headers, &app_state.bearer_token) -} - -fn authorize_request(headers: &HeaderMap, expected_token: &str) -> CliResult<()> { - let authorization_header = headers.get(AUTHORIZATION); - let Some(authorization_header) = authorization_header else { - return Err("missing Authorization header".to_owned()); - }; - - let authorization_text = authorization_header - .to_str() - .map_err(|error| format!("invalid Authorization header encoding: {error}"))?; - let bearer_prefix = "Bearer "; - let provided_token = authorization_text.strip_prefix(bearer_prefix); - let Some(provided_token) = provided_token else { - return Err("Authorization header must use Bearer auth".to_owned()); - }; - - if !constant_time_eq(provided_token.as_bytes(), expected_token.as_bytes()) { - return Err("invalid gateway bearer token".to_owned()); - } - - Ok(()) -} - -fn build_gateway_channel_inventory_read_model( - loaded_config: &LoadedSupervisorConfig, -) -> CliResult { - let config_path = loaded_config.resolved_path.display().to_string(); - let inventory = mvp::channel::channel_inventory(&loaded_config.config); - let read_model = build_channels_cli_json_payload(config_path.as_str(), &inventory); - Ok(read_model) -} - -fn build_gateway_runtime_snapshot_read_model( - loaded_config: &LoadedSupervisorConfig, -) -> CliResult { - let snapshot = collect_runtime_snapshot_cli_state_from_loaded_config(loaded_config)?; - let read_model = build_runtime_snapshot_read_model(&snapshot); - Ok(read_model) -} - -fn build_gateway_operator_summary_read_model( - status: &super::state::GatewayOwnerStatus, - channel_inventory: &GatewayChannelInventoryReadModel, - runtime_snapshot: &GatewayRuntimeSnapshotReadModel, -) -> GatewayOperatorSummaryReadModel { - build_operator_summary_read_model(status, channel_inventory, runtime_snapshot) -} - -fn gateway_control_config(app_state: &GatewayControlAppState) -> CliResult<&LoongClawConfig> { - let config = app_state - .config - .as_ref() - .ok_or_else(|| "gateway ACP config is unavailable".to_owned())?; - Ok(config) -} - -fn gateway_control_acp_manager( - app_state: &GatewayControlAppState, -) -> CliResult<&AcpSessionManager> { - let manager = app_state - .acp_manager - .as_deref() - .ok_or_else(|| "gateway ACP session manager is unavailable".to_owned())?; - Ok(manager) -} - -fn gateway_acp_session_list_limit(requested_limit: Option) -> usize { - let requested_limit = requested_limit.unwrap_or(GATEWAY_ACP_SESSION_LIST_DEFAULT_LIMIT); - requested_limit.clamp(1, GATEWAY_ACP_SESSION_LIST_MAX_LIMIT) -} - -fn sort_gateway_acp_sessions(sessions: &mut [crate::mvp::acp::AcpSessionMetadata]) { - sessions.sort_by(|left, right| { - let activity_order = right.last_activity_ms.cmp(&left.last_activity_ms); - if activity_order == std::cmp::Ordering::Equal { - return left.session_key.cmp(&right.session_key); - } - activity_order - }); -} - -fn serialize_json_value(value: &T, context: &str) -> CliResult { - serde_json::to_value(value).map_err(|error| format!("serialize {context} failed: {error}")) -} - -fn gateway_control_listener_address() -> SocketAddrV4 { - let bind_address = Ipv4Addr::LOCALHOST; - let bind_port = 0_u16; - SocketAddrV4::new(bind_address, bind_port) -} - -fn new_gateway_control_bearer_token() -> String { - let random_bytes = rand::random::<[u8; 32]>(); - URL_SAFE_NO_PAD.encode(random_bytes) -} - -fn write_gateway_control_token_file(path: &Path, token: &str) -> CliResult<()> { - ensure_gateway_control_parent_dir(path)?; - harden_gateway_control_parent_dir(path)?; - - let mut options = OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(GATEWAY_CONTROL_TOKEN_FILE_MODE); - } - let open_result = options.open(path); - let mut file = open_result.map_err(|error| { - format!( - "open gateway control token file failed for {}: {error}", - path.display() - ) - })?; - file.write_all(token.as_bytes()).map_err(|error| { - format!( - "write gateway control token file failed for {}: {error}", - path.display() - ) - })?; - file.sync_all().map_err(|error| { - format!( - "sync gateway control token file failed for {}: {error}", - path.display() - ) - })?; - harden_gateway_control_token_file(path) -} - -fn ensure_gateway_control_parent_dir(path: &Path) -> CliResult<()> { - let parent = path.parent(); - let Some(parent) = parent else { - return Ok(()); - }; - if parent.as_os_str().is_empty() { - return Ok(()); - } - - fs::create_dir_all(parent).map_err(|error| { - format!( - "create gateway control token parent directory failed for {}: {error}", - parent.display() - ) - }) -} - -#[cfg(unix)] -fn harden_gateway_control_parent_dir(path: &Path) -> CliResult<()> { - use std::os::unix::fs::PermissionsExt; - - let parent = path.parent(); - let Some(parent) = parent else { - return Ok(()); - }; - if parent.as_os_str().is_empty() || !parent.exists() { - return Ok(()); - } - - let metadata = fs::metadata(parent).map_err(|error| { - format!( - "read gateway control runtime directory metadata failed for {}: {error}", - parent.display() - ) - })?; - let mut permissions = metadata.permissions(); - permissions.set_mode(GATEWAY_CONTROL_RUNTIME_DIR_MODE); - fs::set_permissions(parent, permissions).map_err(|error| { - format!( - "set gateway control runtime directory permissions failed for {}: {error}", - parent.display() - ) - }) -} - -#[cfg(not(unix))] -fn harden_gateway_control_parent_dir(_path: &Path) -> CliResult<()> { - Ok(()) -} - -#[cfg(unix)] -fn harden_gateway_control_token_file(path: &Path) -> CliResult<()> { - use std::os::unix::fs::PermissionsExt; - - if !path.exists() { - return Ok(()); - } - - let metadata = fs::metadata(path).map_err(|error| { - format!( - "read gateway control token metadata failed for {}: {error}", - path.display() - ) - })?; - let mut permissions = metadata.permissions(); - permissions.set_mode(GATEWAY_CONTROL_TOKEN_FILE_MODE); - fs::set_permissions(path, permissions).map_err(|error| { - format!( - "set gateway control token permissions failed for {}: {error}", - path.display() - ) - }) -} - -#[cfg(not(unix))] -fn harden_gateway_control_token_file(_path: &Path) -> CliResult<()> { - Ok(()) -} - -fn remove_gateway_control_token_file(path: &Path) -> CliResult<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!( - "remove gateway control token file failed for {}: {error}", - path.display() - )), - } -} - -fn combine_gateway_control_task_results( - server_result: CliResult<()>, - cleanup_result: CliResult<()>, -) -> CliResult<()> { - match (server_result, cleanup_result) { - (Ok(()), Ok(())) => Ok(()), - (Err(server_error), Ok(())) => Err(server_error), - (Ok(()), Err(cleanup_error)) => Err(cleanup_error), - (Err(server_error), Err(cleanup_error)) => { - let final_error = format!("{server_error}; {cleanup_error}"); - Err(final_error) - } - } -} - -fn merge_gateway_control_errors(primary_error: String, secondary_error: Option) -> String { - let Some(secondary_error) = secondary_error else { - return primary_error; - }; - - format!("{primary_error}; {secondary_error}") -} - -fn gateway_stop_outcome_status(outcome: GatewayStopRequestOutcome) -> StatusCode { - match outcome { - GatewayStopRequestOutcome::Requested => StatusCode::ACCEPTED, - GatewayStopRequestOutcome::AlreadyRequested => StatusCode::ACCEPTED, - GatewayStopRequestOutcome::AlreadyStopped => StatusCode::OK, - } -} - -fn gateway_stop_outcome_message(outcome: GatewayStopRequestOutcome) -> &'static str { - match outcome { - GatewayStopRequestOutcome::Requested => "gateway stop requested", - GatewayStopRequestOutcome::AlreadyRequested => "gateway stop already requested", - GatewayStopRequestOutcome::AlreadyStopped => "gateway is not running", - } -} - -fn gateway_stop_outcome_code(outcome: GatewayStopRequestOutcome) -> &'static str { - match outcome { - GatewayStopRequestOutcome::Requested => "requested", - GatewayStopRequestOutcome::AlreadyRequested => "already_requested", - GatewayStopRequestOutcome::AlreadyStopped => "already_stopped", - } -} - -fn json_response(status_code: StatusCode, payload: Value) -> GatewayControlJsonResponse { - (status_code, Json(payload)) -} - -fn json_error(status_code: StatusCode, code: &str, message: &str) -> GatewayControlJsonResponse { - let payload = json!({ - "error": { - "code": code, - "message": message, - } - }); - json_response(status_code, payload) -} - -/// Minimal router for health endpoint integration tests. -#[doc(hidden)] -pub fn build_gateway_health_test_router() -> Router { - Router::new().route("/health", get(handle_health)) -} - -/// Minimal router for SSE events endpoint integration tests. -#[doc(hidden)] -pub fn build_gateway_events_test_router( - bearer_token: String, - event_bus: GatewayEventBus, -) -> Router { - let mut state = GatewayControlAppState::test_minimal(bearer_token); - state.event_bus = Some(event_bus); - let app_state = Arc::new(state); - Router::new() - .route("/v1/events", get(handle_events)) - .with_state(app_state) -} - -/// Minimal router for ACP gateway endpoint integration tests. -#[doc(hidden)] -pub fn build_gateway_acp_test_router( - bearer_token: String, - config: LoongClawConfig, - acp_manager: Arc, -) -> Router { - let mut state = GatewayControlAppState::test_minimal(bearer_token); - state.acp_manager = Some(acp_manager); - state.config = Some(config); - let app_state = Arc::new(state); - Router::new() - .route("/v1/acp/status", get(handle_acp_status)) - .route("/v1/acp/observability", get(handle_acp_observability)) - .route("/v1/acp/dispatch", get(handle_acp_dispatch)) - .with_state(app_state) -} +use std::{ + fs, + fs::OpenOptions, + io::Write, + net::{Ipv4Addr, SocketAddrV4}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use axum::{ + Json, Router, + extract::{Query, State}, + http::{HeaderMap, StatusCode, header::AUTHORIZATION}, + routing::{get, post}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::{ + net::TcpListener, + sync::{oneshot, watch}, + task::JoinHandle, +}; + +use crate::mvp::acp::AcpSessionManager; +use crate::mvp::config::LoongClawConfig; +use crate::{ + CliResult, build_channels_cli_json_payload, + collect_runtime_snapshot_cli_state_from_loaded_config, mvp, supervisor::LoadedSupervisorConfig, +}; + +use super::api_acp::{handle_acp_dispatch, handle_acp_observability, handle_acp_status}; +use super::api_events::handle_events; +use super::api_health::handle_health; +use super::api_turn::handle_turn; +use super::event_bus::GatewayEventBus; +use super::read_models::{ + GatewayChannelInventoryReadModel, GatewayOperatorSummaryReadModel, + GatewayRuntimeSnapshotReadModel, build_acp_observability_read_model, + build_acp_session_list_read_model, build_acp_status_read_model, + build_operator_summary_read_model, build_runtime_snapshot_read_model, +}; +use super::state::{ + GatewayControlSurfaceBinding, GatewayStopRequestOutcome, gateway_control_token_path, + load_gateway_owner_status, request_gateway_stop, +}; + +const GATEWAY_CONTROL_TOKEN_FILE_MODE: u32 = 0o600; +const GATEWAY_CONTROL_RUNTIME_DIR_MODE: u32 = 0o700; +const GATEWAY_ACP_SESSION_LIST_DEFAULT_LIMIT: usize = 50; +const GATEWAY_ACP_SESSION_LIST_MAX_LIMIT: usize = 200; + +type GatewayControlJsonResponse = (StatusCode, Json); + +#[derive(Debug, Default, Deserialize)] +struct GatewayAcpSessionsQuery { + limit: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct GatewayAcpStatusQuery { + session: Option, + conversation_id: Option, + route_session_id: Option, +} + +#[derive(Clone)] +pub(crate) struct GatewayControlAppState { + pub(crate) runtime_dir: PathBuf, + pub(crate) config_path: String, + pub(crate) bearer_token: String, + pub(crate) channel_inventory: Arc, + pub(crate) runtime_snapshot: Arc, + pub(crate) event_bus: Option, + pub(crate) acp_manager: Option>, + pub(crate) config: Option, +} + +impl GatewayControlAppState { + /// Minimal state for tests that don't need ACP. + pub fn test_minimal(bearer_token: String) -> Self { + use super::read_models::*; + use serde_json::json; + + let channel_inventory = GatewayChannelInventoryReadModel { + config: String::new(), + schema: GatewayChannelInventorySchema { + version: 1, + primary_channel_view: "channel_surfaces", + catalog_view: "channel_catalog", + legacy_channel_views: &[], + }, + channels: vec![], + catalog_only_channels: vec![], + channel_catalog: vec![], + channel_surfaces: vec![], + }; + let runtime_snapshot = GatewayRuntimeSnapshotReadModel { + config: String::new(), + schema: GatewayRuntimeSnapshotSchema { + version: 1, + surface: "test", + purpose: "test", + }, + provider: json!({}), + context_engine: json!({}), + memory_system: json!({}), + acp: json!({}), + channels: GatewayRuntimeSnapshotChannelsReadModel { + enabled_channel_ids: vec![], + enabled_service_channel_ids: vec![], + inventory: channel_inventory.clone(), + }, + tool_runtime: json!({}), + tools: GatewayRuntimeSnapshotToolsReadModel { + visible_tool_count: 0, + visible_tool_names: vec![], + capability_snapshot_sha256: String::new(), + capability_snapshot: String::new(), + tool_calling: super::read_models::GatewayToolCallingReadModel { + availability: "inactive".to_owned(), + structured_tool_schema_enabled: false, + effective_tool_schema_mode: "enabled_with_downgrade".to_owned(), + active_model: String::new(), + reason: "no runtime-visible tools are enabled".to_owned(), + }, + }, + runtime_plugins: json!({}), + external_skills: json!({}), + }; + Self { + runtime_dir: PathBuf::from("/tmp/test"), + config_path: String::new(), + bearer_token, + channel_inventory: Arc::new(channel_inventory), + runtime_snapshot: Arc::new(runtime_snapshot), + event_bus: None, + acp_manager: None, + config: None, + } + } +} + +struct GatewayControlSurfaceRuntime { + exit_sender: watch::Sender>>, + shutdown_sender: Mutex>>, + join_handle: Mutex>>>, +} + +#[derive(Clone)] +pub struct GatewayControlSurface { + binding: GatewayControlSurfaceBinding, + runtime: Arc, +} + +impl GatewayControlSurface { + pub fn binding(&self) -> &GatewayControlSurfaceBinding { + &self.binding + } + + pub async fn wait_for_unexpected_exit(&self) -> CliResult { + let exit_result = self.wait_for_exit_result().await?; + match exit_result { + Ok(()) => Err("gateway control surface exited unexpectedly".to_owned()), + Err(error) => Err(error), + } + } + + pub async fn shutdown(&self) -> CliResult<()> { + let shutdown_sender = { + let sender_guard = self.runtime.shutdown_sender.lock(); + let mut sender_guard = sender_guard.map_err(|error| { + format!("gateway control surface shutdown lock poisoned: {error}") + })?; + sender_guard.take() + }; + if let Some(shutdown_sender) = shutdown_sender { + let _ = shutdown_sender.send(()); + } + + let join_handle = { + let join_guard = self.runtime.join_handle.lock(); + let mut join_guard = join_guard + .map_err(|error| format!("gateway control surface join lock poisoned: {error}"))?; + join_guard.take() + }; + let Some(join_handle) = join_handle else { + return Ok(()); + }; + + join_handle + .await + .map_err(|error| format!("gateway control surface task failed to join: {error}"))? + } + + async fn wait_for_exit_result(&self) -> CliResult> { + let mut exit_receiver = self.runtime.exit_sender.subscribe(); + let initial_result = exit_receiver.borrow().clone(); + if let Some(initial_result) = initial_result { + return Ok(initial_result); + } + + exit_receiver + .changed() + .await + .map_err(|error| format!("gateway control surface exit watch failed: {error}"))?; + + let exit_result = exit_receiver.borrow().clone(); + exit_result + .ok_or_else(|| "gateway control surface exited without reporting a result".to_owned()) + } +} + +pub async fn start_gateway_control_surface( + runtime_dir: &Path, + loaded_config: &LoadedSupervisorConfig, + acp_manager: Option>, +) -> CliResult { + let channel_inventory = build_gateway_channel_inventory_read_model(loaded_config)?; + let runtime_snapshot = build_gateway_runtime_snapshot_read_model(loaded_config)?; + let bearer_token = new_gateway_control_bearer_token(); + let token_path = gateway_control_token_path(runtime_dir); + + write_gateway_control_token_file(token_path.as_path(), bearer_token.as_str())?; + + let listener_address = gateway_control_listener_address(); + let listener_result = TcpListener::bind(listener_address).await; + let listener = match listener_result { + Ok(listener) => listener, + Err(error) => { + let bind_error = format!("bind gateway control surface failed: {error}"); + let cleanup_result = remove_gateway_control_token_file(token_path.as_path()); + let final_error = merge_gateway_control_errors(bind_error, cleanup_result.err()); + return Err(final_error); + } + }; + + let local_address_result = listener.local_addr(); + let local_address = match local_address_result { + Ok(local_address) => local_address, + Err(error) => { + let address_error = + format!("read gateway control surface local address failed: {error}"); + let cleanup_result = remove_gateway_control_token_file(token_path.as_path()); + let final_error = merge_gateway_control_errors(address_error, cleanup_result.err()); + return Err(final_error); + } + }; + + let bind_address = local_address.ip().to_string(); + let port = local_address.port(); + let binding = GatewayControlSurfaceBinding { + bind_address, + port, + token_path: token_path.clone(), + }; + + let event_bus = if acp_manager.is_some() { + Some(GatewayEventBus::new(256)) + } else { + None + }; + + let app_state = GatewayControlAppState { + runtime_dir: runtime_dir.to_path_buf(), + config_path: loaded_config.resolved_path.display().to_string(), + bearer_token, + channel_inventory: Arc::new(channel_inventory), + runtime_snapshot: Arc::new(runtime_snapshot), + event_bus, + acp_manager, + config: Some(loaded_config.config.clone()), + }; + let app_state = Arc::new(app_state); + let router = build_gateway_control_router(app_state); + + let (shutdown_sender, shutdown_receiver) = oneshot::channel(); + let (exit_sender, _) = watch::channel::>>(None); + let exit_sender_for_task = exit_sender.clone(); + let token_path_for_task = token_path; + let join_handle = tokio::spawn(async move { + let server = axum::serve(listener, router); + let server = server.with_graceful_shutdown(async move { + let _ = shutdown_receiver.await; + }); + let server_result = server + .await + .map_err(|error| format!("gateway control surface server failed: {error}")); + let cleanup_result = remove_gateway_control_token_file(token_path_for_task.as_path()); + let final_result = combine_gateway_control_task_results(server_result, cleanup_result); + let _ = exit_sender_for_task.send(Some(final_result.clone())); + final_result + }); + + let runtime = GatewayControlSurfaceRuntime { + exit_sender, + shutdown_sender: Mutex::new(Some(shutdown_sender)), + join_handle: Mutex::new(Some(join_handle)), + }; + let runtime = Arc::new(runtime); + + Ok(GatewayControlSurface { binding, runtime }) +} + +fn build_gateway_control_router(app_state: Arc) -> Router { + Router::new() + .route("/api/gateway/status", get(handle_gateway_status)) + .route("/api/gateway/channels", get(handle_gateway_channels)) + .route( + "/api/gateway/runtime-snapshot", + get(handle_gateway_runtime_snapshot), + ) + .route( + "/api/gateway/operator-summary", + get(handle_gateway_operator_summary), + ) + .route( + "/api/gateway/acp/sessions", + get(handle_gateway_acp_sessions), + ) + .route("/api/gateway/acp/status", get(handle_gateway_acp_status)) + .route( + "/api/gateway/acp/observability", + get(handle_gateway_acp_observability), + ) + .route("/api/gateway/stop", post(handle_gateway_stop)) + .route("/v1/status", get(handle_gateway_status)) + .route("/v1/channels", get(handle_gateway_channels)) + .route("/v1/runtime/snapshot", get(handle_gateway_runtime_snapshot)) + .route("/v1/acp/status", get(handle_acp_status)) + .route("/v1/acp/observability", get(handle_acp_observability)) + .route("/v1/acp/dispatch", get(handle_acp_dispatch)) + .route("/v1/events", get(handle_events)) + .route("/v1/turn", post(handle_turn)) + .route("/health", get(handle_health)) + .with_state(app_state) +} + +async fn handle_gateway_status( + headers: HeaderMap, + State(app_state): State>, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let status = load_gateway_owner_status(app_state.runtime_dir.as_path()); + let Some(status) = status else { + return json_error( + StatusCode::SERVICE_UNAVAILABLE, + "status_unavailable", + "gateway owner status is unavailable", + ); + }; + + let payload_result = serialize_json_value(&status, "gateway status payload"); + match payload_result { + Ok(payload) => json_response(StatusCode::OK, payload), + Err(error) => json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "serialize_failed", + error.as_str(), + ), + } +} + +async fn handle_gateway_channels( + headers: HeaderMap, + State(app_state): State>, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let payload = serialize_json_value( + app_state.channel_inventory.as_ref(), + "gateway channels payload", + ); + match payload { + Ok(payload) => json_response(StatusCode::OK, payload), + Err(error) => json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "serialize_failed", + error.as_str(), + ), + } +} + +async fn handle_gateway_runtime_snapshot( + headers: HeaderMap, + State(app_state): State>, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let payload = serialize_json_value( + app_state.runtime_snapshot.as_ref(), + "gateway runtime snapshot payload", + ); + match payload { + Ok(payload) => json_response(StatusCode::OK, payload), + Err(error) => json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "serialize_failed", + error.as_str(), + ), + } +} + +async fn handle_gateway_operator_summary( + headers: HeaderMap, + State(app_state): State>, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let status = load_gateway_owner_status(app_state.runtime_dir.as_path()); + let Some(status) = status else { + return json_error( + StatusCode::SERVICE_UNAVAILABLE, + "status_unavailable", + "gateway owner status is unavailable", + ); + }; + + let summary = build_gateway_operator_summary_read_model( + &status, + app_state.channel_inventory.as_ref(), + app_state.runtime_snapshot.as_ref(), + ); + let payload = serialize_json_value(&summary, "gateway operator summary payload"); + match payload { + Ok(payload) => json_response(StatusCode::OK, payload), + Err(error) => json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "serialize_failed", + error.as_str(), + ), + } +} + +async fn handle_gateway_acp_sessions( + headers: HeaderMap, + State(app_state): State>, + Query(query): Query, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let manager = match gateway_control_acp_manager(app_state.as_ref()) { + Ok(manager) => manager, + Err(error) => { + return json_error( + StatusCode::SERVICE_UNAVAILABLE, + "acp_unavailable", + error.as_str(), + ); + } + }; + + let sessions_result = manager.list_sessions(); + let mut sessions = match sessions_result { + Ok(sessions) => sessions, + Err(error) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "acp_sessions_unavailable", + error.as_str(), + ); + } + }; + + sort_gateway_acp_sessions(sessions.as_mut_slice()); + let matched_count = sessions.len(); + let limit = gateway_acp_session_list_limit(query.limit); + sessions.truncate(limit); + + let payload = build_acp_session_list_read_model( + app_state.config_path.as_str(), + matched_count, + sessions.as_slice(), + ); + let payload = match serialize_json_value(&payload, "gateway ACP sessions payload") { + Ok(payload) => payload, + Err(error) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "serialize_failed", + error.as_str(), + ); + } + }; + + json_response(StatusCode::OK, payload) +} + +async fn handle_gateway_acp_status( + headers: HeaderMap, + State(app_state): State>, + Query(query): Query, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let config = match gateway_control_config(app_state.as_ref()) { + Ok(config) => config, + Err(error) => { + return json_error( + StatusCode::SERVICE_UNAVAILABLE, + "acp_unavailable", + error.as_str(), + ); + } + }; + let manager = match gateway_control_acp_manager(app_state.as_ref()) { + Ok(manager) => manager, + Err(error) => { + return json_error( + StatusCode::SERVICE_UNAVAILABLE, + "acp_unavailable", + error.as_str(), + ); + } + }; + + let resolved_session_key = crate::resolve_acp_status_session_key( + config, + query.session.as_deref(), + query.conversation_id.as_deref(), + query.route_session_id.as_deref(), + ); + let resolved_session_key = match resolved_session_key { + Ok(resolved_session_key) => resolved_session_key, + Err(error) if is_gateway_acp_not_found_error(error.as_str()) => { + return json_error(StatusCode::NOT_FOUND, "not_found", error.as_str()); + } + Err(error) => { + return json_error(StatusCode::BAD_REQUEST, "invalid_selector", error.as_str()); + } + }; + + let status_result = manager + .get_status(config, resolved_session_key.as_str()) + .await; + let status = match status_result { + Ok(status) => status, + Err(error) if is_gateway_acp_not_found_error(error.as_str()) => { + return json_error(StatusCode::NOT_FOUND, "not_found", error.as_str()); + } + Err(error) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "acp_status_unavailable", + error.as_str(), + ); + } + }; + + let payload = build_acp_status_read_model( + app_state.config_path.as_str(), + query.session.as_deref(), + query.conversation_id.as_deref(), + query.route_session_id.as_deref(), + resolved_session_key.as_str(), + &status, + ); + let payload = match serialize_json_value(&payload, "gateway ACP status payload") { + Ok(payload) => payload, + Err(error) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "serialize_failed", + error.as_str(), + ); + } + }; + + json_response(StatusCode::OK, payload) +} + +async fn handle_gateway_acp_observability( + headers: HeaderMap, + State(app_state): State>, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let config = match gateway_control_config(app_state.as_ref()) { + Ok(config) => config, + Err(error) => { + return json_error( + StatusCode::SERVICE_UNAVAILABLE, + "acp_unavailable", + error.as_str(), + ); + } + }; + let manager = match gateway_control_acp_manager(app_state.as_ref()) { + Ok(manager) => manager, + Err(error) => { + return json_error( + StatusCode::SERVICE_UNAVAILABLE, + "acp_unavailable", + error.as_str(), + ); + } + }; + + let snapshot_result = manager.observability_snapshot(config).await; + let snapshot = match snapshot_result { + Ok(snapshot) => snapshot, + Err(error) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "acp_observability_unavailable", + error.as_str(), + ); + } + }; + + let payload = build_acp_observability_read_model(app_state.config_path.as_str(), &snapshot); + let payload = match serialize_json_value(&payload, "gateway ACP observability payload") { + Ok(payload) => payload, + Err(error) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "serialize_failed", + error.as_str(), + ); + } + }; + + json_response(StatusCode::OK, payload) +} + +async fn handle_gateway_stop( + headers: HeaderMap, + State(app_state): State>, +) -> GatewayControlJsonResponse { + if let Err(error) = authorize_request(&headers, app_state.bearer_token.as_str()) { + return json_error(StatusCode::UNAUTHORIZED, "unauthorized", error.as_str()); + } + + let stop_result = request_gateway_stop(app_state.runtime_dir.as_path()); + let outcome = match stop_result { + Ok(outcome) => outcome, + Err(error) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "stop_failed", + error.as_str(), + ); + } + }; + + let response_status = gateway_stop_outcome_status(outcome); + let response_message = gateway_stop_outcome_message(outcome); + let payload = json!({ + "outcome": gateway_stop_outcome_code(outcome), + "message": response_message, + }); + json_response(response_status, payload) +} + +pub(crate) fn is_gateway_acp_not_found_error(error: &str) -> bool { + let is_session_error = error.starts_with("ACP session `"); + let is_conversation_error = error.starts_with("ACP conversation `"); + let is_route_error = error.starts_with("ACP route session `"); + let has_registration_marker = error.contains(" is not registered"); + let is_lookup_error = is_session_error || is_conversation_error || is_route_error; + is_lookup_error && has_registration_marker +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut result = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + result |= x ^ y; + } + result == 0 +} + +pub(crate) fn authorize_request_from_state( + headers: &HeaderMap, + app_state: &GatewayControlAppState, +) -> CliResult<()> { + authorize_request(headers, &app_state.bearer_token) +} + +fn authorize_request(headers: &HeaderMap, expected_token: &str) -> CliResult<()> { + let authorization_header = headers.get(AUTHORIZATION); + let Some(authorization_header) = authorization_header else { + return Err("missing Authorization header".to_owned()); + }; + + let authorization_text = authorization_header + .to_str() + .map_err(|error| format!("invalid Authorization header encoding: {error}"))?; + let bearer_prefix = "Bearer "; + let provided_token = authorization_text.strip_prefix(bearer_prefix); + let Some(provided_token) = provided_token else { + return Err("Authorization header must use Bearer auth".to_owned()); + }; + + if !constant_time_eq(provided_token.as_bytes(), expected_token.as_bytes()) { + return Err("invalid gateway bearer token".to_owned()); + } + + Ok(()) +} + +fn build_gateway_channel_inventory_read_model( + loaded_config: &LoadedSupervisorConfig, +) -> CliResult { + let config_path = loaded_config.resolved_path.display().to_string(); + let inventory = mvp::channel::channel_inventory(&loaded_config.config); + let read_model = build_channels_cli_json_payload(config_path.as_str(), &inventory); + Ok(read_model) +} + +fn build_gateway_runtime_snapshot_read_model( + loaded_config: &LoadedSupervisorConfig, +) -> CliResult { + let snapshot = collect_runtime_snapshot_cli_state_from_loaded_config(loaded_config)?; + let read_model = build_runtime_snapshot_read_model(&snapshot); + Ok(read_model) +} + +fn build_gateway_operator_summary_read_model( + status: &super::state::GatewayOwnerStatus, + channel_inventory: &GatewayChannelInventoryReadModel, + runtime_snapshot: &GatewayRuntimeSnapshotReadModel, +) -> GatewayOperatorSummaryReadModel { + build_operator_summary_read_model(status, channel_inventory, runtime_snapshot) +} + +fn gateway_control_config(app_state: &GatewayControlAppState) -> CliResult<&LoongClawConfig> { + let config = app_state + .config + .as_ref() + .ok_or_else(|| "gateway ACP config is unavailable".to_owned())?; + Ok(config) +} + +fn gateway_control_acp_manager( + app_state: &GatewayControlAppState, +) -> CliResult<&AcpSessionManager> { + let manager = app_state + .acp_manager + .as_deref() + .ok_or_else(|| "gateway ACP session manager is unavailable".to_owned())?; + Ok(manager) +} + +fn gateway_acp_session_list_limit(requested_limit: Option) -> usize { + let requested_limit = requested_limit.unwrap_or(GATEWAY_ACP_SESSION_LIST_DEFAULT_LIMIT); + requested_limit.clamp(1, GATEWAY_ACP_SESSION_LIST_MAX_LIMIT) +} + +fn sort_gateway_acp_sessions(sessions: &mut [crate::mvp::acp::AcpSessionMetadata]) { + sessions.sort_by(|left, right| { + let activity_order = right.last_activity_ms.cmp(&left.last_activity_ms); + if activity_order == std::cmp::Ordering::Equal { + return left.session_key.cmp(&right.session_key); + } + activity_order + }); +} + +fn serialize_json_value(value: &T, context: &str) -> CliResult { + serde_json::to_value(value).map_err(|error| format!("serialize {context} failed: {error}")) +} + +fn gateway_control_listener_address() -> SocketAddrV4 { + let bind_address = Ipv4Addr::LOCALHOST; + let bind_port = 0_u16; + SocketAddrV4::new(bind_address, bind_port) +} + +fn new_gateway_control_bearer_token() -> String { + let random_bytes = rand::random::<[u8; 32]>(); + URL_SAFE_NO_PAD.encode(random_bytes) +} + +fn write_gateway_control_token_file(path: &Path, token: &str) -> CliResult<()> { + ensure_gateway_control_parent_dir(path)?; + harden_gateway_control_parent_dir(path)?; + + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(GATEWAY_CONTROL_TOKEN_FILE_MODE); + } + let open_result = options.open(path); + let mut file = open_result.map_err(|error| { + format!( + "open gateway control token file failed for {}: {error}", + path.display() + ) + })?; + file.write_all(token.as_bytes()).map_err(|error| { + format!( + "write gateway control token file failed for {}: {error}", + path.display() + ) + })?; + file.sync_all().map_err(|error| { + format!( + "sync gateway control token file failed for {}: {error}", + path.display() + ) + })?; + harden_gateway_control_token_file(path) +} + +fn ensure_gateway_control_parent_dir(path: &Path) -> CliResult<()> { + let parent = path.parent(); + let Some(parent) = parent else { + return Ok(()); + }; + if parent.as_os_str().is_empty() { + return Ok(()); + } + + fs::create_dir_all(parent).map_err(|error| { + format!( + "create gateway control token parent directory failed for {}: {error}", + parent.display() + ) + }) +} + +#[cfg(unix)] +fn harden_gateway_control_parent_dir(path: &Path) -> CliResult<()> { + use std::os::unix::fs::PermissionsExt; + + let parent = path.parent(); + let Some(parent) = parent else { + return Ok(()); + }; + if parent.as_os_str().is_empty() || !parent.exists() { + return Ok(()); + } + + let metadata = fs::metadata(parent).map_err(|error| { + format!( + "read gateway control runtime directory metadata failed for {}: {error}", + parent.display() + ) + })?; + let mut permissions = metadata.permissions(); + permissions.set_mode(GATEWAY_CONTROL_RUNTIME_DIR_MODE); + fs::set_permissions(parent, permissions).map_err(|error| { + format!( + "set gateway control runtime directory permissions failed for {}: {error}", + parent.display() + ) + }) +} + +#[cfg(not(unix))] +fn harden_gateway_control_parent_dir(_path: &Path) -> CliResult<()> { + Ok(()) +} + +#[cfg(unix)] +fn harden_gateway_control_token_file(path: &Path) -> CliResult<()> { + use std::os::unix::fs::PermissionsExt; + + if !path.exists() { + return Ok(()); + } + + let metadata = fs::metadata(path).map_err(|error| { + format!( + "read gateway control token metadata failed for {}: {error}", + path.display() + ) + })?; + let mut permissions = metadata.permissions(); + permissions.set_mode(GATEWAY_CONTROL_TOKEN_FILE_MODE); + fs::set_permissions(path, permissions).map_err(|error| { + format!( + "set gateway control token permissions failed for {}: {error}", + path.display() + ) + }) +} + +#[cfg(not(unix))] +fn harden_gateway_control_token_file(_path: &Path) -> CliResult<()> { + Ok(()) +} + +fn remove_gateway_control_token_file(path: &Path) -> CliResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "remove gateway control token file failed for {}: {error}", + path.display() + )), + } +} + +fn combine_gateway_control_task_results( + server_result: CliResult<()>, + cleanup_result: CliResult<()>, +) -> CliResult<()> { + match (server_result, cleanup_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(server_error), Ok(())) => Err(server_error), + (Ok(()), Err(cleanup_error)) => Err(cleanup_error), + (Err(server_error), Err(cleanup_error)) => { + let final_error = format!("{server_error}; {cleanup_error}"); + Err(final_error) + } + } +} + +fn merge_gateway_control_errors(primary_error: String, secondary_error: Option) -> String { + let Some(secondary_error) = secondary_error else { + return primary_error; + }; + + format!("{primary_error}; {secondary_error}") +} + +fn gateway_stop_outcome_status(outcome: GatewayStopRequestOutcome) -> StatusCode { + match outcome { + GatewayStopRequestOutcome::Requested => StatusCode::ACCEPTED, + GatewayStopRequestOutcome::AlreadyRequested => StatusCode::ACCEPTED, + GatewayStopRequestOutcome::AlreadyStopped => StatusCode::OK, + } +} + +fn gateway_stop_outcome_message(outcome: GatewayStopRequestOutcome) -> &'static str { + match outcome { + GatewayStopRequestOutcome::Requested => "gateway stop requested", + GatewayStopRequestOutcome::AlreadyRequested => "gateway stop already requested", + GatewayStopRequestOutcome::AlreadyStopped => "gateway is not running", + } +} + +fn gateway_stop_outcome_code(outcome: GatewayStopRequestOutcome) -> &'static str { + match outcome { + GatewayStopRequestOutcome::Requested => "requested", + GatewayStopRequestOutcome::AlreadyRequested => "already_requested", + GatewayStopRequestOutcome::AlreadyStopped => "already_stopped", + } +} + +fn json_response(status_code: StatusCode, payload: Value) -> GatewayControlJsonResponse { + (status_code, Json(payload)) +} + +fn json_error(status_code: StatusCode, code: &str, message: &str) -> GatewayControlJsonResponse { + let payload = json!({ + "error": { + "code": code, + "message": message, + } + }); + json_response(status_code, payload) +} + +/// Minimal router for health endpoint integration tests. +#[doc(hidden)] +pub fn build_gateway_health_test_router() -> Router { + Router::new().route("/health", get(handle_health)) +} + +/// Minimal router for SSE events endpoint integration tests. +#[doc(hidden)] +pub fn build_gateway_events_test_router( + bearer_token: String, + event_bus: GatewayEventBus, +) -> Router { + let mut state = GatewayControlAppState::test_minimal(bearer_token); + state.event_bus = Some(event_bus); + let app_state = Arc::new(state); + Router::new() + .route("/v1/events", get(handle_events)) + .with_state(app_state) +} + +/// Minimal router for ACP gateway endpoint integration tests. +#[doc(hidden)] +pub fn build_gateway_acp_test_router( + bearer_token: String, + config: LoongClawConfig, + acp_manager: Arc, +) -> Router { + let mut state = GatewayControlAppState::test_minimal(bearer_token); + state.acp_manager = Some(acp_manager); + state.config = Some(config); + let app_state = Arc::new(state); + Router::new() + .route("/v1/acp/status", get(handle_acp_status)) + .route("/v1/acp/observability", get(handle_acp_observability)) + .route("/v1/acp/dispatch", get(handle_acp_dispatch)) + .with_state(app_state) +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index daac95044..08c638fc6 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -1,6498 +1,6505 @@ -#![allow( - clippy::print_stdout, - clippy::print_stderr, - clippy::expect_used, - private_interfaces -)] // CLI daemon binary -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - future::Future, - io::Write, - path::{Path, PathBuf}, - pin::Pin, - process, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; -use kernel::{ - BootstrapTaskStatus, Capability, ConnectorCommand, FixedClock, InMemoryAuditSink, - PluginActivationStatus, PluginScanner, PluginSetupReadinessContext, PluginTranslator, - TaskIntent, ToolCoreOutcome, ToolCoreRequest, evaluate_plugin_setup_requirements, -}; -use loongclaw_contracts::SecretRef; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - -pub use loongclaw_app as mvp; -pub use loongclaw_spec::spec_execution::*; -pub use loongclaw_spec::spec_runtime::*; -pub use loongclaw_spec::{CliResult, DEFAULT_AGENT_ID, DEFAULT_PACK_ID, kernel_bootstrap}; - -pub use self::channel_send_target_kind::{ - default_twitch_send_target_kind, parse_twitch_send_target_kind, -}; -pub use self::cli_json::build_runtime_snapshot_cli_json_payload; -pub use self::delegate_child_cli::run_detached_delegate_child_cli; -pub use self::env_compat::make_env_compatible; -pub use self::mcp_cli::{ - build_mcp_server_detail_cli_json_payload, build_mcp_servers_cli_json_payload, - run_list_mcp_servers_cli, run_show_mcp_server_cli, -}; -pub use loongclaw_bench::{ - run_programmatic_pressure_baseline_lint_cli, run_programmatic_pressure_benchmark_cli, - run_wasm_cache_benchmark_cli, -}; -#[cfg(any(feature = "memory-sqlite", feature = "mvp"))] -pub use memory_context_benchmark::run_memory_context_benchmark_cli; -pub use runtime_trajectory_cli::{format_runtime_trajectory_summary, run_runtime_trajectory_cli}; -#[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] -pub fn run_memory_context_benchmark_cli( - output_path: &str, - temp_root: Option<&str>, - history_turns: usize, - sliding_window: usize, - summary_max_chars: usize, - words_per_turn: usize, - rebuild_iterations: usize, - hot_iterations: usize, - warmup_iterations: usize, - suite_repetitions: usize, - enforce_gate: bool, - min_steady_state_speedup_ratio: f64, -) -> CliResult<()> { - let _ = ( - output_path, - temp_root, - history_turns, - sliding_window, - summary_max_chars, - words_per_turn, - rebuild_iterations, - hot_iterations, - warmup_iterations, - suite_repetitions, - enforce_gate, - min_steady_state_speedup_ratio, - ); - Err("benchmark-memory-context requires the daemon `memory-sqlite` feature".to_owned()) -} - -pub use {base64, kernel, sha2}; - -pub mod audit_cli; -mod browser_companion_diagnostics; -pub mod browser_preview; -mod channel_bridge_render; -#[cfg(test)] -mod channel_send_cli_tests; -mod channel_send_target_kind; -mod cli_handoff; -mod cli_json; -mod command_kind; -pub mod completions_cli; -mod control_plane_server; -mod copilot_onboarding; -mod delegate_child_cli; -pub mod doctor_cli; -pub mod doctor_security_cli; -mod env_compat; -mod external_skills_policy_probe; -pub mod feishu_cli; -pub mod feishu_support; -pub mod gateway; -pub mod import_cli; -mod mcp_cli; -#[cfg(any(feature = "memory-sqlite", feature = "mvp"))] -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; -pub mod onboard_presentation; -mod onboard_types; -mod onboard_web_search; -mod onboarding_model_policy; -pub mod operator_prompt; -pub mod personalize_cli; -mod plugin_bridge_account_summary; -pub mod plugins_cli; -mod provider_credential_policy; -mod provider_model_probe_policy; -pub mod provider_presentation; -mod provider_route_diagnostics; -pub mod runtime_capability_cli; -pub mod runtime_experiment_cli; -pub mod runtime_restore_cli; -mod runtime_snapshot_render; -pub mod runtime_trajectory_cli; -pub mod session_cli; -pub mod sessions_cli; -pub mod skills_cli; -pub mod source_presentation; -pub mod status_cli; -pub mod supervisor; -mod task_execution; -pub mod tasks_cli; -mod tlon_cli; -mod tool_calling_readiness; -pub mod trajectory_cli; -pub mod work_unit_cli; -use channel_bridge_render::{ - push_channel_surface_managed_plugin_bridge_discovery, - push_channel_surface_plugin_bridge_contract, -}; -pub(crate) use channel_bridge_render::{ - render_line_safe_optional_text_value, render_line_safe_text_value, render_line_safe_text_values, -}; -pub use gateway::read_models::{ChannelsCliJsonPayload, ChannelsCliJsonSchema}; -pub use loongclaw_spec::programmatic::{ - acquire_programmatic_circuit_slot, record_programmatic_circuit_outcome, -}; -pub use observability::{debug_variant_name, init_tracing, summarize_error}; -pub use runtime_snapshot_render::render_runtime_snapshot_text; -pub(crate) use runtime_snapshot_render::{ - runtime_snapshot_acp_json, runtime_snapshot_context_engine_json, - runtime_snapshot_external_skills_json, runtime_snapshot_memory_system_json, - runtime_snapshot_provider_json, runtime_snapshot_runtime_plugins_json, - runtime_snapshot_tool_runtime_json, -}; -pub use session_cli::{ - SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, SessionSearchArtifactDocument, - SessionSearchArtifactResult, SessionSearchArtifactSchema, collect_session_search_artifact, - format_session_search_inspect_text, format_session_search_text, load_session_search_artifact, - run_session_search_cli, run_session_search_inspect_cli, -}; -use task_execution::execute_daemon_task_with_supervisor; -pub use task_execution::{DaemonTaskExecution, run_demo, run_task_cli}; -pub use tlon_cli::TLON_SEND_CLI_SPEC; -use tlon_cli::{default_tlon_send_target_kind, parse_tlon_send_target_kind}; -#[rustfmt::skip] -use tool_calling_readiness::{RuntimeSnapshotToolCallingState, collect_runtime_snapshot_tool_calling_state}; -pub use trajectory_cli::{ - TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, TrajectoryExportArtifactDocument, - TrajectoryExportArtifactSchema, TrajectoryExportEvent, TrajectoryExportSessionSummary, - TrajectoryExportTurn, collect_trajectory_export_artifact, format_trajectory_export_text, - format_trajectory_inspect_text, load_trajectory_export_artifact, run_trajectory_export_cli, - run_trajectory_inspect_cli, -}; -#[allow( - clippy::expect_used, - clippy::panic, - clippy::unwrap_used, - clippy::missing_panics_doc -)] -#[doc(hidden)] -pub mod test_support; - -pub const PUBLIC_GITHUB_REPO: &str = "loongclaw-ai/loongclaw"; -pub const CLI_COMMAND_NAME: &str = mvp::config::CLI_COMMAND_NAME; -pub const LEGACY_CLI_COMMAND_NAME: &str = mvp::config::LEGACY_CLI_COMMAND_NAME; - -pub fn active_cli_command_name() -> &'static str { - mvp::config::active_cli_command_name() -} - -fn render_welcome_long_about(command_name: &str) -> String { - format!( - "Show the configured welcome banner and quick commands.\n\nquick commands:\n- {command_name} ask --config --message \"...\"\n- {command_name} chat --config \n- {command_name} personalize --config \n- {command_name} doctor --config \n- {command_name} --help\n\nReplace with your current config path, or set LOONGCLAW_CONFIG_PATH first." - ) -} - -fn render_import_long_about(command_name: &str) -> String { - format!( - "Power-user import flow for previewing or applying detected migration sources explicitly.\n\nUse this when you want exact CLI control over which source and domains are reused. If you want the guided path, use `{command_name} onboard` instead. When the same source kind resolves to multiple detected configs, rerun with `--source-path ` to choose one exact source." - ) -} - -fn render_migrate_long_about(command_name: &str) -> String { - format!( - "Power-user config import flow for discovering, previewing, or applying external workspace state explicitly.\n\nUse this when you want exact CLI control over import mode selection and output handling for compatibility sources and older workspace roots. If you want the guided path, use `{command_name} onboard` instead.\n\nMode quick reference:\n- discover, plan_many, recommend_primary, merge_profiles, map_external_skills: require `--input`\n- plan: requires `--input`; `--output` is optional preview target\n- apply: requires `--input` and `--output`\n- apply_selected: requires `--input` and `--output`; use `--source-id` to pin one discovered source, and `--apply-external-skills-plan` to bridge installable local external skills into the managed runtime\n- rollback_last_apply: requires `--output`" - ) -} - -fn render_ask_long_about(command_name: &str) -> String { - format!( - "Run one non-interactive one-shot assistant turn.\n\nUse this when you want a fast answer without entering the interactive `{command_name} chat` REPL. The command reuses the normal CLI conversation runtime, session memory, provider selection, and ACP options." - ) -} - -pub fn build_cli_command(command_name: &'static str) -> clap::Command { - Cli::command() - .name(command_name) - .bin_name(command_name) - .mut_subcommand("welcome", |command| { - command.long_about(render_welcome_long_about(command_name)) - }) - .mut_subcommand("import", |command| { - command.long_about(render_import_long_about(command_name)) - }) - .mut_subcommand("migrate", |command| { - command - .about("Preview or apply config import modes explicitly") - .long_about(render_migrate_long_about(command_name)) - }) - .mut_subcommand("ask", |command| { - command.long_about(render_ask_long_about(command_name)) - }) -} - -pub fn parse_cli() -> Cli { - let mut matches = build_cli_command(active_cli_command_name()).get_matches(); - Cli::from_arg_matches_mut(&mut matches).unwrap_or_else(|error| error.exit()) -} - -pub use control_plane_server::{build_control_plane_router, run_control_plane_serve_cli}; - -pub fn native_spec_tool_executor( - request: ToolCoreRequest, -) -> Option> { - if mvp::tools::canonical_tool_name(request.tool_name.as_str()) != "config.import" { - return None; - } - Some(mvp::tools::execute_tool_core(request)) -} - -pub type ChannelCliCommandFuture<'a> = Pin> + Send + 'a>>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum BridgeSupportProfileArg { - NativeBalanced, - OpenclawEcosystemBalanced, -} - -impl BridgeSupportProfileArg { - fn as_str(self) -> &'static str { - match self { - Self::NativeBalanced => "native-balanced", - Self::OpenclawEcosystemBalanced => "openclaw-ecosystem-balanced", - } - } -} - -#[derive(clap::Args, Debug, Clone, Default)] -pub struct RunSpecBridgeSupportArgs { - /// Optional JSON file containing a bridge support policy override for this spec run - #[arg(long, conflicts_with_all = ["bridge_profile", "bridge_support_delta"])] - pub bridge_support: Option, - /// Optional bundled bridge support profile override for this spec run - #[arg(long, value_enum, conflicts_with_all = ["bridge_support", "bridge_support_delta"])] - pub bridge_profile: Option, - /// Optional delta artifact JSON file derived from a bundled bridge support profile - #[arg(long, conflicts_with_all = ["bridge_support", "bridge_profile"])] - pub bridge_support_delta: Option, - /// Optional sha256 pin for the resolved bridge support policy override - #[arg(long)] - pub bridge_support_sha256: Option, - /// Optional sha256 pin for the bridge support delta artifact override - #[arg(long)] - pub bridge_support_delta_sha256: Option, -} - -#[derive(Debug, Clone, Copy)] -pub struct ChannelSendCliArgs<'a> { - pub config_path: Option<&'a str>, - pub account: Option<&'a str>, - pub target: Option<&'a str>, - pub target_kind: mvp::channel::ChannelOutboundTargetKind, - pub text: &'a str, - pub as_card: bool, -} - -#[derive(Debug, Clone, Copy)] -pub struct ChannelServeCliArgs<'a> { - pub config_path: Option<&'a str>, - pub account: Option<&'a str>, - pub once: bool, - pub bind_override: Option<&'a str>, - pub path_override: Option<&'a str>, -} - -#[derive(Debug, Clone, Copy)] -pub struct ChannelSendCliSpec { - pub family: mvp::channel::ChannelCatalogCommandFamilyDescriptor, - pub run: for<'a> fn(ChannelSendCliArgs<'a>) -> ChannelCliCommandFuture<'a>, -} - -#[derive(Debug, Clone, Copy)] -pub struct ChannelServeCliSpec { - pub family: mvp::channel::ChannelCommandFamilyDescriptor, - pub run: for<'a> fn(ChannelServeCliArgs<'a>) -> ChannelCliCommandFuture<'a>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MultiChannelServeChannelAccount { - pub channel_id: String, - pub account_id: String, -} - -impl std::str::FromStr for MultiChannelServeChannelAccount { - type Err = String; - - fn from_str(raw: &str) -> Result { - parse_multi_channel_serve_channel_account(raw) - } -} - -#[derive(Parser, Debug)] -#[command( - name = CLI_COMMAND_NAME, - about = "LoongClaw low-level runtime daemon", - version -)] -pub struct Cli { - #[command(subcommand)] - pub command: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)] -pub enum InitSpecPreset { - #[default] - Default, - PluginTrustGuard, -} - -#[derive(Subcommand, Debug)] -pub enum Commands { - #[command( - long_about = "Show the configured welcome banner and quick commands.\n\nquick commands:\n- loong ask --config --message \"...\"\n- loong chat --config \n- loong personalize --config \n- loong doctor --config \n- loong --help\n\nReplace with your current config path, or set LOONGCLAW_CONFIG_PATH first." - )] - /// Show a welcome banner for an already configured install - Welcome, - /// Run the original end-to-end bootstrap demo - Demo, - /// Execute one task through the kernel+harness path - RunTask { - #[arg(long)] - objective: String, - #[arg(long, default_value = "{}")] - payload: String, - }, - /// Invoke one connector operation through kernel policy gate - InvokeConnector { - #[arg(long)] - operation: String, - #[arg(long, default_value = "{}")] - payload: String, - }, - /// Demonstrate audit lifecycle with fixed clock and token revocation - AuditDemo, - /// Generate a runnable JSON spec template for quick vertical customization - InitSpec { - #[arg(long, default_value = "loongclaw.spec.json")] - output: String, - #[arg(long, value_enum, default_value_t = InitSpecPreset::Default)] - preset: InitSpecPreset, - }, - /// Run a full workflow from a JSON spec (task/connector/runtime/tool/memory) - RunSpec { - #[arg(long)] - spec: String, - #[arg(long, default_value_t = false)] - print_audit: bool, - #[arg(long, default_value_t = false)] - render_summary: bool, - #[command(flatten)] - bridge_support: RunSpecBridgeSupportArgs, - }, - /// Run pressure benchmarks for programmatic orchestration and optional regression gate checks - BenchmarkProgrammaticPressure { - #[arg( - long, - default_value = "examples/benchmarks/programmatic-pressure-matrix.json" - )] - matrix: String, - #[arg(long)] - baseline: Option, - #[arg( - long, - default_value = "target/benchmarks/programmatic-pressure-report.json" - )] - output: String, - #[arg(long, default_value_t = false)] - enforce_gate: bool, - #[arg(long, default_value_t = false)] - preflight_fail_on_warnings: bool, - }, - /// Lint pressure baseline coverage without running benchmark scenarios - BenchmarkProgrammaticPressureLint { - #[arg( - long, - default_value = "examples/benchmarks/programmatic-pressure-matrix.json" - )] - matrix: String, - #[arg(long)] - baseline: Option, - #[arg( - long, - default_value = "target/benchmarks/programmatic-pressure-baseline-lint-report.json" - )] - output: String, - #[arg(long, default_value_t = false)] - enforce_gate: bool, - #[arg(long, default_value_t = false)] - fail_on_warnings: bool, - }, - /// Benchmark Wasm compile cache behavior and enforce hot-path speedup gate - BenchmarkWasmCache { - #[arg(long, default_value = "examples/plugins-wasm/secure_echo.wasm")] - wasm: String, - #[arg( - long, - default_value = "target/benchmarks/wasm-cache-benchmark-report.json" - )] - output: String, - #[arg(long, default_value_t = 8)] - cold_iterations: usize, - #[arg(long, default_value_t = 24)] - hot_iterations: usize, - #[arg(long, default_value_t = 2)] - warmup_iterations: usize, - #[arg(long, default_value_t = false)] - enforce_gate: bool, - #[arg(long, default_value_t = 1.5)] - min_speedup_ratio: f64, - }, - /// Benchmark memory prompt-context hydration across window-only, rebuild, steady-state, and shrink catch-up summary paths - BenchmarkMemoryContext { - #[arg( - long, - default_value = "target/benchmarks/memory-context-benchmark-report.json" - )] - output: String, - #[arg(long)] - temp_root: Option, - #[arg(long, default_value_t = 256)] - history_turns: usize, - #[arg(long, default_value_t = 24)] - sliding_window: usize, - #[arg(long, default_value_t = 1024)] - summary_max_chars: usize, - #[arg(long, default_value_t = 24)] - words_per_turn: usize, - #[arg(long, default_value_t = 12)] - rebuild_iterations: usize, - #[arg(long, default_value_t = 32)] - hot_iterations: usize, - #[arg(long, default_value_t = 4)] - warmup_iterations: usize, - #[arg(long, default_value_t = 1)] - suite_repetitions: usize, - #[arg(long, default_value_t = false)] - enforce_gate: bool, - #[arg(long, default_value_t = 1.2)] - min_steady_state_speedup_ratio: f64, - }, - /// Validate config semantics and report structured diagnostics - ValidateConfig { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - #[arg(long, value_enum)] - output: Option, - #[arg(long, default_value = "en")] - locale: String, - #[arg(long, default_value_t = false)] - fail_on_diagnostics: bool, - }, - #[command( - about = "Guided onboarding for fast first-chat setup with preflight diagnostics", - long_about = "Guided onboarding for fast first-chat setup with preflight diagnostics.\n\nThis is the default path for most users. LoongClaw will detect reusable settings for provider, channels, or workspace guidance, suggest a starting point, and walk through quick review before first chat." - )] - Onboard { - /// Write the resulting config to a custom path instead of the default loongclaw config location - #[arg(long)] - output: Option, - /// Overwrite an existing target config path instead of stopping for manual review - #[arg(long, default_value_t = false)] - force: bool, - /// Use provided flags only and skip interactive prompts except required safety checks - #[arg(long, default_value_t = false)] - non_interactive: bool, - /// Confirm the onboarding risk acknowledgement in non-interactive mode - #[arg(long, default_value_t = false)] - accept_risk: bool, - #[arg( - long, - value_name = mvp::config::PROVIDER_SELECTOR_PLACEHOLDER, - help = mvp::config::PROVIDER_SELECTOR_HUMAN_SUMMARY - )] - provider: Option, - /// Preselect the model to use after the provider choice is resolved - #[arg(long)] - model: Option, - /// Provider credential environment variable name, for example OPENAI_API_KEY - #[arg(long = "api-key", alias = "api-key-env")] - api_key_env: Option, - #[arg( - long = "web-search-provider", - value_name = "PROVIDER", - help = mvp::config::WEB_SEARCH_PROVIDER_VALID_VALUES - )] - web_search_provider: Option, - /// Web search credential environment variable name, for example TAVILY_API_KEY - #[arg(long = "web-search-api-key", alias = "web-search-api-key-env")] - web_search_api_key_env: Option, - /// Select a native prompt personality in non-interactive mode - #[arg(long)] - personality: Option, - /// Select a memory profile in non-interactive mode - #[arg(long)] - memory_profile: Option, - /// Preseed the CLI system prompt instead of editing it interactively - #[arg(long)] - system_prompt: Option, - /// Skip probing the resolved provider model list during onboarding - #[arg(long, default_value_t = false)] - skip_model_probe: bool, - }, - #[command( - about = "Capture optional operator preferences for future sessions", - long_about = "Capture optional operator preferences for future sessions.\n\nThis command stores advisory working preferences such as preferred name, response density, initiative level, and standing boundaries. Rerun it any time to update or clear saved preferences. It does not replace runtime identity files, and it does not change the primary setup path. If you do not have a config yet, run `loong onboard` first." - )] - Personalize { - /// Config file path to update (defaults to auto-discovery) - #[arg(long)] - config: Option, - }, - #[command( - about = "Preview or apply migration sources explicitly", - long_about = "Power-user import flow for previewing or applying detected migration sources explicitly.\n\nUse this when you want exact CLI control over which source and domains are reused. If you want the guided path, use `loong onboard` instead. When the same source kind resolves to multiple detected configs, rerun with `--source-path ` to choose one exact source." - )] - Import { - /// Write the imported config to a custom path instead of the default loongclaw config location - #[arg(long)] - output: Option, - /// Overwrite an existing target config path instead of stopping for manual review - #[arg(long, default_value_t = false)] - force: bool, - /// Print the selected import candidate preview in text mode - #[arg(long, default_value_t = false)] - preview: bool, - /// Apply the selected import candidate to the target config path - #[arg(long, default_value_t = false)] - apply: bool, - /// Emit machine-readable preview JSON for scripting or automation - #[arg(long, default_value_t = false)] - json: bool, - /// Limit selection to one source kind such as recommended, existing, codex, or env - #[arg(long)] - from: Option, - /// Choose one exact detected source path when multiple candidates of the same kind exist - #[arg(long)] - source_path: Option, - #[arg( - long, - value_name = mvp::config::PROVIDER_SELECTOR_PLACEHOLDER, - help = mvp::config::PROVIDER_SELECTOR_HUMAN_SUMMARY - )] - provider: Option, - /// Reuse only the listed domains, for example provider,channels - #[arg(long, value_delimiter = ',')] - include: Vec, - /// Exclude the listed domains from the selected import candidate - #[arg(long, value_delimiter = ',')] - exclude: Vec, - }, - #[command( - about = "Preview or apply config import modes explicitly", - long_about = "Power-user config import flow for discovering, previewing, or applying external workspace state explicitly.\n\nUse this when you want exact CLI control over import mode selection and output handling for compatibility sources and older workspace roots. If you want the guided path, use `loong onboard` instead.\n\nMode quick reference:\n- discover, plan_many, recommend_primary, merge_profiles, map_external_skills: require `--input`\n- plan: requires `--input`; `--output` is optional preview target\n- apply: requires `--input` and `--output`\n- apply_selected: requires `--input` and `--output`; use `--source-id` to pin one discovered source, and `--apply-external-skills-plan` to bridge installable local external skills into the managed runtime\n- rollback_last_apply: requires `--output`" - )] - Migrate { - /// Path to the legacy agent workspace or root to inspect - #[arg(long)] - input: Option, - /// Target LoongClaw config path to preview, write, or roll back - #[arg(long)] - output: Option, - /// Hint the legacy claw-family source kind for single-source plan/apply modes - #[arg(long)] - source: Option, - /// Migration mode to run - #[arg(long, value_enum)] - mode: migrate_cli::MigrateMode, - /// Emit machine-readable JSON instead of text output - #[arg(long, default_value_t = false)] - json: bool, - /// Explicit discovered source id to apply for apply_selected mode - #[arg(long)] - source_id: Option, - /// Merge profile-lane content while keeping one prompt owner - #[arg(long, default_value_t = false)] - safe_profile_merge: bool, - /// Explicit primary source id when safe profile merge is enabled - #[arg(long)] - primary_source_id: Option, - /// Bridge installable local external skills into the managed runtime during apply_selected - #[arg(long, default_value_t = false)] - apply_external_skills_plan: bool, - /// Overwrite an existing target config path instead of stopping for manual review - #[arg(long, default_value_t = false)] - force: bool, - }, - /// Run configuration diagnostics and optionally apply safe config/path fixes - Doctor { - /// Config file path to validate (defaults to auto-discovery) - #[arg(long, global = true)] - config: Option, - /// Apply safe auto-fixes for detected diagnostics - #[arg(long, global = true, default_value_t = false)] - fix: bool, - /// Emit machine-readable JSON diagnostics - #[arg(long, global = true, default_value_t = false)] - json: bool, - /// Skip provider model probing during diagnostics - #[arg(long, global = true, default_value_t = false)] - skip_model_probe: bool, - #[command(subcommand)] - command: Option, - }, - /// Inspect the retained audit journal through a bounded CLI surface - Audit { - #[arg(long, global = true)] - config: Option, - #[arg(long, global = true, default_value_t = false)] - json: bool, - #[command(subcommand)] - command: audit_cli::AuditCommands, - }, - /// Manage installed external skills through an operator-facing CLI surface - Skills { - #[arg(long, global = true)] - config: Option, - #[arg(long, global = true, default_value_t = false)] - json: bool, - #[command(subcommand)] - command: skills_cli::SkillsCommands, - }, - /// Manage async background tasks on top of the current session runtime - Tasks { - #[arg(long, global = true)] - config: Option, - #[arg(long, global = true, default_value_t = false)] - json: bool, - #[arg(long, global = true, default_value = "default")] - session: String, - #[command(subcommand)] - command: tasks_cli::TasksCommands, - }, - #[command(hide = true)] - DelegateChildRun { - #[arg(long)] - config_path: String, - #[arg(long)] - payload_file: String, - }, - #[command( - about = "Inspect and manage persisted runtime sessions through an operator-facing session shell", - long_about = "Bounded operator-facing session shell for persisted runtime sessions.\n\nUse this surface to list visible sessions, inspect one session's workflow metadata, review lifecycle events, inspect transcript history, and apply bounded recover, cancel, or archive actions without inventing a second session model." - )] - Sessions { - #[arg(long, global = true)] - config: Option, - #[arg(long, global = true, default_value_t = false)] - json: bool, - #[arg(long, global = true, default_value = "default")] - session: String, - #[command(subcommand)] - command: sessions_cli::SessionsCommands, - }, - /// Print one operator-readable runtime summary over gateway, ACP, and durable work-unit health - #[rustfmt::skip] - Status { #[arg(long)] config: Option, #[arg(long, default_value_t = false)] json: bool }, - #[command( - visible_alias = "plugin", - about = "Author manifest-first plugin packages and inspect shared plugin governance truth", - long_about = "Manifest-first plugin namespace for bounded authoring bootstrap, inspecting manifest-first package inventory, diagnosing package-author contract issues, evaluating profile-aware preflight, and consuming the deduplicated operator action plan.\n\nThis command does not introduce a second policy engine. It reuses the existing spec `plugin_inventory` and `plugin_preflight` surfaces for shared plugin truth and adds thin author-facing surfaces for external package roots." - )] - Plugins { - #[arg(long, global = true, default_value_t = false)] - json: bool, - #[command(subcommand)] - command: plugins_cli::PluginsCommands, - }, - /// List compiled channel surfaces, aliases, and readiness status - Channels { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Fetch and print currently available provider model list - ListModels { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Print a unified runtime snapshot for experiment reproducibility and lineage capture - RuntimeSnapshot { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - #[arg(long)] - output: Option, - #[arg(long)] - label: Option, - #[arg(long)] - experiment_id: Option, - #[arg(long)] - parent_snapshot_id: Option, - }, - #[command( - long_about = "Restore a persisted runtime snapshot artifact into the current config and managed skill state.\n\nDry-run by default; pass --apply to mutate config or managed skills." - )] - /// Restore a persisted runtime snapshot artifact into the current config and managed skill state - RuntimeRestore { - #[arg(long)] - config: Option, - #[arg(long)] - snapshot: String, - #[arg(long, default_value_t = false)] - json: bool, - #[arg(long, default_value_t = false)] - apply: bool, - }, - /// Manage snapshot-linked experiment run records - RuntimeExperiment { - #[command(subcommand)] - command: runtime_experiment_cli::RuntimeExperimentCommands, - }, - /// Manage run-derived capability candidates, family readiness, promotion plans, and governed apply outputs - RuntimeCapability { - #[command(subcommand)] - command: runtime_capability_cli::RuntimeCapabilityCommands, - }, - /// Manage durable work units for long-running runtime orchestration - WorkUnit { - #[command(subcommand)] - command: work_unit_cli::WorkUnitCommands, - }, - /// List available conversation context engines and selected runtime engine - ListContextEngines { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// List available memory systems and selected runtime memory system - ListMemorySystems { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// List configured MCP servers and their runtime-visible inventory state - ListMcpServers { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Show one configured MCP server and its runtime-visible inventory state - ShowMcpServer { - #[arg(long)] - config: Option, - #[arg(long)] - name: String, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// List available ACP runtime backends and current control-plane selection - ListAcpBackends { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// List persisted ACP session metadata from the local control-plane store - ListAcpSessions { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Inspect live ACP session status by session key or conversation identity - AcpStatus { - #[arg(long)] - config: Option, - #[arg(long, conflicts_with_all = ["conversation_id", "route_session_id"])] - session: Option, - #[arg(long, conflicts_with_all = ["session", "route_session_id"])] - conversation_id: Option, - #[arg(long, conflicts_with_all = ["session", "conversation_id"])] - route_session_id: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Inspect ACP control-plane observability snapshot from the shared session manager - AcpObservability { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Print ACP runtime event summary for a conversation session - AcpEventSummary { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long, default_value_t = 200)] - limit: usize, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Evaluate ACP conversation dispatch policy for a session or structured channel address - AcpDispatch { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long)] - channel: Option, - #[arg(long)] - conversation_id: Option, - #[arg(long)] - account_id: Option, - #[arg(long)] - thread_id: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Run ACP backend readiness diagnostics for the selected or requested backend - AcpDoctor { - #[arg(long)] - config: Option, - #[arg(long)] - backend: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - #[command( - about = "Run the loopback-only internal control-plane skeleton", - long_about = "Run the internal control-plane skeleton.\n\nBy default this control-plane listener binds 127.0.0.1 only. You may provide `--bind ` to override the listener address, but non-loopback binds require `--config` plus `control_plane.allow_remote=true` and a configured `control_plane.shared_token`. Baseline endpoints are `/readyz`, `/healthz`, `/control/challenge`, `/control/connect`, `/control/subscribe`, `/control/snapshot`, and `/control/events`. When `--config` is provided, repository-backed `/session/list`, `/session/read`, `/approval/list`, `/pairing/list`, `/pairing/resolve`, `/acp/session/list`, and `/acp/session/read` views become available for the selected session root." - )] - ControlPlaneServe { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long)] - bind: Option, - #[arg(long, default_value_t = 0)] - port: u16, - }, - #[command( - about = "Run one non-interactive assistant turn", - long_about = "Run one non-interactive one-shot assistant turn.\n\nUse this when you want a fast answer without entering the interactive `loong chat` REPL. The command reuses the normal CLI conversation runtime, session memory, provider selection, and ACP options." - )] - Ask { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long)] - message: String, - #[arg(long, default_value_t = false)] - acp: bool, - #[arg(long, default_value_t = false)] - acp_event_stream: bool, - #[arg(long = "acp-bootstrap-mcp-server")] - acp_bootstrap_mcp_server: Vec, - #[arg(long = "acp-cwd")] - acp_cwd: Option, - }, - /// Start interactive CLI chat channel with sliding-window memory - Chat { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long, default_value_t = false)] - acp: bool, - #[arg(long, default_value_t = false)] - acp_event_stream: bool, - #[arg(long = "acp-bootstrap-mcp-server")] - acp_bootstrap_mcp_server: Vec, - #[arg(long = "acp-cwd")] - acp_cwd: Option, - }, - /// Print safe-lane runtime event summary for a session - SafeLaneSummary { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long, default_value_t = 200)] - limit: usize, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Search transcript turns across visible sessions - SessionSearch { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long)] - query: String, - #[arg(long, default_value_t = 20)] - limit: usize, - #[arg(long)] - output: Option, - #[arg(long, default_value_t = false)] - include_archived: bool, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Inspect one exported session-search artifact - SessionSearchInspect { - #[arg(long)] - artifact: String, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Export one session trajectory artifact with transcript turns and session events - TrajectoryExport { - #[arg(long)] - config: Option, - #[arg(long)] - session: Option, - #[arg(long)] - output: Option, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Inspect one exported trajectory artifact - TrajectoryInspect { - #[arg(long)] - artifact: String, - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Export or inspect runtime trajectory artifacts for replay, evaluation, or research workflows - RuntimeTrajectory { - #[command(subcommand)] - command: runtime_trajectory_cli::RuntimeTrajectoryCommands, - }, - /// Send one Telegram message - TelegramSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_telegram_send_target_kind(), - value_parser = parse_telegram_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Run Telegram channel polling/response loop - TelegramServe { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - once: bool, - #[arg(long)] - account: Option, - }, - /// Send one Feishu message or card - FeishuSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long)] - receive_id_type: Option, - #[arg(long = "target", visible_alias = "receive-id")] - target: String, - #[arg( - long, - default_value_t = default_feishu_send_target_kind(), - value_parser = parse_feishu_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: Option, - #[arg(long = "post-json")] - post_json: Option, - #[arg(long)] - image_key: Option, - #[arg(long)] - file_key: Option, - #[arg(long)] - image_path: Option, - #[arg(long)] - file_path: Option, - #[arg(long)] - file_type: Option, - #[arg(long, default_value_t = false)] - card: bool, - #[arg(long)] - uuid: Option, - }, - /// Run Feishu event callback server and auto-reply via provider - FeishuServe { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long)] - bind: Option, - #[arg(long)] - path: Option, - }, - /// Send one Matrix room message - MatrixSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_matrix_send_target_kind(), - value_parser = parse_matrix_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Run Matrix sync reply loop - MatrixServe { - #[arg(long)] - config: Option, - #[arg(long, default_value_t = false)] - once: bool, - #[arg(long)] - account: Option, - }, - /// Send one WeCom AIBot proactive message - WecomSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_wecom_send_target_kind(), - value_parser = parse_wecom_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Run WeCom AIBot long-connection reply loop - WecomServe { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - }, - /// Run WhatsApp Cloud API webhook server and auto-reply via provider - WhatsappServe { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long)] - bind: Option, - #[arg(long)] - path: Option, - }, - /// Send one Discord channel message - DiscordSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_discord_send_target_kind(), - value_parser = parse_discord_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one DingTalk custom robot webhook message - DingtalkSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: Option, - #[arg( - long, - default_value_t = default_dingtalk_send_target_kind(), - value_parser = parse_dingtalk_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Slack channel message - SlackSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_slack_send_target_kind(), - value_parser = parse_slack_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one LINE push message - LineSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_line_send_target_kind(), - value_parser = parse_line_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one WhatsApp business message - WhatsappSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_whatsapp_send_target_kind(), - value_parser = parse_whatsapp_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one SMTP email message - EmailSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_email_send_target_kind(), - value_parser = parse_email_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one generic webhook POST message - WebhookSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: Option, - #[arg( - long, - default_value_t = default_webhook_send_target_kind(), - value_parser = parse_webhook_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Google Chat incoming webhook message - GoogleChatSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: Option, - #[arg( - long, - default_value_t = default_google_chat_send_target_kind(), - value_parser = parse_google_chat_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Microsoft Teams incoming webhook message - TeamsSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: Option, - #[arg( - long, - default_value_t = default_teams_send_target_kind(), - value_parser = parse_teams_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Tlon direct message or group post - TlonSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_tlon_send_target_kind(), - value_parser = parse_tlon_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Signal direct message - SignalSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_signal_send_target_kind(), - value_parser = parse_signal_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Twitch chat message - TwitchSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_twitch_send_target_kind(), - value_parser = parse_twitch_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Mattermost channel post - MattermostSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_mattermost_send_target_kind(), - value_parser = parse_mattermost_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Nextcloud Talk bot room message - NextcloudTalkSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_nextcloud_talk_send_target_kind(), - value_parser = parse_nextcloud_talk_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one Synology Chat incoming webhook message - SynologyChatSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: Option, - #[arg( - long, - default_value_t = default_synology_chat_send_target_kind(), - value_parser = parse_synology_chat_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one IRC message to a channel or nick - IrcSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_irc_send_target_kind(), - value_parser = parse_irc_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Send one iMessage chat through BlueBubbles - ImessageSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: String, - #[arg( - long, - default_value_t = default_imessage_send_target_kind(), - value_parser = parse_imessage_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Publish one signed Nostr text note - NostrSend { - #[arg(long)] - config: Option, - #[arg(long)] - account: Option, - #[arg(long = "target")] - target: Option, - #[arg( - long, - default_value_t = default_nostr_send_target_kind(), - value_parser = parse_nostr_send_target_kind - )] - target_kind: mvp::channel::ChannelOutboundTargetKind, - #[arg(long)] - text: String, - }, - /// Run the multi-channel supervisor for coordinated runtime-backed service-channel serving - MultiChannelServe { - #[arg(long)] - config: Option, - #[arg(long)] - session: String, - #[arg(long = "channel-account", value_name = "CHANNEL=ACCOUNT")] - channel_account: Vec, - }, - /// Run the gateway lifecycle namespace - Gateway { - #[command(subcommand)] - command: gateway::service::GatewayCommand, - }, - /// Run the Feishu integration namespace - Feishu { - #[command(subcommand)] - command: feishu_cli::FeishuCommand, - }, - /// Print a shell completion script to stdout - Completions { - /// Target shell (bash, zsh, fish, powershell, elvish) - shell: clap_complete::Shell, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum ValidateConfigOutput { - Text, - Json, - ProblemJson, -} - -fn parse_multi_channel_serve_channel_account( - raw: &str, -) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err("multi-channel channel-account entries cannot be empty".to_owned()); - } - - let (raw_channel_id, raw_account_id) = trimmed.split_once('=').ok_or_else(|| { - format!("multi-channel channel-account `{trimmed}` must use CHANNEL=ACCOUNT syntax") - })?; - - let channel_token = raw_channel_id.trim(); - if channel_token.is_empty() { - return Err(format!( - "multi-channel channel-account `{trimmed}` is missing a channel id" - )); - } - - let supported_channel_ids = supported_multi_channel_serve_channel_ids(); - let supported_channels = supported_channel_ids.join(", "); - let runtime_descriptor = mvp::channel::resolve_channel_runtime_command_descriptor(channel_token) - .ok_or_else(|| { - format!( - "unrecognized multi-channel service channel `{channel_token}` (available runtime-backed channels: {supported_channels})" - ) - })?; - let runtime_channel_id = runtime_descriptor.channel_id; - let runtime_is_supported = supported_channel_ids.contains(&runtime_channel_id); - if !runtime_is_supported { - return Err(format!( - "multi-channel service channel `{channel_token}` resolves to `{runtime_channel_id}` but is not supported in this build (expected one of: {supported_channels})" - )); - } - - let account_token = raw_account_id.trim(); - if account_token.is_empty() { - return Err(format!( - "multi-channel channel-account `{trimmed}` is missing an account id" - )); - } - - Ok(MultiChannelServeChannelAccount { - channel_id: runtime_descriptor.channel_id.to_owned(), - account_id: account_token.to_owned(), - }) -} - -fn supported_multi_channel_serve_channel_ids() -> Vec<&'static str> { - let supported_channels = mvp::channel::background_channel_runtime_descriptors() - .into_iter() - .map(|descriptor| descriptor.channel_id) - .collect::>(); - supported_channels.into_iter().collect() -} - -#[cfg(test)] -mod multi_channel_serve_tests { - use std::collections::BTreeSet; - - use super::*; - - #[test] - fn supported_multi_channel_serve_channel_ids_follow_background_runtime_registry() { - let expected_ids = mvp::channel::background_channel_runtime_descriptors() - .into_iter() - .map(|descriptor| descriptor.channel_id) - .collect::>() - .into_iter() - .collect::>(); - let actual_ids = supported_multi_channel_serve_channel_ids(); - - assert_eq!(actual_ids, expected_ids); - } - - #[test] - fn parse_multi_channel_serve_channel_account_rejects_compiled_out_matrix_runtime() { - let supported_channel_ids = supported_multi_channel_serve_channel_ids(); - let matrix_is_supported = supported_channel_ids.contains(&"matrix"); - if matrix_is_supported { - return; - } - - let error = parse_multi_channel_serve_channel_account("matrix=bridge-sync") - .expect_err("compiled-out matrix runtime should be rejected"); - - assert!( - error.contains( - "multi-channel service channel `matrix` resolves to `matrix` but is not supported in this build" - ) - ); - } - - #[test] - fn parse_multi_channel_serve_channel_account_rejects_unknown_runtime_channel() { - let error = parse_multi_channel_serve_channel_account("unknown=bridge-sync") - .expect_err("unknown runtime channel should be rejected"); - - assert!(error.contains("unrecognized multi-channel service channel `unknown`")); - } -} - -fn resolved_default_entry_config_path() -> PathBuf { - std::env::var_os("LOONGCLAW_CONFIG_PATH") - .map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(mvp::config::default_config_path) -} - -fn default_onboard_command() -> Commands { - Commands::Onboard { - output: None, - force: false, - non_interactive: false, - accept_risk: false, - provider: None, - model: None, - api_key_env: None, - web_search_provider: None, - web_search_api_key_env: None, - personality: None, - memory_profile: None, - system_prompt: None, - skip_model_probe: false, - } -} - -pub fn resolve_default_entry_command() -> Commands { - if resolved_default_entry_config_path().is_file() { - Commands::Welcome - } else { - default_onboard_command() - } -} - -pub fn redacted_command_name(command: &Commands) -> &'static str { - command.command_kind_for_logging() -} - -fn resolve_welcome_config_path() -> CliResult { - let config_path = resolved_default_entry_config_path(); - if config_path.is_file() { - Ok(config_path) - } else { - Err(format!( - "Config file not found at {}. Run `{} onboard` to set up LoongClaw.", - config_path.display(), - active_cli_command_name(), - )) - } -} - -fn render_welcome_banner(config_path: &Path, config: &mvp::config::LoongClawConfig) -> String { - let config_path_display = config_path.display().to_string(); - let next_actions = next_actions::collect_setup_next_actions(config, &config_path_display); - let mut quick_command_lines = Vec::new(); - - for action in next_actions { - let action_label = action.label; - let action_command = action.command; - let quick_command_line = format!("- {action_label}: {action_command}"); - quick_command_lines.push(quick_command_line); - } - - quick_command_lines.push(format!("- Help: {} --help", CLI_COMMAND_NAME)); - let quick_commands = quick_command_lines.join("\n"); - - format!( - "LoongClaw is configured and ready.\nVersion: {}\nConfig: {}\n\nQuick commands:\n{}", - env!("CARGO_PKG_VERSION"), - config_path_display, - quick_commands, - ) -} - -pub fn run_welcome_cli() -> CliResult<()> { - let config_path = resolve_welcome_config_path()?; - let config_path_string = config_path.display().to_string(); - let load_result = mvp::config::load(Some(config_path_string.as_str()))?; - let (_resolved_path, config) = load_result; - println!("{}", render_welcome_banner(config_path.as_path(), &config)); - Ok(()) -} - -#[cfg(test)] -mod first_run_entry_tests { - use super::*; - use crate::test_support::ScopedEnv; - use std::{ - fs, - path::{Path, PathBuf}, - process, - sync::atomic::{AtomicU64, Ordering}, - time::{SystemTime, UNIX_EPOCH}, - }; - - static UNIQUE_TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); - - fn unique_temp_dir(prefix: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock should be after epoch") - .as_nanos(); - let pid = process::id(); - let counter = UNIQUE_TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!("{prefix}-{pid}-{nanos}-{counter}")) - } - - fn isolated_home(prefix: &str) -> (ScopedEnv, PathBuf) { - let mut env = ScopedEnv::new(); - let home = unique_temp_dir(prefix); - fs::create_dir_all(&home).expect("create isolated home"); - env.set("HOME", &home); - env.remove("LOONG_HOME"); - env.remove("LOONGCLAW_CONFIG_PATH"); - (env, home) - } - - #[test] - fn resolve_default_entry_command_routes_to_onboard_when_config_is_missing() { - let (_env, _home) = isolated_home("loongclaw-default-entry-missing"); - - assert!( - matches!(resolve_default_entry_command(), Commands::Onboard { .. }), - "missing config should route to onboard" - ); - } - - #[test] - fn resolve_default_entry_command_routes_to_welcome_when_default_config_exists() { - let (_env, _home) = isolated_home("loongclaw-default-entry-present"); - let config_path = mvp::config::default_config_path(); - mvp::config::write( - Some(config_path.to_str().expect("utf8 config path")), - &mvp::config::LoongClawConfig::default(), - true, - ) - .expect("write default config"); - - assert!( - matches!(resolve_default_entry_command(), Commands::Welcome), - "present config should route to welcome" - ); - } - - #[test] - fn resolve_default_entry_command_honors_loongclaw_config_path_override() { - let mut env = ScopedEnv::new(); - let config_path = unique_temp_dir("loongclaw-default-entry-env").join("custom-config.toml"); - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent).expect("create config parent"); - } - mvp::config::write( - Some(config_path.to_str().expect("utf8 config path")), - &mvp::config::LoongClawConfig::default(), - true, - ) - .expect("write explicit config"); - env.set("LOONGCLAW_CONFIG_PATH", &config_path); - - assert!( - matches!(resolve_default_entry_command(), Commands::Welcome), - "env override config should route to welcome" - ); - } - - #[test] - fn resolve_default_entry_command_routes_to_onboard_when_config_path_is_a_directory() { - let mut env = ScopedEnv::new(); - let config_dir = unique_temp_dir("loongclaw-default-entry-dir"); - fs::create_dir_all(&config_dir).expect("create config directory"); - env.set("LOONGCLAW_CONFIG_PATH", &config_dir); - - assert!( - matches!(resolve_default_entry_command(), Commands::Onboard { .. }), - "directory config path should still route to onboard" - ); - } - - #[test] - fn redacted_command_name_omits_sensitive_command_payloads() { - let command = Commands::RunTask { - objective: "secret objective".to_owned(), - payload: "{\"api_key\":\"secret\"}".to_owned(), - }; - - let redacted_name = redacted_command_name(&command); - - assert_eq!(redacted_name, "run_task"); - } - - #[test] - fn run_welcome_cli_rejects_missing_config_file() { - let mut env = ScopedEnv::new(); - let config_path = unique_temp_dir("loongclaw-welcome-missing").join("missing-config.toml"); - env.set("LOONGCLAW_CONFIG_PATH", &config_path); - - let error = run_welcome_cli().expect_err("missing config should fail welcome"); - - assert!( - error.contains("Config file not found"), - "welcome should explain the missing config file: {error}" - ); - assert!( - error.contains("loong onboard"), - "welcome should point users back to onboarding: {error}" - ); - } - - #[test] - fn run_welcome_cli_rejects_directory_config_path() { - let mut env = ScopedEnv::new(); - let config_dir = unique_temp_dir("loongclaw-welcome-dir"); - fs::create_dir_all(&config_dir).expect("create config directory"); - env.set("LOONGCLAW_CONFIG_PATH", &config_dir); - - let error = run_welcome_cli().expect_err("directory config path should fail welcome"); - - assert!( - error.contains("Config file not found"), - "welcome should reject directory config paths as missing config files: {error}" - ); - } - - #[test] - fn render_welcome_banner_includes_version_and_next_commands() { - let config = mvp::config::LoongClawConfig::default(); - let rendered = render_welcome_banner(Path::new("/tmp/loongclaw's config.toml"), &config); - - assert!( - rendered.contains(env!("CARGO_PKG_VERSION")), - "welcome banner should include the current version: {rendered}" - ); - assert!( - rendered.contains("loong ask --config '/tmp/loongclaw'\"'\"'s config.toml'"), - "welcome banner should include a quoted ask command: {rendered}" - ); - assert!( - rendered.contains("loong chat --config '/tmp/loongclaw'\"'\"'s config.toml'"), - "welcome banner should include a quoted chat command: {rendered}" - ); - assert!( - rendered.contains("loong personalize --config '/tmp/loongclaw'\"'\"'s config.toml'"), - "welcome banner should include a quoted personalize command: {rendered}" - ); - assert!( - rendered.contains("loong --help"), - "welcome banner should point users to root help: {rendered}" - ); - assert!( - rendered.contains("- first answer:"), - "welcome banner should preserve the shared next-action label for ask: {rendered}" - ); - assert!( - rendered.contains("- working preferences:"), - "welcome banner should preserve the shared next-action label for personalize: {rendered}" - ); - } -} - -pub async fn invoke_connector_cli(operation: &str, payload_raw: &str) -> CliResult<()> { - let payload = cli_json::parse_json_payload(payload_raw, "invoke-connector payload")?; - - let kernel = kernel_bootstrap::KernelBuilder::default().build(); - let token = kernel - .issue_token(DEFAULT_PACK_ID, DEFAULT_AGENT_ID, 120) - .map_err(|error| format!("token issue failed: {error}"))?; - - let dispatch = kernel - .execute_connector_core( - DEFAULT_PACK_ID, - &token, - None, - ConnectorCommand { - connector_name: "webhook".to_owned(), - operation: operation.to_owned(), - required_capabilities: BTreeSet::from([Capability::InvokeConnector]), - payload, - }, - ) - .await - .map_err(|error| format!("connector dispatch failed: {error}"))?; - - let pretty = serde_json::to_string_pretty(&dispatch.outcome) - .map_err(|error| format!("serialize connector outcome failed: {error}"))?; - println!("{pretty}"); - Ok(()) -} - -pub async fn run_audit_demo() -> CliResult<()> { - let fixed_clock = Arc::new(FixedClock::new(1_700_000_000)); - let audit_sink = Arc::new(InMemoryAuditSink::default()); - - let kernel = kernel_bootstrap::KernelBuilder::default() - .clock(fixed_clock.clone()) - .audit(audit_sink.clone()) - .build(); - - let token = kernel - .issue_token(DEFAULT_PACK_ID, DEFAULT_AGENT_ID, 30) - .map_err(|error| format!("token issue failed: {error}"))?; - - let _ = execute_daemon_task_with_supervisor( - &kernel, - DEFAULT_PACK_ID, - &token, - TaskIntent { - task_id: "task-audit-01".to_owned(), - objective: "produce audit evidence".to_owned(), - required_capabilities: BTreeSet::from([Capability::InvokeTool]), - payload: json!({}), - }, - ) - .await?; - - fixed_clock.advance_by(5); - - let _ = kernel - .execute_connector_core( - DEFAULT_PACK_ID, - &token, - None, - ConnectorCommand { - connector_name: "webhook".to_owned(), - operation: "notify".to_owned(), - required_capabilities: BTreeSet::from([Capability::InvokeConnector]), - payload: json!({"channel": "audit"}), - }, - ) - .await - .map_err(|error| format!("connector invoke failed: {error}"))?; - - kernel - .revoke_token(&token.token_id, Some(DEFAULT_AGENT_ID)) - .map_err(|error| format!("token revoke failed: {error}"))?; - - let pretty = serde_json::to_string_pretty(&audit_sink.snapshot()) - .map_err(|error| format!("serialize audit events failed: {error}"))?; - println!("{pretty}"); - Ok(()) -} - -pub fn init_spec_cli(output_path: &str, preset: InitSpecPreset) -> CliResult<()> { - let spec = match preset { - InitSpecPreset::Default => RunnerSpec::template(), - InitSpecPreset::PluginTrustGuard => RunnerSpec::plugin_trust_guard_template(), - }; - write_json_file(output_path, &spec)?; - println!("spec template written to {}", output_path); - Ok(()) -} - -pub async fn run_spec_cli( - spec_path: &str, - print_audit: bool, - render_summary: bool, - bridge_support: &RunSpecBridgeSupportArgs, -) -> CliResult<()> { - validate_run_spec_bridge_support_args(bridge_support)?; - let resolved = read_spec_file_with_bridge_support_resolution( - spec_path, - run_spec_bridge_support_selection(bridge_support).as_ref(), - )?; - let report = execute_spec_with_native_tool_executor_and_bridge_support_provenance( - &resolved.spec, - print_audit, - Some(native_spec_tool_executor), - resolved.bridge_support_source, - resolved.bridge_support_delta_source, - resolved.bridge_support_delta_sha256, - ) - .await; - if render_summary { - eprintln!("{}", render_spec_run_summary(&report)); - } - let pretty = serde_json::to_string_pretty(&report) - .map_err(|error| format!("serialize spec run report failed: {error}"))?; - println!("{pretty}"); - Ok(()) -} - -fn validate_run_spec_bridge_support_args(args: &RunSpecBridgeSupportArgs) -> CliResult<()> { - let has_policy_source = args.bridge_support.is_some() - || args.bridge_profile.is_some() - || args.bridge_support_delta.is_some(); - let has_sha256_pin = - args.bridge_support_sha256.is_some() || args.bridge_support_delta_sha256.is_some(); - - if has_policy_source || !has_sha256_pin { - return Ok(()); - } - - Err( - "run-spec bridge support sha256 pins require --bridge-support, --bridge-profile, or --bridge-support-delta" - .to_owned(), - ) -} - -fn render_spec_run_summary(report: &SpecRunReport) -> String { - let mut lines = vec![format!( - "run-spec summary pack={} agent={} status={} operation={}", - report.pack_id, - report.agent_id, - spec_run_status_label(report), - report.operation_kind - )]; - - if let Some(blocked_reason) = report.blocked_reason.as_deref() { - lines.push(format!( - "blocked_reason={}", - sanitize_summary_field(blocked_reason) - )); - } - - if report.plugin_trust_summary.scanned_plugins > 0 { - let trust = &report.plugin_trust_summary; - lines.push(format!( - "plugin_trust scanned={} official={} verified_community={} unverified={} high_risk={} high_risk_unverified={} blocked_auto_apply={} review_required={}", - trust.scanned_plugins, - trust.official_plugins, - trust.verified_community_plugins, - trust.unverified_plugins, - trust.high_risk_plugins, - trust.high_risk_unverified_plugins, - trust.blocked_auto_apply_plugins, - trust.review_required_plugins.len() - )); - - for entry in trust.review_required_plugins.iter().take(3) { - lines.push(render_plugin_trust_review_summary(entry)); - } - if trust.review_required_plugins.len() > 3 { - lines.push(format!( - "plugin_review remaining={}", - trust.review_required_plugins.len() - 3 - )); - } - } - - if let Some(summary) = report.tool_search_summary.as_ref() { - lines.push(format!( - "tool_search {}", - sanitize_summary_field(&summary.headline) - )); - - if summary.trust_filter_summary.applied { - lines.push(format!( - "tool_search_filters query_requested={} structured_requested={} effective={} conflicting={} filtered_out_by_tier={}", - format_string_list_or_dash(&summary.trust_filter_summary.query_requested_tiers), - format_string_list_or_dash(&summary.trust_filter_summary.structured_requested_tiers), - format_string_list_or_dash(&summary.trust_filter_summary.effective_tiers), - summary.trust_filter_summary.conflicting_requested_tiers, - format_usize_rollup(&summary.trust_filter_summary.filtered_out_tier_counts) - )); - } - - for (index, entry) in summary.top_results.iter().enumerate() { - lines.push(format!( - "tool_search_top[{}] provider={} connector={} tool_id={} trust={} bridge={} score={} setup_ready={} loaded={} deferred={}", - index + 1, - entry.provider_id, - entry.connector_name, - entry.tool_id, - entry.trust_tier.as_deref().unwrap_or("-"), - entry.bridge_kind, - entry.score, - entry.setup_ready, - entry.loaded, - entry.deferred - )); - } - } - - lines.join("\n") -} - -fn spec_run_status_label(report: &SpecRunReport) -> &'static str { - if report.blocked_reason.is_some() || report.operation_kind == "blocked" { - "blocked" - } else { - "ok" - } -} - -fn render_plugin_trust_review_summary(entry: &PluginTrustReviewEntry) -> String { - format!( - "plugin_review plugin={} tier={} bridge={} activation={} bootstrap={} source={} provenance={} reason={}", - entry.plugin_id, - entry.trust_tier.as_str(), - entry.bridge_kind.as_str(), - plugin_activation_status_label(entry.activation_status), - entry - .bootstrap_status - .map(bootstrap_task_status_label) - .unwrap_or("-"), - sanitize_summary_field(&entry.source_path), - sanitize_summary_field(&entry.provenance_summary), - sanitize_summary_field(&entry.reason) - ) -} - -fn plugin_activation_status_label(status: PluginActivationStatus) -> &'static str { - match status { - PluginActivationStatus::Ready => "ready", - PluginActivationStatus::SetupIncomplete => "setup_incomplete", - PluginActivationStatus::BlockedInvalidManifestContract => { - "blocked_invalid_manifest_contract" - } - PluginActivationStatus::BlockedUnsupportedBridge => "blocked_unsupported_bridge", - PluginActivationStatus::BlockedUnsupportedAdapterFamily => { - "blocked_unsupported_adapter_family" - } - PluginActivationStatus::BlockedCompatibilityMode => "blocked_compatibility_mode", - PluginActivationStatus::BlockedIncompatibleHost => "blocked_incompatible_host", - PluginActivationStatus::BlockedSlotClaimConflict => "blocked_slot_claim_conflict", - PluginActivationStatus::Unknown => "unknown", - } -} - -fn bootstrap_task_status_label(status: BootstrapTaskStatus) -> &'static str { - match status { - BootstrapTaskStatus::Applied => "applied", - BootstrapTaskStatus::DeferredUnsupportedAutoApply => "deferred_unsupported_auto_apply", - BootstrapTaskStatus::SkippedNotReady => "skipped_not_ready", - BootstrapTaskStatus::SkippedByPolicyLimit => "skipped_by_policy_limit", - } -} - -fn format_string_list_or_dash(values: &[String]) -> String { - if values.is_empty() { - return "-".to_owned(); - } - - values.join(",") -} - -fn sanitize_summary_field(value: &str) -> String { - value.split_whitespace().collect::>().join(" ") -} - -fn run_spec_bridge_support_selection( - args: &RunSpecBridgeSupportArgs, -) -> Option { - let selection = BridgeSupportSelectionInput { - path: args.bridge_support.clone(), - bundled_profile: args - .bridge_profile - .map(BridgeSupportProfileArg::as_str) - .map(str::to_owned), - delta_artifact: args.bridge_support_delta.clone(), - expected_sha256: args.bridge_support_sha256.clone(), - expected_delta_sha256: args.bridge_support_delta_sha256.clone(), - }; - (selection.path.is_some() - || selection.bundled_profile.is_some() - || selection.delta_artifact.is_some()) - .then_some(selection) -} - -#[derive(Debug, Clone, Deserialize)] -struct RunnerSpecFileInput { - #[serde(flatten)] - spec: RunnerSpec, - #[serde(default)] - bridge_support_selection: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct BridgeSupportSelectionInput { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub bundled_profile: Option, - #[serde(default)] - pub delta_artifact: Option, - #[serde(default)] - pub expected_sha256: Option, - #[serde(default)] - pub expected_delta_sha256: Option, -} - -#[derive(Debug, Clone)] -pub struct ResolvedRunnerSpecFile { - pub spec: RunnerSpec, - pub bridge_support_source: Option, - pub bridge_support_delta_source: Option, - pub bridge_support_delta_sha256: Option, -} - -pub fn run_validate_config_cli( - config_path: Option<&str>, - as_json: bool, - output: Option, - locale: &str, - fail_on_diagnostics: bool, -) -> CliResult<()> { - let output = resolve_validate_output(as_json, output)?; - let normalized_locale = mvp::config::normalize_validation_locale(locale); - let supported_locales = mvp::config::supported_validation_locales(); - let (resolved_path, diagnostics) = - mvp::config::validate_file_with_locale(config_path, &normalized_locale)?; - let diagnostics_count = diagnostics.len(); - let diagnostics_summary = summarize_validation_diagnostics(&diagnostics); - - match output { - ValidateConfigOutput::Text => { - if diagnostics.is_empty() { - println!("config={} valid=true", resolved_path.display()); - } else { - println!( - "config={} valid={} diagnostics={} errors={} warnings={}", - resolved_path.display(), - diagnostics_summary.valid, - diagnostics_count, - diagnostics_summary.error_count, - diagnostics_summary.warning_count, - ); - for diagnostic in &diagnostics { - println!("{}", diagnostic.message); - } - } - } - ValidateConfigOutput::Json => { - let payload = json!({ - "diagnostics_schema_version": 1, - "config": resolved_path.display().to_string(), - "valid": diagnostics_summary.valid, - "error_count": diagnostics_summary.error_count, - "warning_count": diagnostics_summary.warning_count, - "locale": normalized_locale, - "supported_locales": supported_locales.clone(), - "diagnostics": diagnostics, - }); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize config validation output failed: {error}"))?; - println!("{pretty}"); - } - ValidateConfigOutput::ProblemJson => { - let payload = if diagnostics.is_empty() { - json!({ - "type": "urn:loongclaw:problem:none", - "title": "Configuration Valid", - "detail": "No configuration diagnostics were reported.", - "instance": resolved_path.display().to_string(), - "valid": true, - "error_count": 0, - "warning_count": 0, - "locale": normalized_locale, - "supported_locales": supported_locales.clone(), - "diagnostics_schema_version": 1, - "errors": [], - }) - } else { - json!({ - "type": if diagnostics_summary.valid { - "urn:loongclaw:problem:config.validation_warning" - } else { - "urn:loongclaw:problem:config.validation_failed" - }, - "title": if diagnostics_summary.valid { - "Configuration Warnings Reported" - } else { - "Configuration Validation Failed" - }, - "detail": format!("{} configuration diagnostic(s) were reported.", diagnostics_count), - "instance": resolved_path.display().to_string(), - "valid": diagnostics_summary.valid, - "error_count": diagnostics_summary.error_count, - "warning_count": diagnostics_summary.warning_count, - "locale": normalized_locale, - "supported_locales": supported_locales.clone(), - "diagnostics_schema_version": 1, - "errors": diagnostics, - }) - }; - let pretty = serde_json::to_string_pretty(&payload).map_err(|error| { - format!("serialize config validation problem output failed: {error}") - })?; - println!("{pretty}"); - } - } - - if fail_on_diagnostics && diagnostics_count > 0 { - return Err(format!( - "config validation failed with {diagnostics_count} diagnostic(s)" - )); - } - - Ok(()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ValidationDiagnosticSummary { - pub valid: bool, - pub error_count: usize, - pub warning_count: usize, -} - -pub fn summarize_validation_diagnostics( - diagnostics: &[mvp::config::ConfigValidationDiagnostic], -) -> ValidationDiagnosticSummary { - let error_count = diagnostics - .iter() - .filter(|diagnostic| diagnostic.severity == "error") - .count(); - let warning_count = diagnostics - .iter() - .filter(|diagnostic| diagnostic.severity == "warn") - .count(); - ValidationDiagnosticSummary { - valid: error_count == 0, - error_count, - warning_count, - } -} - -pub fn resolve_validate_output( - as_json: bool, - output: Option, -) -> CliResult { - if as_json && output.is_some() { - return Err( - "validate-config: `--json` conflicts with `--output`; use one of them".to_owned(), - ); - } - if as_json { - return Ok(ValidateConfigOutput::Json); - } - Ok(output.unwrap_or(ValidateConfigOutput::Text)) -} - -pub async fn run_list_models_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let models = mvp::provider::fetch_available_models(&config).await?; - if as_json { - let payload = json!({ - "config": resolved_path.display().to_string(), - "provider_kind": config.provider.kind, - "models_endpoint": config.provider.models_endpoint(), - "models": models, - }); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize model-list output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!( - "config={} provider_kind={:?} models_endpoint={}", - resolved_path.display(), - config.provider.kind, - config.provider.models_endpoint() - ); - for model in models { - println!("{model}"); - } - Ok(()) -} - -pub const RUNTIME_SNAPSHOT_CLI_JSON_SCHEMA_VERSION: u32 = 1; -pub const RUNTIME_SNAPSHOT_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 2; -#[derive(Debug, Clone)] -pub struct RuntimeSnapshotCliState { - pub config: String, - pub provider: RuntimeSnapshotProviderState, - pub context_engine: mvp::conversation::ContextEngineRuntimeSnapshot, - pub memory_system: mvp::memory::MemorySystemRuntimeSnapshot, - pub acp: mvp::acp::AcpRuntimeSnapshot, - pub enabled_channel_ids: Vec, - pub enabled_service_channel_ids: Vec, - pub channels: mvp::channel::ChannelInventory, - pub tool_runtime: mvp::tools::runtime_config::ToolRuntimeConfig, - pub visible_tool_names: Vec, - pub capability_snapshot: String, - pub capability_snapshot_sha256: String, - pub tool_calling: RuntimeSnapshotToolCallingState, - pub runtime_plugins: RuntimeSnapshotRuntimePluginsState, - pub external_skills: RuntimeSnapshotExternalSkillsState, - pub restore_spec: RuntimeSnapshotRestoreSpec, -} - -#[derive(Debug, Clone)] -pub struct RuntimeSnapshotProviderState { - pub active_profile_id: String, - pub active_label: String, - pub last_provider_id: Option, - pub saved_profile_ids: Vec, - pub profiles: Vec, -} - -#[derive(Debug, Clone)] -pub struct RuntimeSnapshotProviderProfileState { - pub profile_id: String, - pub is_active: bool, - pub default_for_kind: bool, - pub descriptor: mvp::config::ProviderDescriptorDocument, - pub kind: mvp::config::ProviderKind, - pub model: String, - pub wire_api: mvp::config::ProviderWireApi, - pub base_url: String, - pub endpoint: String, - pub models_endpoint: String, - pub protocol_family: &'static str, - pub credential_resolved: bool, - pub auth_env: Option, - pub reasoning_effort: Option, - pub temperature: f64, - pub max_tokens: Option, - pub request_timeout_ms: u64, - pub retry_max_attempts: usize, - pub header_names: Vec, - pub preferred_models: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimeSnapshotInventoryStatus { - Ok, - Disabled, - Error, -} - -impl RuntimeSnapshotInventoryStatus { - pub const fn as_str(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Disabled => "disabled", - Self::Error => "error", - } - } -} - -#[derive(Debug, Clone)] -pub struct RuntimeSnapshotExternalSkillsState { - pub policy: mvp::tools::runtime_config::ExternalSkillsRuntimePolicy, - pub override_active: bool, - pub inventory_status: RuntimeSnapshotInventoryStatus, - pub inventory_error: Option, - pub inventory: Value, - pub resolved_skill_count: usize, - pub shadowed_skill_count: usize, -} - -#[derive(Debug, Clone)] -pub struct RuntimeSnapshotRuntimePluginsState { - pub enabled: bool, - pub roots: Vec, - pub supported_bridges: Vec, - pub supported_adapter_families: Vec, - pub inventory_status: RuntimeSnapshotInventoryStatus, - pub inventory_error: Option, - pub readiness_evaluation: String, - pub scanned_root_count: usize, - pub scanned_file_count: usize, - pub discovered_plugin_count: usize, - pub translated_plugin_count: usize, - pub ready_plugin_count: usize, - pub setup_incomplete_plugin_count: usize, - pub blocked_plugin_count: usize, - pub plugins: Vec, -} - -#[derive(Debug, Clone)] -pub struct RuntimeSnapshotRuntimePluginState { - pub plugin_id: String, - pub provider_id: String, - pub connector_name: String, - pub source_path: String, - pub source_kind: String, - pub package_root: String, - pub package_manifest_path: Option, - pub bridge_kind: String, - pub adapter_family: String, - pub setup_mode: Option, - pub setup_surface: Option, - pub slot_claims: Vec, - pub conflicting_slot_claims: Vec, - pub status: String, - pub reason: String, - pub missing_required_env_vars: Vec, - pub missing_required_config_keys: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeSnapshotArtifactMetadata { - pub created_at: String, - pub label: Option, - pub experiment_id: Option, - pub parent_snapshot_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeSnapshotArtifactLineage { - pub snapshot_id: String, - pub created_at: String, - pub label: Option, - pub experiment_id: Option, - pub parent_snapshot_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct RuntimeSnapshotRestoreSpec { - pub provider: RuntimeSnapshotRestoreProviderSpec, - pub conversation: mvp::config::ConversationConfig, - pub memory: mvp::config::MemoryConfig, - pub acp: mvp::config::AcpConfig, - pub tools: mvp::config::ToolConfig, - pub external_skills: mvp::config::ExternalSkillsConfig, - #[serde(default)] - pub runtime_plugins: mvp::config::RuntimePluginsConfig, - pub managed_skills: RuntimeSnapshotRestoreManagedSkillsSpec, - pub warnings: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct RuntimeSnapshotRestoreProviderSpec { - pub active_provider: Option, - pub last_provider: Option, - pub profiles: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -pub struct RuntimeSnapshotRestoreManagedSkillsSpec { - pub skills: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeSnapshotRestoreManagedSkillSpec { - pub skill_id: String, - pub display_name: String, - pub summary: String, - pub source_kind: String, - pub source_path: String, - pub sha256: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeSnapshotArtifactSchema { - pub version: u32, - pub surface: String, - pub purpose: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct RuntimeSnapshotArtifactDocument { - pub config: String, - pub schema: RuntimeSnapshotArtifactSchema, - pub lineage: RuntimeSnapshotArtifactLineage, - pub provider: Value, - pub context_engine: Value, - pub memory_system: Value, - pub acp: Value, - pub channels: Value, - pub tool_runtime: Value, - pub tools: Value, - #[serde(default)] - pub runtime_plugins: Value, - pub external_skills: Value, - pub restore_spec: RuntimeSnapshotRestoreSpec, -} - -pub fn run_runtime_snapshot_cli( - config_path: Option<&str>, - as_json: bool, - output_path: Option<&str>, - label: Option<&str>, - experiment_id: Option<&str>, - parent_snapshot_id: Option<&str>, -) -> CliResult<()> { - let snapshot = collect_runtime_snapshot_cli_state(config_path)?; - let metadata = - runtime_snapshot_artifact_metadata_now(label, experiment_id, parent_snapshot_id)?; - let artifact_payload = build_runtime_snapshot_artifact_json_payload(&snapshot, &metadata)?; - - if let Some(output_path) = output_path { - persist_json_artifact(output_path, &artifact_payload, "runtime snapshot artifact")?; - } - - if as_json { - let pretty = serde_json::to_string_pretty(&artifact_payload).map_err(|error| { - format!("serialize runtime snapshot artifact output failed: {error}") - })?; - println!("{pretty}"); - return Ok(()); - } - - println!( - "{}", - render_runtime_snapshot_artifact_text(&snapshot, &artifact_payload) - ); - Ok(()) -} - -pub fn collect_runtime_snapshot_cli_state( - config_path: Option<&str>, -) -> CliResult { - let (resolved_path, config) = mvp::config::load(config_path)?; - collect_runtime_snapshot_cli_state_from_parts(resolved_path.as_path(), &config) -} - -pub(crate) fn collect_runtime_snapshot_cli_state_from_loaded_config( - loaded_config: &supervisor::LoadedSupervisorConfig, -) -> CliResult { - let resolved_path = loaded_config.resolved_path.as_path(); - let config = &loaded_config.config; - collect_runtime_snapshot_cli_state_from_parts(resolved_path, config) -} - -fn collect_runtime_snapshot_cli_state_from_parts( - resolved_path: &Path, - config: &mvp::config::LoongClawConfig, -) -> CliResult { - let config_display = resolved_path.display().to_string(); - let provider = collect_runtime_snapshot_provider_state(config); - let context_engine = mvp::conversation::collect_context_engine_runtime_snapshot(config)?; - let memory_system = mvp::memory::collect_memory_system_runtime_snapshot(config)?; - let acp = mvp::acp::collect_acp_runtime_snapshot(config)?; - let enabled_channel_ids = config.enabled_channel_ids(); - let enabled_service_channel_ids = config.enabled_service_channel_ids(); - let channels = mvp::channel::channel_inventory(config); - let tool_runtime = mvp::tools::runtime_config::ToolRuntimeConfig::from_loongclaw_config( - config, - Some(resolved_path), - ); - let (external_skills, snapshot_tool_runtime) = - collect_runtime_snapshot_external_skills_state(&tool_runtime); - let tool_view = mvp::tools::runtime_tool_view_for_runtime_config(&snapshot_tool_runtime); - let visible_tools = tool_view - .tool_names() - .map(str::to_owned) - .collect::>(); - let capability_snapshot = mvp::tools::capability_snapshot_with_config(&snapshot_tool_runtime); - let capability_snapshot_sha256 = - runtime_snapshot_tool_digest(&visible_tools, &capability_snapshot)?; - let tool_calling = collect_runtime_snapshot_tool_calling_state(config, visible_tools.len()); - let runtime_plugins = collect_runtime_snapshot_runtime_plugins_state(config); - let restore_spec = build_runtime_snapshot_restore_spec(config, &external_skills); - Ok(RuntimeSnapshotCliState { - config: config_display, - provider, - context_engine, - memory_system, - acp, - enabled_channel_ids, - enabled_service_channel_ids, - channels, - tool_runtime: snapshot_tool_runtime, - visible_tool_names: visible_tools, - capability_snapshot, - capability_snapshot_sha256, - tool_calling, - runtime_plugins, - external_skills, - restore_spec, - }) -} - -fn collect_runtime_snapshot_provider_state( - config: &mvp::config::LoongClawConfig, -) -> RuntimeSnapshotProviderState { - let active_profile_id = config - .active_provider_id() - .unwrap_or(config.provider.kind.profile().id) - .to_owned(); - let saved_profile_ids = provider_presentation::saved_provider_profile_ids(config); - let profiles = if config.providers.is_empty() { - vec![build_runtime_snapshot_provider_profile_state( - active_profile_id.as_str(), - &mvp::config::ProviderProfileConfig { - default_for_kind: true, - provider: config.provider.clone(), - }, - true, - )] - } else { - saved_profile_ids - .iter() - .filter_map(|profile_id| { - config.providers.get(profile_id).map(|profile| { - build_runtime_snapshot_provider_profile_state( - profile_id, - profile, - profile_id == &active_profile_id, - ) - }) - }) - .collect::>() - }; - - RuntimeSnapshotProviderState { - active_profile_id, - active_label: provider_presentation::active_provider_detail_label(config), - last_provider_id: config.last_provider_id().map(str::to_owned), - saved_profile_ids, - profiles, - } -} - -fn build_runtime_snapshot_provider_profile_state( - profile_id: &str, - profile: &mvp::config::ProviderProfileConfig, - is_active: bool, -) -> RuntimeSnapshotProviderProfileState { - let provider = &profile.provider; - let descriptor = provider.descriptor_document(); - let mut header_names = provider.headers.keys().cloned().collect::>(); - header_names.sort(); - - RuntimeSnapshotProviderProfileState { - profile_id: profile_id.to_owned(), - is_active, - default_for_kind: profile.default_for_kind, - descriptor, - kind: provider.kind, - model: provider.model.clone(), - wire_api: provider.wire_api, - base_url: provider.resolved_base_url(), - endpoint: provider.endpoint(), - models_endpoint: provider.models_endpoint(), - protocol_family: provider.kind.profile().protocol_family.as_str(), - credential_resolved: runtime_snapshot_provider_credentials_resolved(provider), - auth_env: provider.resolved_auth_env_name(), - reasoning_effort: provider - .reasoning_effort - .map(|value| value.as_str().to_owned()), - temperature: provider.temperature, - max_tokens: provider.max_tokens, - request_timeout_ms: provider.request_timeout_ms, - retry_max_attempts: provider.retry_max_attempts, - header_names, - preferred_models: provider.preferred_models.clone(), - } -} - -fn runtime_snapshot_provider_credentials_resolved(provider: &mvp::config::ProviderConfig) -> bool { - provider_credential_policy::provider_has_locally_available_credentials(provider) -} - -fn collect_runtime_snapshot_external_skills_state( - tool_runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, -) -> ( - RuntimeSnapshotExternalSkillsState, - mvp::tools::runtime_config::ToolRuntimeConfig, -) { - let empty_inventory = json!({ - "skills": [], - "shadowed_skills": [], - }); - - let (effective_policy, override_active) = - match runtime_snapshot_effective_external_skills_policy(tool_runtime) { - Ok(policy_state) => policy_state, - Err(error) => { - return ( - RuntimeSnapshotExternalSkillsState { - policy: tool_runtime.external_skills.clone(), - override_active: false, - inventory_status: RuntimeSnapshotInventoryStatus::Error, - inventory_error: Some(error.clone()), - inventory: json!({ - "skills": [], - "shadowed_skills": [], - "error": error, - }), - resolved_skill_count: 0, - shadowed_skill_count: 0, - }, - tool_runtime.clone(), - ); - } - }; - - let mut effective_tool_runtime = tool_runtime.clone(); - effective_tool_runtime.external_skills = effective_policy.clone(); - - if !effective_policy.enabled { - return ( - RuntimeSnapshotExternalSkillsState { - policy: effective_policy, - override_active, - inventory_status: RuntimeSnapshotInventoryStatus::Disabled, - inventory_error: None, - inventory: empty_inventory, - resolved_skill_count: 0, - shadowed_skill_count: 0, - }, - effective_tool_runtime, - ); - } - - match mvp::tools::execute_tool_core_with_config( - ToolCoreRequest { - tool_name: "external_skills.list".to_owned(), - payload: json!({}), - }, - &effective_tool_runtime, - ) { - Ok(outcome) => ( - RuntimeSnapshotExternalSkillsState { - policy: effective_policy, - override_active, - inventory_status: RuntimeSnapshotInventoryStatus::Ok, - inventory_error: None, - resolved_skill_count: json_array_len(outcome.payload.get("skills")), - shadowed_skill_count: json_array_len(outcome.payload.get("shadowed_skills")), - inventory: outcome.payload, - }, - effective_tool_runtime, - ), - Err(error) => ( - RuntimeSnapshotExternalSkillsState { - policy: effective_policy, - override_active, - inventory_status: RuntimeSnapshotInventoryStatus::Error, - inventory_error: Some(error.clone()), - inventory: json!({ - "skills": [], - "shadowed_skills": [], - "error": error, - }), - resolved_skill_count: 0, - shadowed_skill_count: 0, - }, - effective_tool_runtime, - ), - } -} - -pub(crate) fn collect_runtime_snapshot_runtime_plugins_state( - config: &mvp::config::LoongClawConfig, -) -> RuntimeSnapshotRuntimePluginsState { - let readiness_evaluation = config - .runtime_plugins - .readiness_evaluation_label() - .to_owned(); - let roots = config - .runtime_plugins - .resolved_roots() - .into_iter() - .map(|root| root.display().to_string()) - .collect::>(); - let supported_bridges = config - .runtime_plugins - .resolved_supported_bridges() - .unwrap_or_default() - .into_iter() - .map(|bridge_kind| bridge_kind.as_str().to_owned()) - .collect::>(); - let supported_adapter_families = config - .runtime_plugins - .normalized_supported_adapter_families(); - - if !config.runtime_plugins.enabled { - return RuntimeSnapshotRuntimePluginsState { - enabled: false, - roots, - supported_bridges, - supported_adapter_families, - inventory_status: RuntimeSnapshotInventoryStatus::Disabled, - inventory_error: None, - readiness_evaluation, - scanned_root_count: 0, - scanned_file_count: 0, - discovered_plugin_count: 0, - translated_plugin_count: 0, - ready_plugin_count: 0, - setup_incomplete_plugin_count: 0, - blocked_plugin_count: 0, - plugins: Vec::new(), - }; - } - - let resolved_roots = config.runtime_plugins.resolved_roots(); - if resolved_roots.is_empty() { - return RuntimeSnapshotRuntimePluginsState { - enabled: true, - roots, - supported_bridges, - supported_adapter_families, - inventory_status: RuntimeSnapshotInventoryStatus::Error, - inventory_error: Some( - "runtime_plugins.enabled=true but no runtime plugin roots are configured" - .to_owned(), - ), - readiness_evaluation, - scanned_root_count: 0, - scanned_file_count: 0, - discovered_plugin_count: 0, - translated_plugin_count: 0, - ready_plugin_count: 0, - setup_incomplete_plugin_count: 0, - blocked_plugin_count: 0, - plugins: Vec::new(), - }; - } - - let scanner = PluginScanner::new(); - let mut combined = kernel::PluginScanReport::default(); - for root in &resolved_roots { - let report = match scanner.scan_path(root) { - Ok(report) => report, - Err(error) => { - return RuntimeSnapshotRuntimePluginsState { - enabled: true, - roots, - supported_bridges, - supported_adapter_families, - inventory_status: RuntimeSnapshotInventoryStatus::Error, - inventory_error: Some(format!( - "runtime plugin scan failed for {}: {error}", - root.display() - )), - readiness_evaluation, - scanned_root_count: 0, - scanned_file_count: 0, - discovered_plugin_count: 0, - translated_plugin_count: 0, - ready_plugin_count: 0, - setup_incomplete_plugin_count: 0, - blocked_plugin_count: 0, - plugins: Vec::new(), - }; - } - }; - merge_plugin_scan_report(&mut combined, report); - } - - let bridge_matrix = match config.runtime_plugins.resolved_bridge_support_matrix() { - Ok(matrix) => matrix, - Err(error) => { - return RuntimeSnapshotRuntimePluginsState { - enabled: true, - roots, - supported_bridges, - supported_adapter_families, - inventory_status: RuntimeSnapshotInventoryStatus::Error, - inventory_error: Some(error), - readiness_evaluation, - scanned_root_count: resolved_roots.len(), - scanned_file_count: combined.scanned_files, - discovered_plugin_count: combined.matched_plugins, - translated_plugin_count: 0, - ready_plugin_count: 0, - setup_incomplete_plugin_count: 0, - blocked_plugin_count: 0, - plugins: Vec::new(), - }; - } - }; - - let translator = PluginTranslator::new(); - let translation = translator.translate_scan_report(&combined); - let readiness_context = runtime_plugin_setup_readiness_context(config); - let activation = translator.plan_activation(&translation, &bridge_matrix, &readiness_context); - let inventory_entries = activation.inventory_entries(&translation); - let inventory_by_key = inventory_entries - .into_iter() - .map(|entry| ((entry.source_path.clone(), entry.plugin_id.clone()), entry)) - .collect::>(); - - let plugins = translation - .entries - .iter() - .map(|entry| { - let entry_key = (entry.source_path.clone(), entry.plugin_id.clone()); - let inventory_entry = inventory_by_key.get(&entry_key); - let setup_mode = entry - .setup - .as_ref() - .map(|setup| setup.mode.as_str().to_owned()); - let setup_surface = entry.setup.as_ref().and_then(|setup| setup.surface.clone()); - let setup_requirements = evaluate_plugin_setup_requirements( - entry - .setup - .as_ref() - .map(|setup| setup.required_env_vars.as_slice()) - .unwrap_or(&[]), - entry - .setup - .as_ref() - .map(|setup| setup.required_config_keys.as_slice()) - .unwrap_or(&[]), - &readiness_context, - ); - let activation_status = inventory_entry.and_then(|item| item.activation_status); - let slot_claims = entry - .slot_claims - .iter() - .map(kernel::PluginSlotClaim::canonical_label) - .collect::>(); - let conflicting_slot_claims = if matches!( - activation_status, - Some(PluginActivationStatus::BlockedSlotClaimConflict) - ) { - slot_claims.clone() - } else { - Vec::new() - }; - let status = activation_status - .map(runtime_plugin_activation_status) - .unwrap_or("unknown") - .to_owned(); - let reason = inventory_entry - .and_then(|item| item.activation_reason.clone()) - .unwrap_or_else(|| "-".to_owned()); - let missing_required_env_vars = if matches!( - activation_status, - Some(PluginActivationStatus::SetupIncomplete) - ) { - setup_requirements.missing_required_env_vars - } else { - Vec::new() - }; - let missing_required_config_keys = if matches!( - activation_status, - Some(PluginActivationStatus::SetupIncomplete) - ) { - setup_requirements.missing_required_config_keys - } else { - Vec::new() - }; - - RuntimeSnapshotRuntimePluginState { - plugin_id: entry.plugin_id.clone(), - provider_id: entry.provider_id.clone(), - connector_name: entry.connector_name.clone(), - source_path: entry.source_path.clone(), - source_kind: entry.source_kind.as_str().to_owned(), - package_root: entry.package_root.clone(), - package_manifest_path: entry.package_manifest_path.clone(), - bridge_kind: entry.runtime.bridge_kind.as_str().to_owned(), - adapter_family: entry.runtime.adapter_family.clone(), - setup_mode, - setup_surface, - slot_claims, - conflicting_slot_claims, - status, - reason, - missing_required_env_vars, - missing_required_config_keys, - } - }) - .collect::>(); - - RuntimeSnapshotRuntimePluginsState { - enabled: true, - roots, - supported_bridges, - supported_adapter_families, - inventory_status: RuntimeSnapshotInventoryStatus::Ok, - inventory_error: None, - readiness_evaluation, - scanned_root_count: resolved_roots.len(), - scanned_file_count: combined.scanned_files, - discovered_plugin_count: combined.matched_plugins, - translated_plugin_count: translation.translated_plugins, - ready_plugin_count: activation.ready_plugins, - setup_incomplete_plugin_count: activation.setup_incomplete_plugins, - blocked_plugin_count: activation.blocked_plugins, - plugins, - } -} - -fn merge_plugin_scan_report( - combined: &mut kernel::PluginScanReport, - report: kernel::PluginScanReport, -) { - let kernel::PluginScanReport { - scanned_files, - matched_plugins, - descriptors, - diagnostic_findings, - } = report; - - combined.scanned_files += scanned_files; - combined.matched_plugins += matched_plugins; - combined.descriptors.extend(descriptors); - combined.diagnostic_findings.extend(diagnostic_findings); -} - -fn runtime_plugin_setup_readiness_context( - config: &mvp::config::LoongClawConfig, -) -> PluginSetupReadinessContext { - let verified_env_vars = std::env::vars_os() - .filter_map(|(key, value)| { - let value_string = value.to_string_lossy(); - let trimmed_value = value_string.trim(); - if trimmed_value.is_empty() { - return None; - } - - Some(key.to_string_lossy().to_string()) - }) - .collect(); - let mut verified_config_keys = BTreeSet::new(); - if let Ok(value) = serde_json::to_value(config) { - collect_config_paths(&value, None, &mut verified_config_keys); - } - - PluginSetupReadinessContext { - verified_env_vars, - verified_config_keys, - } -} - -fn collect_config_paths(value: &Value, prefix: Option<&str>, out: &mut BTreeSet) { - match value { - Value::Object(map) => { - for (key, child) in map { - let next_prefix = match prefix { - Some(prefix) => format!("{prefix}.{key}"), - None => key.clone(), - }; - - match child { - Value::Null => {} - Value::Object(_) - | Value::Array(_) - | Value::Bool(_) - | Value::Number(_) - | Value::String(_) => { - out.insert(next_prefix.clone()); - collect_config_paths(child, Some(next_prefix.as_str()), out); - } - } - } - } - Value::Array(items) => { - for child in items { - collect_config_paths(child, prefix, out); - } - } - Value::Null => {} - Value::Bool(_) | Value::Number(_) | Value::String(_) => { - if let Some(prefix) = prefix { - out.insert(prefix.to_owned()); - } - } - } -} - -fn runtime_snapshot_effective_external_skills_policy( - tool_runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, -) -> Result< - ( - mvp::tools::runtime_config::ExternalSkillsRuntimePolicy, - bool, - ), - String, -> { - let outcome = mvp::tools::execute_tool_core_with_config( - ToolCoreRequest { - tool_name: "external_skills.policy".to_owned(), - payload: json!({ - "action": "get", - }), - }, - tool_runtime, - ) - .map_err(|error| format!("resolve effective external skills policy failed: {error}"))?; - - let policy = runtime_snapshot_external_skills_policy_from_payload(&outcome.payload)?; - let override_active = outcome - .payload - .get("override_active") - .and_then(Value::as_bool) - .unwrap_or(false); - Ok((policy, override_active)) -} - -fn runtime_snapshot_external_skills_policy_from_payload( - payload: &Value, -) -> Result { - let policy = payload - .get("policy") - .and_then(Value::as_object) - .ok_or_else(|| { - "runtime snapshot external skills policy payload missing `policy`".to_owned() - })?; - - Ok(mvp::tools::runtime_config::ExternalSkillsRuntimePolicy { - enabled: policy - .get("enabled") - .and_then(Value::as_bool) - .ok_or_else(|| { - "runtime snapshot external skills policy missing `enabled`".to_owned() - })?, - require_download_approval: policy - .get("require_download_approval") - .and_then(Value::as_bool) - .ok_or_else(|| { - "runtime snapshot external skills policy missing `require_download_approval`" - .to_owned() - })?, - allowed_domains: json_string_array_to_set( - policy.get("allowed_domains"), - "runtime snapshot external skills policy.allowed_domains", - )?, - blocked_domains: json_string_array_to_set( - policy.get("blocked_domains"), - "runtime snapshot external skills policy.blocked_domains", - )?, - install_root: policy - .get("install_root") - .and_then(Value::as_str) - .map(Path::new) - .map(Path::to_path_buf), - auto_expose_installed: policy - .get("auto_expose_installed") - .and_then(Value::as_bool) - .ok_or_else(|| { - "runtime snapshot external skills policy missing `auto_expose_installed`".to_owned() - })?, - }) -} - -fn runtime_snapshot_tool_digest( - visible_tool_names: &[String], - capability_snapshot: &str, -) -> CliResult { - let serialized = serde_json::to_vec(&json!({ - "visible_tool_names": visible_tool_names, - "capability_snapshot": capability_snapshot, - })) - .map_err(|error| format!("serialize runtime snapshot tool digest input failed: {error}"))?; - Ok(hex::encode(Sha256::digest(serialized))) -} - -fn json_array_len(value: Option<&Value>) -> usize { - value.and_then(Value::as_array).map_or(0, Vec::len) -} - -fn runtime_plugin_activation_status(status: PluginActivationStatus) -> &'static str { - status.as_str() -} - -fn json_string_array_to_set( - value: Option<&Value>, - context: &str, -) -> Result, String> { - let items = value - .and_then(Value::as_array) - .ok_or_else(|| format!("{context} must be an array"))?; - items - .iter() - .map(|item| { - item.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("{context} must contain only strings")) - }) - .collect() -} - -fn build_runtime_snapshot_restore_spec( - config: &mvp::config::LoongClawConfig, - external_skills: &RuntimeSnapshotExternalSkillsState, -) -> RuntimeSnapshotRestoreSpec { - let mut warnings = Vec::new(); - let mut profiles = runtime_snapshot_restore_provider_profiles(config); - for (profile_id, profile) in &mut profiles { - normalize_runtime_snapshot_restore_provider_profile(profile_id, profile, &mut warnings); - } - - RuntimeSnapshotRestoreSpec { - provider: RuntimeSnapshotRestoreProviderSpec { - active_provider: config.active_provider_id().map(str::to_owned), - last_provider: config.last_provider_id().map(str::to_owned), - profiles, - }, - conversation: config.conversation.clone(), - memory: config.memory.clone(), - acp: config.acp.clone(), - tools: config.tools.clone(), - external_skills: config.external_skills.clone(), - runtime_plugins: config.runtime_plugins.clone(), - managed_skills: build_runtime_snapshot_restore_managed_skills_spec( - external_skills, - &mut warnings, - ), - warnings, - } -} - -fn runtime_snapshot_restore_provider_profiles( - config: &mvp::config::LoongClawConfig, -) -> BTreeMap { - if !config.providers.is_empty() { - return config.providers.clone(); - } - - let profile_id = config - .active_provider_id() - .unwrap_or(config.provider.kind.profile().id) - .to_owned(); - BTreeMap::from([( - profile_id, - mvp::config::ProviderProfileConfig { - default_for_kind: true, - provider: config.provider.clone(), - }, - )]) -} - -fn normalize_runtime_snapshot_restore_provider_profile( - profile_id: &str, - profile: &mut mvp::config::ProviderProfileConfig, - warnings: &mut Vec, -) { - runtime_snapshot_migrate_provider_env_reference( - &mut profile.provider.api_key, - &mut profile.provider.api_key_env, - ); - runtime_snapshot_migrate_provider_env_reference( - &mut profile.provider.oauth_access_token, - &mut profile.provider.oauth_access_token_env, - ); - - if runtime_snapshot_redact_provider_secret_field( - profile.provider.api_key.as_mut(), - profile_id, - "api_key", - warnings, - ) { - profile.provider.api_key = None; - } - if runtime_snapshot_redact_provider_secret_field( - profile.provider.oauth_access_token.as_mut(), - profile_id, - "oauth_access_token", - warnings, - ) { - profile.provider.oauth_access_token = None; - } - - let header_keys_to_remove = profile - .provider - .headers - .iter() - .filter(|(header_name, header_value)| { - !runtime_snapshot_provider_header_is_safe_to_persist( - profile.provider.kind, - header_name, - header_value, - ) - }) - .map(|(header_name, _)| header_name.clone()) - .collect::>(); - for header_name in header_keys_to_remove { - profile.provider.headers.remove(&header_name); - warnings.push(format!( - "restore spec redacted inline provider header `{header_name}` for profile `{profile_id}`" - )); - } -} - -fn runtime_snapshot_redact_provider_secret_field( - raw: Option<&mut SecretRef>, - profile_id: &str, - field_name: &str, - warnings: &mut Vec, -) -> bool { - let Some(raw) = raw else { - return false; - }; - if raw.inline_literal_value().is_none() { - return false; - } - warnings.push(format!( - "restore spec redacted inline provider credential `{field_name}` for profile `{profile_id}`" - )); - true -} - -fn runtime_snapshot_provider_header_is_safe_to_persist( - provider_kind: mvp::config::ProviderKind, - header_name: &str, - header_value: &str, -) -> bool { - if header_value.trim().is_empty() || runtime_snapshot_is_env_reference_literal(header_value) { - return true; - } - - let normalized = header_name.trim().to_ascii_lowercase(); - matches!( - normalized.as_str(), - "accept" - | "accept-charset" - | "accept-encoding" - | "accept-language" - | "anthropic-version" - | "cache-control" - | "content-language" - | "content-type" - | "pragma" - | "user-agent" - | "anthropic-beta" - | "openai-beta" - ) || provider_kind - .default_headers() - .iter() - .any(|(default_name, _)| default_name.eq_ignore_ascii_case(&normalized)) -} - -fn runtime_snapshot_migrate_provider_env_reference( - inline_secret: &mut Option, - env_name: &mut Option, -) { - let explicit_env_name = inline_secret - .as_ref() - .and_then(SecretRef::explicit_env_name); - if let Some(explicit_env_name) = explicit_env_name { - *inline_secret = Some(SecretRef::Env { - env: explicit_env_name, - }); - *env_name = None; - return; - } - - if inline_secret.as_ref().is_some_and(SecretRef::is_configured) { - *env_name = None; - return; - } - - let configured_env_name = env_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - if let Some(configured_env_name) = configured_env_name { - *inline_secret = Some(SecretRef::Env { - env: configured_env_name, - }); - } - *env_name = None; -} - -fn runtime_snapshot_is_env_reference_literal(raw: &str) -> bool { - runtime_snapshot_parse_env_reference(raw).is_some() -} - -fn runtime_snapshot_parse_env_reference(raw: &str) -> Option<&str> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - - if let Some(inner) = trimmed - .strip_prefix("${") - .and_then(|value| value.strip_suffix('}')) - { - return runtime_snapshot_is_valid_env_name(inner).then_some(inner); - } - - if let Some(inner) = trimmed.strip_prefix('$') { - return runtime_snapshot_is_valid_env_name(inner).then_some(inner); - } - - if let Some(inner) = trimmed.strip_prefix("env:") { - return runtime_snapshot_is_valid_env_name(inner).then_some(inner); - } - - if let Some(inner) = trimmed - .strip_prefix('%') - .and_then(|value| value.strip_suffix('%')) - { - return runtime_snapshot_is_valid_env_name(inner).then_some(inner); - } - - None -} - -fn runtime_snapshot_is_valid_env_name(raw: &str) -> bool { - let mut chars = raw.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !(first == '_' || first.is_ascii_alphabetic()) { - return false; - } - chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) -} - -fn build_runtime_snapshot_restore_managed_skills_spec( - external_skills: &RuntimeSnapshotExternalSkillsState, - warnings: &mut Vec, -) -> RuntimeSnapshotRestoreManagedSkillsSpec { - match external_skills.inventory_status { - RuntimeSnapshotInventoryStatus::Disabled => { - warnings.push( - "restore spec could not enumerate managed external skills because runtime inventory is disabled" - .to_owned(), - ); - return RuntimeSnapshotRestoreManagedSkillsSpec::default(); - } - RuntimeSnapshotInventoryStatus::Error => { - warnings.push( - "restore spec could not enumerate managed external skills because runtime inventory collection failed" - .to_owned(), - ); - return RuntimeSnapshotRestoreManagedSkillsSpec::default(); - } - RuntimeSnapshotInventoryStatus::Ok => {} - } - - let Some(skills) = external_skills - .inventory - .get("skills") - .and_then(Value::as_array) - else { - return RuntimeSnapshotRestoreManagedSkillsSpec::default(); - }; - - let mut managed_skills = skills - .iter() - .filter(|skill| skill.get("scope").and_then(Value::as_str) == Some("managed")) - .filter_map(|skill| { - let skill_id = skill.get("skill_id").and_then(Value::as_str)?; - let display_name = skill - .get("display_name") - .and_then(Value::as_str) - .unwrap_or_default(); - let summary = skill - .get("summary") - .and_then(Value::as_str) - .unwrap_or_default(); - let source_kind = skill.get("source_kind").and_then(Value::as_str)?; - let source_path = skill.get("source_path").and_then(Value::as_str)?; - let sha256 = skill.get("sha256").and_then(Value::as_str)?; - Some(RuntimeSnapshotRestoreManagedSkillSpec { - skill_id: skill_id.to_owned(), - display_name: display_name.to_owned(), - summary: summary.to_owned(), - source_kind: source_kind.to_owned(), - source_path: source_path.to_owned(), - sha256: sha256.to_owned(), - }) - }) - .collect::>(); - managed_skills.sort_by(|left, right| left.skill_id.cmp(&right.skill_id)); - RuntimeSnapshotRestoreManagedSkillsSpec { - skills: managed_skills, - } -} - -#[cfg(test)] -mod runtime_snapshot_restore_spec_tests { - use super::*; - use serde_json::json; - - #[test] - fn runtime_snapshot_restore_managed_skills_keeps_entries_without_display_metadata() { - let mut warnings = Vec::new(); - let spec = build_runtime_snapshot_restore_managed_skills_spec( - &RuntimeSnapshotExternalSkillsState { - policy: mvp::tools::runtime_config::ExternalSkillsRuntimePolicy::default(), - override_active: false, - inventory_status: RuntimeSnapshotInventoryStatus::Ok, - inventory_error: None, - inventory: json!({ - "skills": [{ - "scope": "managed", - "skill_id": "demo-skill", - "source_kind": "directory", - "source_path": "/tmp/demo-skill", - "sha256": "deadbeef" - }] - }), - resolved_skill_count: 1, - shadowed_skill_count: 0, - }, - &mut warnings, - ); - - assert!(warnings.is_empty()); - assert_eq!(spec.skills.len(), 1); - assert_eq!(spec.skills[0].skill_id, "demo-skill"); - assert!(spec.skills[0].display_name.is_empty()); - assert!(spec.skills[0].summary.is_empty()); - } - - #[test] - fn runtime_snapshot_provider_header_safety_uses_explicit_safe_names_only() { - assert!(runtime_snapshot_provider_header_is_safe_to_persist( - mvp::config::ProviderKind::Anthropic, - "anthropic-version", - "2023-06-01", - )); - assert!(runtime_snapshot_provider_header_is_safe_to_persist( - mvp::config::ProviderKind::Deepseek, - "anthropic-version", - "2023-06-01", - )); - assert!(runtime_snapshot_provider_header_is_safe_to_persist( - mvp::config::ProviderKind::Anthropic, - "anthropic-beta", - "prompt-caching-2024-07-31", - )); - assert!(runtime_snapshot_provider_header_is_safe_to_persist( - mvp::config::ProviderKind::Openai, - "openai-beta", - "assistants=v2", - )); - assert!(runtime_snapshot_provider_header_is_safe_to_persist( - mvp::config::ProviderKind::Deepseek, - "x-goog-api-key", - "${GOOGLE_API_KEY}", - )); - assert!(!runtime_snapshot_provider_header_is_safe_to_persist( - mvp::config::ProviderKind::Deepseek, - "x-secret-beta", - "literal-secret", - )); - assert!(!runtime_snapshot_provider_header_is_safe_to_persist( - mvp::config::ProviderKind::Deepseek, - "x-secret-version", - "literal-secret", - )); - } - - #[test] - fn runtime_snapshot_restore_normalization_moves_provider_env_name_fields_into_secret_refs() { - let mut warnings = Vec::new(); - let mut profile = mvp::config::ProviderProfileConfig { - default_for_kind: true, - provider: mvp::config::ProviderConfig { - kind: mvp::config::ProviderKind::Openai, - model: "openai/gpt-5.1-codex".to_owned(), - api_key_env: Some("OPENAI_API_KEY".to_owned()), - oauth_access_token_env: Some("OPENAI_CODEX_OAUTH_TOKEN".to_owned()), - ..Default::default() - }, - }; - - normalize_runtime_snapshot_restore_provider_profile( - "openai-main", - &mut profile, - &mut warnings, - ); - - assert_eq!( - profile.provider.api_key, - Some(SecretRef::Env { - env: "OPENAI_API_KEY".to_owned(), - }) - ); - assert_eq!(profile.provider.api_key_env, None); - assert_eq!( - profile.provider.oauth_access_token, - Some(SecretRef::Env { - env: "OPENAI_CODEX_OAUTH_TOKEN".to_owned(), - }) - ); - assert_eq!(profile.provider.oauth_access_token_env, None); - assert!(warnings.is_empty()); - } - - #[test] - fn runtime_snapshot_restore_normalization_canonicalizes_matching_explicit_env_reference() { - let mut warnings = Vec::new(); - let mut profile = mvp::config::ProviderProfileConfig { - default_for_kind: true, - provider: mvp::config::ProviderConfig { - kind: mvp::config::ProviderKind::Openai, - model: "openai/gpt-5.1-codex".to_owned(), - api_key: Some(SecretRef::Inline("${INLINE_OPENAI_API_KEY}".to_owned())), - api_key_env: Some(" INLINE_OPENAI_API_KEY ".to_owned()), - oauth_access_token: Some(SecretRef::Inline( - "$INLINE_OPENAI_OAUTH_TOKEN".to_owned(), - )), - oauth_access_token_env: Some("INLINE_OPENAI_OAUTH_TOKEN".to_owned()), - ..Default::default() - }, - }; - - normalize_runtime_snapshot_restore_provider_profile( - "openai-main", - &mut profile, - &mut warnings, - ); - - assert_eq!( - profile.provider.api_key, - Some(SecretRef::Env { - env: "INLINE_OPENAI_API_KEY".to_owned(), - }) - ); - assert_eq!(profile.provider.api_key_env, None); - assert_eq!( - profile.provider.oauth_access_token, - Some(SecretRef::Env { - env: "INLINE_OPENAI_OAUTH_TOKEN".to_owned(), - }) - ); - assert_eq!(profile.provider.oauth_access_token_env, None); - assert!(warnings.is_empty()); - } - - #[test] - fn runtime_snapshot_restore_normalization_prefers_explicit_env_reference_over_legacy_env_field() - { - let mut warnings = Vec::new(); - let mut profile = mvp::config::ProviderProfileConfig { - default_for_kind: true, - provider: mvp::config::ProviderConfig { - kind: mvp::config::ProviderKind::Openai, - model: "openai/gpt-5.1-codex".to_owned(), - api_key: Some(SecretRef::Inline("${INLINE_OPENAI_API_KEY}".to_owned())), - api_key_env: Some("CONFIGURED_OPENAI_API_KEY".to_owned()), - oauth_access_token: Some(SecretRef::Inline( - "$INLINE_OPENAI_OAUTH_TOKEN".to_owned(), - )), - oauth_access_token_env: Some("CONFIGURED_OPENAI_OAUTH_TOKEN".to_owned()), - ..Default::default() - }, - }; - - normalize_runtime_snapshot_restore_provider_profile( - "openai-main", - &mut profile, - &mut warnings, - ); - - assert_eq!( - profile.provider.api_key, - Some(SecretRef::Env { - env: "INLINE_OPENAI_API_KEY".to_owned(), - }) - ); - assert_eq!(profile.provider.api_key_env, None); - assert_eq!( - profile.provider.oauth_access_token, - Some(SecretRef::Env { - env: "INLINE_OPENAI_OAUTH_TOKEN".to_owned(), - }) - ); - assert_eq!(profile.provider.oauth_access_token_env, None); - assert!(warnings.is_empty()); - } - - #[test] - fn runtime_snapshot_restore_normalization_treats_blank_inline_secret_as_absent() { - let mut warnings = Vec::new(); - let mut profile = mvp::config::ProviderProfileConfig { - default_for_kind: true, - provider: mvp::config::ProviderConfig { - kind: mvp::config::ProviderKind::Openai, - model: "openai/gpt-5.1-codex".to_owned(), - api_key: Some(SecretRef::Inline(" ".to_owned())), - api_key_env: Some("OPENAI_API_KEY".to_owned()), - oauth_access_token: Some(SecretRef::Inline(" ".to_owned())), - oauth_access_token_env: Some("OPENAI_CODEX_OAUTH_TOKEN".to_owned()), - ..Default::default() - }, - }; - - normalize_runtime_snapshot_restore_provider_profile( - "openai-main", - &mut profile, - &mut warnings, - ); - - assert_eq!( - profile.provider.api_key, - Some(SecretRef::Env { - env: "OPENAI_API_KEY".to_owned(), - }) - ); - assert_eq!(profile.provider.api_key_env, None); - assert_eq!( - profile.provider.oauth_access_token, - Some(SecretRef::Env { - env: "OPENAI_CODEX_OAUTH_TOKEN".to_owned(), - }) - ); - assert_eq!(profile.provider.oauth_access_token_env, None); - assert!(warnings.is_empty()); - } - - #[test] - fn runtime_snapshot_tool_runtime_json_reports_browser_execution_tiers() { - let mut runtime = mvp::tools::runtime_config::ToolRuntimeConfig::default(); - runtime.browser_companion.enabled = true; - runtime.browser_companion.ready = true; - runtime.browser_companion.command = Some("browser-companion".to_owned()); - - let json = runtime_snapshot_tool_runtime_json(&runtime); - - assert_eq!(json["browser"]["execution_tier"], json!("restricted")); - assert_eq!( - json["browser_companion"]["execution_tier"], - json!("balanced") - ); - } -} - -fn runtime_snapshot_artifact_metadata_now( - label: Option<&str>, - experiment_id: Option<&str>, - parent_snapshot_id: Option<&str>, -) -> CliResult { - let created_at = OffsetDateTime::now_utc() - .format(&Rfc3339) - .map_err(|error| format!("format runtime snapshot artifact timestamp failed: {error}"))?; - Ok(RuntimeSnapshotArtifactMetadata { - created_at, - label: runtime_snapshot_optional_arg(label), - experiment_id: runtime_snapshot_optional_arg(experiment_id), - parent_snapshot_id: runtime_snapshot_optional_arg(parent_snapshot_id), - }) -} - -fn runtime_snapshot_optional_arg(raw: Option<&str>) -> Option { - raw.map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) -} - -pub(crate) fn persist_json_artifact( - output_path: &str, - payload: &Value, - artifact_label: &str, -) -> CliResult<()> { - let output_path = PathBuf::from(output_path); - let parent_path = output_path - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")); - fs::create_dir_all(&parent_path).map_err(|error| { - format!( - "create {artifact_label} directory {} failed: {error}", - parent_path.display() - ) - })?; - let encoded = serde_json::to_string_pretty(payload) - .map_err(|error| format!("serialize {artifact_label} failed: {error}"))?; - let file_name = output_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("artifact"); - let process_id = process::id(); - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| format!("build {artifact_label} temp path failed: {error}"))? - .as_nanos(); - let temp_file_name = format!(".{file_name}.{process_id}.{timestamp}.tmp"); - let temp_path = parent_path.join(temp_file_name); - - let open_result = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path); - let mut temp_file = open_result.map_err(|error| { - format!( - "create {artifact_label} temp file {} failed: {error}", - temp_path.display() - ) - })?; - temp_file.write_all(encoded.as_bytes()).map_err(|error| { - format!( - "write {artifact_label} temp file {} failed: {error}", - temp_path.display() - ) - })?; - temp_file.sync_all().map_err(|error| { - format!( - "sync {artifact_label} temp file {} failed: {error}", - temp_path.display() - ) - })?; - drop(temp_file); - - let rename_result = fs::rename(&temp_path, &output_path); - if let Err(error) = rename_result { - let _ = fs::remove_file(&temp_path); - return Err(format!( - "replace {artifact_label} {} failed: {error}", - output_path.display() - )); - } - Ok(()) -} - -pub fn build_runtime_snapshot_artifact_json_payload( - snapshot: &RuntimeSnapshotCliState, - metadata: &RuntimeSnapshotArtifactMetadata, -) -> CliResult { - let base_payload = cli_json::build_runtime_snapshot_cli_json_payload(snapshot)?; - let lineage = runtime_snapshot_artifact_lineage(snapshot, metadata)?; - let document = RuntimeSnapshotArtifactDocument { - config: snapshot.config.clone(), - schema: RuntimeSnapshotArtifactSchema { - version: RUNTIME_SNAPSHOT_ARTIFACT_JSON_SCHEMA_VERSION, - surface: "runtime_snapshot".to_owned(), - purpose: "experiment_reproducibility".to_owned(), - }, - lineage, - provider: base_payload.get("provider").cloned().unwrap_or(Value::Null), - context_engine: base_payload - .get("context_engine") - .cloned() - .unwrap_or(Value::Null), - memory_system: base_payload - .get("memory_system") - .cloned() - .unwrap_or(Value::Null), - acp: base_payload.get("acp").cloned().unwrap_or(Value::Null), - channels: base_payload.get("channels").cloned().unwrap_or(Value::Null), - tool_runtime: base_payload - .get("tool_runtime") - .cloned() - .unwrap_or(Value::Null), - tools: base_payload.get("tools").cloned().unwrap_or(Value::Null), - runtime_plugins: base_payload - .get("runtime_plugins") - .cloned() - .unwrap_or(Value::Null), - external_skills: base_payload - .get("external_skills") - .cloned() - .unwrap_or(Value::Null), - restore_spec: snapshot.restore_spec.clone(), - }; - serde_json::to_value(document) - .map_err(|error| format!("serialize runtime snapshot artifact payload failed: {error}")) -} - -fn runtime_snapshot_artifact_lineage( - snapshot: &RuntimeSnapshotCliState, - metadata: &RuntimeSnapshotArtifactMetadata, -) -> CliResult { - let serialized = serde_json::to_vec(&json!({ - "config": snapshot.config, - "created_at": metadata.created_at, - "label": metadata.label, - "experiment_id": metadata.experiment_id, - "parent_snapshot_id": metadata.parent_snapshot_id, - "capability_snapshot_sha256": snapshot.capability_snapshot_sha256, - "active_provider": snapshot.provider.active_profile_id, - })) - .map_err(|error| format!("serialize runtime snapshot lineage input failed: {error}"))?; - Ok(RuntimeSnapshotArtifactLineage { - snapshot_id: hex::encode(Sha256::digest(serialized)), - created_at: metadata.created_at.clone(), - label: metadata.label.clone(), - experiment_id: metadata.experiment_id.clone(), - parent_snapshot_id: metadata.parent_snapshot_id.clone(), - }) -} - -fn render_runtime_snapshot_artifact_text( - snapshot: &RuntimeSnapshotCliState, - artifact_payload: &Value, -) -> String { - let lineage = artifact_payload - .get("lineage") - .cloned() - .unwrap_or(Value::Null); - let schema_version = artifact_payload - .get("schema") - .and_then(|schema| schema.get("version")) - .and_then(Value::as_u64) - .unwrap_or(u64::from(RUNTIME_SNAPSHOT_ARTIFACT_JSON_SCHEMA_VERSION)); - - [ - format!("schema.version={schema_version}"), - format!("snapshot_id={}", json_string_field(&lineage, "snapshot_id")), - format!("created_at={}", json_string_field(&lineage, "created_at")), - format!("label={}", json_string_field(&lineage, "label")), - format!( - "experiment_id={}", - json_string_field(&lineage, "experiment_id") - ), - format!( - "parent_snapshot_id={}", - json_string_field(&lineage, "parent_snapshot_id") - ), - format!("restore_warnings={}", snapshot.restore_spec.warnings.len()), - render_runtime_snapshot_text(snapshot), - ] - .join("\n") -} -pub fn run_channels_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let inventory = mvp::channel::channel_inventory(&config); - let resolved_path_display = resolved_path.display().to_string(); - - if as_json { - let payload = build_channels_cli_json_payload(&resolved_path_display, &inventory); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize channel status output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!( - "{}", - render_channel_surfaces_text(&resolved_path_display, &inventory) - ); - Ok(()) -} - -pub const CHANNELS_CLI_JSON_SCHEMA_VERSION: u32 = 1; -pub const CHANNELS_CLI_JSON_LEGACY_VIEWS: &[&str] = &["channels", "catalog_only_channels"]; - -pub fn build_channels_cli_json_payload( - config_path: &str, - inventory: &mvp::channel::ChannelInventory, -) -> ChannelsCliJsonPayload { - gateway::read_models::build_channel_inventory_read_model(config_path, inventory) -} - -pub fn render_channel_surfaces_text( - config_path: &str, - inventory: &mvp::channel::ChannelInventory, -) -> String { - let mut lines = vec![format!("config={config_path}")]; - let mut catalog_only_surfaces = Vec::new(); - - for surface in &inventory.channel_surfaces { - if surface.catalog.implementation_status - == mvp::channel::ChannelCatalogImplementationStatus::Stub - { - catalog_only_surfaces.push(surface); - continue; - } - - push_channel_surface_header(&mut lines, surface); - lines.push(render_channel_onboarding_line(&surface.catalog.onboarding)); - push_channel_surface_plugin_bridge_contract(&mut lines, surface); - push_channel_surface_managed_plugin_bridge_discovery(&mut lines, surface); - for snapshot in &surface.configured_accounts { - let api_base_url = snapshot.api_base_url.as_deref().unwrap_or("-"); - lines.push(format!( - " account configured_account={} configured_account_label={} default_account={} default_source={} compiled={} enabled={} api_base_url={}", - snapshot.configured_account_id, - snapshot.configured_account_label, - snapshot.is_default_account, - snapshot.default_account_source.as_str(), - snapshot.compiled, - snapshot.enabled, - api_base_url - )); - for note in &snapshot.notes { - lines.push(format!(" note: {note}")); - } - for operation in &snapshot.operations { - let catalog_operation = surface.catalog.operation(operation.id); - let requirement_ids = catalog_operation - .map(|catalog_operation| { - render_channel_operation_requirement_ids(catalog_operation.requirements) - }) - .unwrap_or_else(|| "-".to_owned()); - lines.push(format!( - " op {} ({}) {}: {} target_kinds={} requirements={}", - operation.id, - operation.command, - operation.health.as_str(), - operation.detail, - render_channel_target_kind_ids( - catalog_operation - .map(|catalog_operation| catalog_operation.supported_target_kinds) - .unwrap_or(&[]) - ), - requirement_ids, - )); - if let Some(runtime) = &operation.runtime { - lines.push(format!( - " runtime account={} account_id={} running={} stale={} busy={} active_runs={} instance_count={} running_instances={} stale_instances={} last_run_activity_at={} last_heartbeat_at={} pid={}", - runtime - .account_label - .as_deref() - .unwrap_or("-"), - runtime - .account_id - .as_deref() - .unwrap_or("-"), - runtime.running, - runtime.stale, - runtime.busy, - runtime.active_runs, - runtime.instance_count, - runtime.running_instances, - runtime.stale_instances, - runtime - .last_run_activity_at - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_owned()), - runtime - .last_heartbeat_at - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_owned()), - runtime - .pid - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_owned()) - )); - } - for issue in &operation.issues { - lines.push(format!(" issue: {issue}")); - } - } - } - } - - if !catalog_only_surfaces.is_empty() { - lines.push("catalog-only channels:".to_owned()); - for surface in catalog_only_surfaces { - push_channel_surface_header(&mut lines, surface); - lines.push(render_channel_onboarding_line(&surface.catalog.onboarding)); - push_channel_surface_plugin_bridge_contract(&mut lines, surface); - push_channel_surface_managed_plugin_bridge_discovery(&mut lines, surface); - for operation in &surface.catalog.operations { - lines.push(format!( - " catalog op {} ({}) availability={} tracks_runtime={} target_kinds={} requirements={}", - operation.id, - operation.command, - operation.availability.as_str(), - operation.tracks_runtime, - render_channel_target_kind_ids(operation.supported_target_kinds), - render_channel_operation_requirement_ids(operation.requirements) - )); - } - } - } - lines.join("\n") -} - -pub fn render_channel_onboarding_line( - onboarding: &mvp::channel::ChannelOnboardingDescriptor, -) -> String { - format!( - " onboarding strategy={} status_command=\"{}\" repair_command={} setup_hint=\"{}\"", - onboarding.strategy.as_str(), - onboarding.status_command, - onboarding - .repair_command - .map(|command| format!("\"{command}\"")) - .unwrap_or_else(|| "-".to_owned()), - onboarding.setup_hint - ) -} - -pub fn render_channel_operation_requirement_ids( - requirements: &[mvp::channel::ChannelCatalogOperationRequirement], -) -> String { - if requirements.is_empty() { - return "-".to_owned(); - } - requirements - .iter() - .map(|requirement| requirement.id) - .collect::>() - .join(",") -} - -pub fn render_channel_target_kind_ids( - target_kinds: &[mvp::channel::ChannelCatalogTargetKind], -) -> String { - if target_kinds.is_empty() { - return "-".to_owned(); - } - target_kinds - .iter() - .map(|kind| kind.as_str()) - .collect::>() - .join(",") -} - -pub fn push_channel_surface_header( - lines: &mut Vec, - surface: &mvp::channel::ChannelSurface, -) { - let aliases = if surface.catalog.aliases.is_empty() { - "-".to_owned() - } else { - surface.catalog.aliases.join(",") - }; - let capabilities = if surface.catalog.capabilities.is_empty() { - "-".to_owned() - } else { - surface - .catalog - .capabilities - .iter() - .map(|capability| capability.as_str()) - .collect::>() - .join(",") - }; - let target_kinds = render_channel_target_kind_ids(&surface.catalog.supported_target_kinds); - lines.push(format!( - "{} [{}] implementation_status={} selection_order={} selection_label=\"{}\" capabilities={} aliases={} transport={} target_kinds={} configured_accounts={} default_configured_account={}", - surface.catalog.label, - surface.catalog.id, - surface.catalog.implementation_status.as_str(), - surface.catalog.selection_order, - surface.catalog.selection_label, - capabilities, - aliases, - surface.catalog.transport, - target_kinds, - surface.configured_accounts.len(), - surface - .default_configured_account_id - .as_deref() - .unwrap_or("-") - )); - lines.push(format!(" blurb: {}", surface.catalog.blurb)); -} - -pub fn run_list_context_engines_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let snapshot = mvp::conversation::collect_context_engine_runtime_snapshot(&config)?; - - if as_json { - let payload = json!({ - "config": resolved_path.display().to_string(), - "selected": context_engine_metadata_json( - &snapshot.selected_metadata, - Some(snapshot.selected.source.as_str()) - ), - "available": snapshot - .available - .iter() - .map(|metadata| context_engine_metadata_json(metadata, None)) - .collect::>(), - "compaction": { - "enabled": snapshot.compaction.enabled, - "min_messages": snapshot.compaction.min_messages, - "trigger_estimated_tokens": snapshot.compaction.trigger_estimated_tokens, - "fail_open": snapshot.compaction.fail_open, - }, - }); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize context-engine output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!("config={}", resolved_path.display()); - println!( - "selected={} source={} api_version={} capabilities={}", - snapshot.selected_metadata.id, - snapshot.selected.source.as_str(), - snapshot.selected_metadata.api_version, - format_capability_names(&snapshot.selected_metadata.capability_names()) - ); - println!( - "compaction=enabled:{} min_messages:{} trigger_estimated_tokens:{} fail_open:{}", - snapshot.compaction.enabled, - snapshot - .compaction - .min_messages - .map_or_else(|| "(none)".to_owned(), |value| value.to_string()), - snapshot - .compaction - .trigger_estimated_tokens - .map_or_else(|| "(none)".to_owned(), |value| value.to_string()), - snapshot.compaction.fail_open - ); - println!("available:"); - for metadata in snapshot.available { - println!( - "- {} api_version={} capabilities={}", - metadata.id, - metadata.api_version, - format_capability_names(&metadata.capability_names()) - ); - } - Ok(()) -} - -pub fn run_list_memory_systems_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let snapshot = mvp::memory::collect_memory_system_runtime_snapshot(&config)?; - - if as_json { - let payload = - build_memory_systems_cli_json_payload(&resolved_path.display().to_string(), &snapshot); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize memory-system output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!( - "{}", - render_memory_system_snapshot_text(&resolved_path.display().to_string(), &snapshot) - ); - Ok(()) -} - -pub fn run_list_acp_backends_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let snapshot = mvp::acp::collect_acp_runtime_snapshot(&config)?; - - if as_json { - let payload = json!({ - "config": resolved_path.display().to_string(), - "enabled": snapshot.control_plane.enabled, - "selected": acp_backend_metadata_json( - &snapshot.selected_metadata, - Some(snapshot.selected.source.as_str()) - ), - "available": snapshot - .available - .iter() - .map(|metadata| acp_backend_metadata_json(metadata, None)) - .collect::>(), - "control_plane": acp_control_plane_json(&snapshot.control_plane), - }); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize ACP backend output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!("config={}", resolved_path.display()); - println!( - "enabled={} selected={} source={} api_version={} capabilities={}", - snapshot.control_plane.enabled, - snapshot.selected_metadata.id, - snapshot.selected.source.as_str(), - snapshot.selected_metadata.api_version, - format_capability_names(&snapshot.selected_metadata.capability_names()) - ); - println!( - "control_plane=dispatch_enabled:{} conversation_routing:{} allowed_channels:{} allowed_account_ids:{} bootstrap_mcp_servers:{} working_directory:{} thread_routing:{} default_agent:{} allowed_agents:{} max_concurrent_sessions:{} session_idle_ttl_ms:{} startup_timeout_ms:{} turn_timeout_ms:{} queue_owner_ttl_ms:{} bindings_enabled:{} emit_runtime_events:{} allow_mcp_server_injection:{}", - snapshot.control_plane.dispatch_enabled, - snapshot.control_plane.conversation_routing.as_str(), - snapshot.control_plane.allowed_channels.join(","), - snapshot.control_plane.allowed_account_ids.join(","), - snapshot.control_plane.bootstrap_mcp_servers.join(","), - snapshot - .control_plane - .working_directory - .as_deref() - .unwrap_or(""), - snapshot.control_plane.thread_routing.as_str(), - snapshot.control_plane.default_agent, - snapshot.control_plane.allowed_agents.join(","), - snapshot.control_plane.max_concurrent_sessions, - snapshot.control_plane.session_idle_ttl_ms, - snapshot.control_plane.startup_timeout_ms, - snapshot.control_plane.turn_timeout_ms, - snapshot.control_plane.queue_owner_ttl_ms, - snapshot.control_plane.bindings_enabled, - snapshot.control_plane.emit_runtime_events, - snapshot.control_plane.allow_mcp_server_injection - ); - println!("available:"); - for metadata in snapshot.available { - println!( - "- {} api_version={} capabilities={} summary={}", - metadata.id, - metadata.api_version, - format_capability_names(&metadata.capability_names()), - metadata.summary - ); - } - Ok(()) -} - -pub fn run_list_acp_sessions_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { - #[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] - { - let _ = (config_path, as_json); - Err("ACP session persistence requires feature `memory-sqlite`".to_owned()) - } - - #[cfg(any(feature = "memory-sqlite", feature = "mvp"))] - { - let (resolved_path, config) = mvp::config::load(config_path)?; - let store = - mvp::acp::AcpSqliteSessionStore::new(Some(config.memory.resolved_sqlite_path())); - let sessions = mvp::acp::AcpSessionStore::list(&store)?; - - if as_json { - let payload = json!({ - "config": resolved_path.display().to_string(), - "sqlite_path": config.memory.resolved_sqlite_path().display().to_string(), - "sessions": sessions - .iter() - .map(acp_session_metadata_json) - .collect::>(), - }); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize ACP session output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!( - "config={} sqlite_path={}", - resolved_path.display(), - config.memory.resolved_sqlite_path().display() - ); - if sessions.is_empty() { - println!("sessions: (none)"); - return Ok(()); - } - println!("sessions:"); - for session in sessions { - println!( - "- session_key={} backend={} conversation_id={} binding_route_session_id={} activation_origin={} state={} mode={} runtime_session_name={} last_activity_ms={} last_error={}", - session.session_key, - session.backend_id, - session.conversation_id.as_deref().unwrap_or("(none)"), - session - .binding - .as_ref() - .map(|binding| binding.route_session_id.as_str()) - .unwrap_or("(none)"), - session - .activation_origin - .map(mvp::acp::AcpRoutingOrigin::as_str) - .unwrap_or("(none)"), - acp_session_state_label(session.state), - session.mode.map(acp_session_mode_label).unwrap_or("(none)"), - session.runtime_session_name, - session.last_activity_ms, - session.last_error.as_deref().unwrap_or("(none)") - ); - } - Ok(()) - } -} - -pub async fn run_acp_doctor_cli( - config_path: Option<&str>, - backend_id: Option<&str>, - as_json: bool, -) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let selection = mvp::acp::resolve_acp_backend_selection(&config); - let backend = backend_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(selection.id.as_str()); - let report = mvp::acp::AcpSessionManager::default() - .doctor(&config, Some(backend)) - .await?; - - if as_json { - let payload = acp_doctor_json( - resolved_path.display().to_string(), - selection.id.as_str(), - backend, - &report, - ); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize ACP doctor output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!("config={}", resolved_path.display()); - println!( - "selected_backend={} requested_backend={} healthy={}", - backend, backend, report.healthy - ); - if report.diagnostics.is_empty() { - println!("diagnostics: (none)"); - return Ok(()); - } - println!("diagnostics:"); - for (key, value) in report.diagnostics { - println!("- {}={}", key, value); - } - Ok(()) -} - -pub fn acp_doctor_json( - config_path: impl Into, - _default_backend: &str, - effective_backend: &str, - report: &mvp::acp::AcpDoctorReport, -) -> Value { - json!({ - "config": config_path.into(), - "selected_backend": effective_backend, - "requested_backend": effective_backend, - "healthy": report.healthy, - "diagnostics": report.diagnostics, - }) -} - -pub async fn run_acp_status_cli( - config_path: Option<&str>, - session_key: Option<&str>, - conversation_id: Option<&str>, - route_session_id: Option<&str>, - as_json: bool, -) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let resolved_session_key = - resolve_acp_status_session_key(&config, session_key, conversation_id, route_session_id)?; - let manager = mvp::acp::shared_acp_session_manager(&config)?; - let status = manager - .get_status(&config, resolved_session_key.as_str()) - .await?; - - if as_json { - let config_display = resolved_path.display().to_string(); - let payload = gateway::read_models::build_acp_status_read_model( - config_display.as_str(), - session_key, - conversation_id, - route_session_id, - resolved_session_key.as_str(), - &status, - ); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize ACP status output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!("config={}", resolved_path.display()); - if let Some(conversation_id) = conversation_id { - println!("requested_conversation_id={conversation_id}"); - } - if let Some(route_session_id) = route_session_id { - println!("requested_route_session_id={route_session_id}"); - } - if let Some(session_key) = session_key { - println!("requested_session={session_key}"); - } - println!("resolved_session_key={}", resolved_session_key); - println!( - "status=backend:{} state:{} mode:{} pending_turns:{} active_turn_id:{} conversation_id:{} binding_route_session_id:{} activation_origin:{} last_activity_ms:{} last_error:{}", - status.backend_id, - acp_session_state_label(status.state), - status.mode.map(acp_session_mode_label).unwrap_or("(none)"), - status.pending_turns, - status.active_turn_id.as_deref().unwrap_or("(none)"), - status.conversation_id.as_deref().unwrap_or("(none)"), - status - .binding - .as_ref() - .map(|binding| binding.route_session_id.as_str()) - .unwrap_or("(none)"), - status - .activation_origin - .map(mvp::acp::AcpRoutingOrigin::as_str) - .unwrap_or("(none)"), - status.last_activity_ms, - status.last_error.as_deref().unwrap_or("(none)") - ); - Ok(()) -} - -pub async fn run_acp_observability_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let manager = mvp::acp::shared_acp_session_manager(&config)?; - let snapshot = manager.observability_snapshot(&config).await?; - - if as_json { - let config_display = resolved_path.display().to_string(); - let payload = gateway::read_models::build_acp_observability_read_model( - config_display.as_str(), - &snapshot, - ); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize ACP observability output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!("config={}", resolved_path.display()); - println!( - "runtime_cache=active_sessions:{} idle_ttl_ms:{} evicted_total:{} last_evicted_at_ms:{}", - snapshot.runtime_cache.active_sessions, - snapshot.runtime_cache.idle_ttl_ms, - snapshot.runtime_cache.evicted_total, - snapshot - .runtime_cache - .last_evicted_at_ms - .map(|value| value.to_string()) - .unwrap_or_else(|| "(none)".to_owned()) - ); - println!( - "sessions=bound:{} unbound:{} activation_origins:{} backends:{}", - snapshot.sessions.bound, - snapshot.sessions.unbound, - format_usize_rollup(&snapshot.sessions.activation_origin_counts), - format_usize_rollup(&snapshot.sessions.backend_counts) - ); - println!( - "actors=active:{} queue_depth:{} waiting:{}", - snapshot.actors.active, snapshot.actors.queue_depth, snapshot.actors.waiting - ); - println!( - "turns=active:{} queue_depth:{} completed:{} failed:{} average_latency_ms:{} max_latency_ms:{}", - snapshot.turns.active, - snapshot.turns.queue_depth, - snapshot.turns.completed, - snapshot.turns.failed, - snapshot.turns.average_latency_ms, - snapshot.turns.max_latency_ms - ); - if snapshot.errors_by_code.is_empty() { - println!("errors_by_code: (none)"); - } else { - println!("errors_by_code:"); - for (key, value) in snapshot.errors_by_code { - println!("- {}={}", key, value); - } - } - Ok(()) -} - -pub fn resolve_acp_status_session_key( - config: &mvp::config::LoongClawConfig, - session_key: Option<&str>, - conversation_id: Option<&str>, - route_session_id: Option<&str>, -) -> CliResult { - let session_key = session_key - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - let conversation_id = conversation_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - let route_session_id = route_session_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - - match (session_key, conversation_id, route_session_id) { - (Some(session_key), None, None) => Ok(session_key), - (None, Some(conversation_id), None) => { - #[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] - { - let _ = (config, conversation_id); - Err("ACP conversation-id lookup requires feature `memory-sqlite`".to_owned()) - } - - #[cfg(any(feature = "memory-sqlite", feature = "mvp"))] - { - let store = mvp::acp::AcpSqliteSessionStore::new(Some( - config.memory.resolved_sqlite_path(), - )); - let metadata = mvp::acp::AcpSessionStore::get_by_conversation_id( - &store, - conversation_id.as_str(), - )? - .ok_or_else(|| { - format!( - "ACP conversation `{}` is not registered in {}", - conversation_id, - config.memory.resolved_sqlite_path().display() - ) - })?; - Ok(metadata.session_key) - } - } - (None, None, Some(route_session_id)) => { - #[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] - { - let _ = (config, route_session_id); - Err("ACP route-session-id lookup requires feature `memory-sqlite`".to_owned()) - } - - #[cfg(any(feature = "memory-sqlite", feature = "mvp"))] - { - let store = mvp::acp::AcpSqliteSessionStore::new(Some( - config.memory.resolved_sqlite_path(), - )); - let metadata = mvp::acp::AcpSessionStore::get_by_binding_route_session_id( - &store, - route_session_id.as_str(), - )? - .ok_or_else(|| { - format!( - "ACP route session `{}` is not registered in {}", - route_session_id, - config.memory.resolved_sqlite_path().display() - ) - })?; - Ok(metadata.session_key) - } - } - (Some(_), Some(_), _) - | (Some(_), _, Some(_)) - | (_, Some(_), Some(_)) => Err( - "acp-status accepts exactly one of --session, --conversation-id, or --route-session-id" - .to_owned(), - ), - (None, None, None) => Err( - "acp-status requires --session , --conversation-id , or --route-session-id " - .to_owned(), - ), - } -} - -pub async fn run_chat_cli( - config_path: Option<&str>, - session: Option<&str>, - acp: bool, - acp_event_stream: bool, - acp_bootstrap_mcp_server: &[String], - acp_cwd: Option<&str>, -) -> CliResult<()> { - let options = build_cli_chat_options(acp, acp_event_stream, acp_bootstrap_mcp_server, acp_cwd); - mvp::chat::run_cli_chat(config_path, session, &options).await -} - -pub async fn run_ask_cli( - config_path: Option<&str>, - session: Option<&str>, - message: &str, - acp: bool, - acp_event_stream: bool, - acp_bootstrap_mcp_server: &[String], - acp_cwd: Option<&str>, -) -> CliResult<()> { - let options = build_cli_chat_options(acp, acp_event_stream, acp_bootstrap_mcp_server, acp_cwd); - mvp::chat::run_cli_ask(config_path, session, message, &options).await -} - -pub fn build_cli_chat_options( - acp: bool, - acp_event_stream: bool, - acp_bootstrap_mcp_server: &[String], - acp_cwd: Option<&str>, -) -> mvp::chat::CliChatOptions { - mvp::chat::CliChatOptions { - acp_requested: acp, - acp_event_stream, - acp_bootstrap_mcp_servers: acp_bootstrap_mcp_server.to_vec(), - acp_working_directory: acp_cwd - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(std::path::PathBuf::from), - } -} - -pub fn run_acp_event_summary_cli( - config_path: Option<&str>, - session: Option<&str>, - limit: usize, - as_json: bool, -) -> CliResult<()> { - if limit == 0 { - return Err("acp-event-summary limit must be >= 1".to_owned()); - } - - let (_, config) = mvp::config::load(config_path)?; - let session_id = session - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("default") - .to_owned(); - - #[cfg(feature = "memory-sqlite")] - { - let mem_config = - mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); - let turns = mvp::memory::window_direct(&session_id, limit, &mem_config) - .map_err(|error| format!("load ACP event summary failed: {error}"))?; - let summary = mvp::acp::summarize_turn_events( - turns - .iter() - .filter_map(|turn| (turn.role == "assistant").then_some(turn.content.as_str())), - ); - if as_json { - let payload = acp_event_summary_json(&session_id, limit, &summary); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize ACP event summary failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - print!("{}", format_acp_event_summary(&session_id, limit, &summary)); - Ok(()) - } - - #[cfg(not(feature = "memory-sqlite"))] - { - let _ = (config, session_id, as_json); - Err("acp-event-summary requires memory-sqlite feature".to_owned()) - } -} - -pub fn run_acp_dispatch_cli( - config_path: Option<&str>, - session: Option<&str>, - channel: Option<&str>, - conversation_id: Option<&str>, - account_id: Option<&str>, - thread_id: Option<&str>, - as_json: bool, -) -> CliResult<()> { - let (resolved_path, config) = mvp::config::load(config_path)?; - let session_id = session - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("default") - .to_owned(); - let address = build_acp_dispatch_address( - session_id.as_str(), - channel, - conversation_id, - account_id, - thread_id, - )?; - let decision = mvp::acp::evaluate_acp_conversation_dispatch_for_address(&config, &address)?; - - if as_json { - let config_display = resolved_path.display().to_string(); - let payload = gateway::read_models::build_acp_dispatch_read_model( - config_display.as_str(), - &address, - session_id.as_str(), - &decision, - ); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize ACP dispatch output failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - println!("config={}", resolved_path.display()); - println!( - "address=session:{} channel:{} account_id:{} conversation_id:{} thread_id:{}", - address.session_id, - address.channel_id.as_deref().unwrap_or("(none)"), - address.account_id.as_deref().unwrap_or("(none)"), - address.conversation_id.as_deref().unwrap_or("(none)"), - address.thread_id.as_deref().unwrap_or("(none)") - ); - println!( - "dispatch=route_via_acp:{} reason:{} automatic_routing_origin:{} route_session_id:{} prefixed_agent_id:{} channel_id:{} account_id:{} conversation_id:{} thread_id:{}", - decision.route_via_acp, - decision.reason.as_str(), - decision - .automatic_routing_origin - .map(mvp::acp::AcpRoutingOrigin::as_str) - .unwrap_or("(none)"), - decision.target.route_session_id, - decision - .target - .prefixed_agent_id - .as_deref() - .unwrap_or("(none)"), - decision.target.channel_id.as_deref().unwrap_or("(none)"), - decision.target.account_id.as_deref().unwrap_or("(none)"), - decision - .target - .conversation_id - .as_deref() - .unwrap_or("(none)"), - decision.target.thread_id.as_deref().unwrap_or("(none)") - ); - println!( - "channel_path={}", - if decision.target.channel_path.is_empty() { - "(none)".to_owned() - } else { - decision.target.channel_path.join(":") - } - ); - Ok(()) -} - -pub fn build_acp_dispatch_address( - session_id: &str, - channel: Option<&str>, - conversation_id: Option<&str>, - account_id: Option<&str>, - thread_id: Option<&str>, -) -> CliResult { - let session_id = session_id.trim(); - if session_id.is_empty() { - return Err("acp-dispatch requires a non-empty --session value".to_owned()); - } - - let channel = channel.map(str::trim).filter(|value| !value.is_empty()); - let conversation_id = conversation_id - .map(str::trim) - .filter(|value| !value.is_empty()); - let account_id = account_id.map(str::trim).filter(|value| !value.is_empty()); - let thread_id = thread_id.map(str::trim).filter(|value| !value.is_empty()); - - let channel = match channel { - Some(channel) => channel, - None => { - if conversation_id.is_some() || account_id.is_some() || thread_id.is_some() { - return Err( - "acp-dispatch requires --channel when using --conversation-id, --account-id, or --thread-id" - .to_owned(), - ); - } - return Ok(mvp::conversation::ConversationSessionAddress::from_session_id(session_id)); - } - }; - - let conversation_id = conversation_id.ok_or_else(|| { - "acp-dispatch requires --conversation-id when --channel is provided".to_owned() - })?; - let mut address = mvp::conversation::ConversationSessionAddress::from_session_id(session_id) - .with_channel_scope(channel, conversation_id); - if let Some(account_id) = account_id { - address = address.with_account_id(account_id); - } - if let Some(thread_id) = thread_id { - address = address.with_thread_id(thread_id); - } - Ok(address) -} - -pub fn run_safe_lane_summary_cli( - config_path: Option<&str>, - session: Option<&str>, - limit: usize, - as_json: bool, -) -> CliResult<()> { - if limit == 0 { - return Err("safe-lane-summary limit must be >= 1".to_owned()); - } - - let (_, config) = mvp::config::load(config_path)?; - let session_id = session - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("default") - .to_owned(); - - #[cfg(feature = "memory-sqlite")] - { - let mem_config = - mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); - let turns = mvp::memory::window_direct(&session_id, limit, &mem_config) - .map_err(|error| format!("load safe-lane summary failed: {error}"))?; - let summary = mvp::conversation::summarize_safe_lane_events( - turns - .iter() - .filter_map(|turn| (turn.role == "assistant").then_some(turn.content.as_str())), - ); - if as_json { - let payload = json!({ - "session": session_id, - "limit": limit, - "summary": summary, - }); - let pretty = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("serialize safe-lane summary failed: {error}"))?; - println!("{pretty}"); - return Ok(()); - } - - let final_status = match summary.final_status { - Some(mvp::conversation::SafeLaneFinalStatus::Succeeded) => "succeeded", - Some(mvp::conversation::SafeLaneFinalStatus::Failed) => "failed", - None => "unknown", - }; - println!("safe_lane_summary session={} limit={}", session_id, limit); - println!( - "events lane_selected={} round_started={} round_completed_succeeded={} round_completed_failed={} verify_failed={} verify_policy_adjusted={} replan_triggered={} final_status={} governor_engaged={} governor_force_no_replan={}", - summary.lane_selected_events, - summary.round_started_events, - summary.round_completed_succeeded_events, - summary.round_completed_failed_events, - summary.verify_failed_events, - summary.verify_policy_adjusted_events, - summary.replan_triggered_events, - summary.final_status_events, - summary.session_governor_engaged_events, - summary.session_governor_force_no_replan_events - ); - println!( - "terminal status={} failure_code={} route_decision={} route_reason={}", - final_status, - summary.final_failure_code.as_deref().unwrap_or("-"), - summary.final_route_decision.as_deref().unwrap_or("-"), - summary.final_route_reason.as_deref().unwrap_or("-") - ); - let route_reasons_rollup = if summary.route_reason_counts.is_empty() { - "-".to_owned() - } else { - summary - .route_reason_counts - .iter() - .map(|(key, value)| format!("{key}:{value}")) - .collect::>() - .join(",") - }; - println!( - "governor trigger_failed_threshold={} trigger_backpressure_threshold={} trigger_trend_threshold={} trigger_recovery_threshold={}", - summary.session_governor_failed_threshold_triggered_events, - summary.session_governor_backpressure_threshold_triggered_events, - summary.session_governor_trend_threshold_triggered_events, - summary.session_governor_recovery_threshold_triggered_events - ); - println!( - "governor_latest snapshots={} trend_samples={} trend_min_samples={} trend_failure_ewma={} trend_backpressure_ewma={} recovery_success_streak={} recovery_streak_threshold={}", - summary.session_governor_metrics_snapshots_seen, - summary - .session_governor_latest_trend_samples - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_owned()), - summary - .session_governor_latest_trend_min_samples - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_owned()), - format_milli_ratio(summary.session_governor_latest_trend_failure_ewma_milli), - format_milli_ratio(summary.session_governor_latest_trend_backpressure_ewma_milli), - summary - .session_governor_latest_recovery_success_streak - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_owned()), - summary - .session_governor_latest_recovery_success_streak_threshold - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_owned()) - ); - println!("rollup route_reasons={route_reasons_rollup}"); - Ok(()) - } - - #[cfg(not(feature = "memory-sqlite"))] - { - let _ = (config, session_id, as_json); - Err("safe-lane-summary requires memory-sqlite feature".to_owned()) - } -} - -#[cfg(feature = "memory-sqlite")] -pub fn format_milli_ratio(value: Option) -> String { - value - .map(|raw| format!("{:.3}", (raw as f64) / 1000.0)) - .unwrap_or_else(|| "-".to_owned()) -} - -pub async fn with_graceful_shutdown(serve_future: F) -> CliResult<()> -where - F: std::future::Future>, -{ - tokio::select! { - result = serve_future => result, - result = wait_for_shutdown_reason() => result.map(|_| ()), - } -} - -#[cfg(unix)] -pub async fn wait_for_shutdown_reason() -> CliResult { - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .map_err(|error| format!("failed to register SIGTERM handler: {error}"))?; - - tokio::select! { - result = tokio::signal::ctrl_c() => { - result.map_err(|error| format!("failed to register Ctrl-C handler: {error}"))?; - eprintln!("\nReceived Ctrl-C, shutting down gracefully..."); - Ok("ctrl-c received".to_owned()) - } - _ = sigterm.recv() => { - eprintln!("\nReceived SIGTERM, shutting down gracefully..."); - Ok("sigterm received".to_owned()) - } - } -} - -#[cfg(not(unix))] -pub async fn wait_for_shutdown_reason() -> CliResult { - tokio::signal::ctrl_c() - .await - .map_err(|error| format!("failed to register Ctrl-C handler: {error}"))?; - eprintln!("\nReceived Ctrl-C, shutting down gracefully..."); - Ok("ctrl-c received".to_owned()) -} - -pub async fn wait_for_shutdown_signal() -> CliResult<()> { - wait_for_shutdown_reason().await.map(|_| ()) -} - -pub const TELEGRAM_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::TELEGRAM_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_telegram_send_cli_impl, -}; - -pub const FEISHU_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::FEISHU_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_feishu_send_cli_impl, -}; - -pub const MATRIX_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::MATRIX_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_matrix_send_cli_impl, -}; - -pub const WECOM_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::WECOM_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_wecom_send_cli_impl, -}; - -pub const DISCORD_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::DISCORD_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_discord_send_cli_impl, -}; - -pub const DINGTALK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::DINGTALK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_dingtalk_send_cli_impl, -}; - -pub const SLACK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::SLACK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_slack_send_cli_impl, -}; - -pub const LINE_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::LINE_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_line_send_cli_impl, -}; - -pub const WHATSAPP_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::WHATSAPP_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_whatsapp_send_cli_impl, -}; - -pub const EMAIL_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::EMAIL_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_email_send_cli_impl, -}; - -pub const WEBHOOK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::WEBHOOK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_webhook_send_cli_impl, -}; - -pub const GOOGLE_CHAT_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::GOOGLE_CHAT_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_google_chat_send_cli_impl, -}; - -pub const TEAMS_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::TEAMS_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_teams_send_cli_impl, -}; - -pub const SIGNAL_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::SIGNAL_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_signal_send_cli_impl, -}; - -pub const TWITCH_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::TWITCH_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_twitch_send_cli_impl, -}; - -pub const MATTERMOST_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::MATTERMOST_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_mattermost_send_cli_impl, -}; - -pub const NEXTCLOUD_TALK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::NEXTCLOUD_TALK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_nextcloud_talk_send_cli_impl, -}; - -pub const SYNOLOGY_CHAT_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::SYNOLOGY_CHAT_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_synology_chat_send_cli_impl, -}; - -pub const IRC_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::IRC_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_irc_send_cli_impl, -}; - -pub const IMESSAGE_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::IMESSAGE_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_imessage_send_cli_impl, -}; - -pub const NOSTR_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { - family: mvp::channel::NOSTR_CATALOG_COMMAND_FAMILY_DESCRIPTOR, - run: run_nostr_send_cli_impl, -}; - -pub const TELEGRAM_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { - family: mvp::channel::TELEGRAM_COMMAND_FAMILY_DESCRIPTOR, - run: run_telegram_serve_cli_impl, -}; - -pub const FEISHU_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { - family: mvp::channel::FEISHU_COMMAND_FAMILY_DESCRIPTOR, - run: run_feishu_serve_cli_impl, -}; - -pub const MATRIX_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { - family: mvp::channel::MATRIX_COMMAND_FAMILY_DESCRIPTOR, - run: run_matrix_serve_cli_impl, -}; - -pub const WECOM_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { - family: mvp::channel::WECOM_COMMAND_FAMILY_DESCRIPTOR, - run: run_wecom_serve_cli_impl, -}; - -pub const WHATSAPP_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { - family: mvp::channel::WHATSAPP_COMMAND_FAMILY_DESCRIPTOR, - run: run_whatsapp_serve_cli_impl, -}; - -pub async fn run_channel_send_cli( - spec: ChannelSendCliSpec, - args: ChannelSendCliArgs<'_>, -) -> CliResult<()> { - let _ = spec.family; - (spec.run)(args).await -} - -pub async fn run_channel_serve_cli( - spec: ChannelServeCliSpec, - args: ChannelServeCliArgs<'_>, -) -> CliResult<()> { - let _ = spec.family; - (spec.run)(args).await -} - -fn require_channel_send_target<'a>(command: &str, target: Option<&'a str>) -> CliResult<&'a str> { - let target = target.map(str::trim).filter(|value| !value.is_empty()); - let Some(target) = target else { - return Err(format!("{command} requires --target")); - }; - - Ok(target) -} - -pub fn run_telegram_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_telegram_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_feishu_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let target = args.target.unwrap_or_default(); - mvp::channel::run_feishu_send( - args.config_path, - args.account, - &mvp::channel::FeishuChannelSendRequest { - receive_id: target.to_owned(), - receive_id_type: Some(args.target_kind.as_str().to_owned()), - text: Some(args.text.to_owned()), - post_json: None, - image_key: None, - file_key: None, - image_path: None, - file_path: None, - file_type: None, - card: args.as_card, - uuid: None, - }, - ) - .await - }) -} - -pub fn run_matrix_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_matrix_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_wecom_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_wecom_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_discord_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_discord_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_dingtalk_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - mvp::channel::run_dingtalk_send( - args.config_path, - args.account, - args.target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_slack_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_slack_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_line_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_line_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_whatsapp_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_whatsapp_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_email_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = require_channel_send_target("email-send", args.target)?; - mvp::channel::run_email_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_webhook_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - mvp::channel::run_webhook_send( - args.config_path, - args.account, - args.target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_google_chat_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - mvp::channel::run_google_chat_send( - args.config_path, - args.account, - args.target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_teams_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - mvp::channel::run_teams_send( - args.config_path, - args.account, - args.target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_mattermost_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = require_channel_send_target("mattermost-send", args.target)?; - mvp::channel::run_mattermost_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_nextcloud_talk_send_cli_impl( - args: ChannelSendCliArgs<'_>, -) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = require_channel_send_target("nextcloud-talk-send", args.target)?; - mvp::channel::run_nextcloud_talk_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_synology_chat_send_cli_impl( - args: ChannelSendCliArgs<'_>, -) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - mvp::channel::run_synology_chat_send( - args.config_path, - args.account, - args.target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_irc_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = require_channel_send_target("irc-send", args.target)?; - mvp::channel::run_irc_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_imessage_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = require_channel_send_target("imessage-send", args.target)?; - mvp::channel::run_imessage_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_nostr_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - mvp::channel::run_nostr_send( - args.config_path, - args.account, - args.target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_signal_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = args.target.unwrap_or_default(); - mvp::channel::run_signal_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_twitch_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.as_card; - let target = require_channel_send_target("twitch-send", args.target)?; - mvp::channel::run_twitch_send( - args.config_path, - args.account, - target, - args.target_kind, - args.text, - ) - .await - }) -} - -pub fn run_telegram_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = (args.bind_override, args.path_override); - with_graceful_shutdown(mvp::channel::run_telegram_channel( - args.config_path, - args.once, - args.account, - )) - .await - }) -} - -pub fn default_channel_send_target_kind( - spec: ChannelSendCliSpec, -) -> mvp::channel::ChannelOutboundTargetKind { - spec.family.default_send_target_kind -} - -pub fn parse_channel_send_target_kind( - spec: ChannelSendCliSpec, - raw: &str, -) -> Result { - let target_kind = raw.parse::()?; - let channel_id = spec.family.channel_id; - let operation = spec.family.send; - if !operation.supports_target_kind(target_kind) { - let supported = operation - .supported_target_kinds - .iter() - .map(|kind| format!("`{}`", kind.as_str())) - .collect::>() - .join(" or "); - return Err(format!( - "{channel_id} --target-kind does not support `{}`; use {}", - target_kind.as_str(), - supported - )); - } - Ok(target_kind) -} - -pub fn default_telegram_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(TELEGRAM_SEND_CLI_SPEC) -} - -pub fn parse_telegram_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(TELEGRAM_SEND_CLI_SPEC, raw) -} - -pub fn default_matrix_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(MATRIX_SEND_CLI_SPEC) -} - -pub fn parse_matrix_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(MATRIX_SEND_CLI_SPEC, raw) -} - -pub fn default_wecom_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(WECOM_SEND_CLI_SPEC) -} - -pub fn parse_wecom_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(WECOM_SEND_CLI_SPEC, raw) -} - -pub fn default_feishu_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(FEISHU_SEND_CLI_SPEC) -} - -pub fn parse_feishu_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(FEISHU_SEND_CLI_SPEC, raw) -} - -pub fn default_discord_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(DISCORD_SEND_CLI_SPEC) -} - -pub fn parse_discord_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(DISCORD_SEND_CLI_SPEC, raw) -} - -pub fn default_dingtalk_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(DINGTALK_SEND_CLI_SPEC) -} - -pub fn parse_dingtalk_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(DINGTALK_SEND_CLI_SPEC, raw) -} - -pub fn default_slack_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(SLACK_SEND_CLI_SPEC) -} - -pub fn parse_slack_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(SLACK_SEND_CLI_SPEC, raw) -} - -pub fn default_line_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(LINE_SEND_CLI_SPEC) -} - -pub fn parse_line_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(LINE_SEND_CLI_SPEC, raw) -} - -pub fn default_whatsapp_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(WHATSAPP_SEND_CLI_SPEC) -} - -pub fn parse_whatsapp_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(WHATSAPP_SEND_CLI_SPEC, raw) -} - -pub fn default_email_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(EMAIL_SEND_CLI_SPEC) -} - -pub fn parse_email_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(EMAIL_SEND_CLI_SPEC, raw) -} - -pub fn default_webhook_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(WEBHOOK_SEND_CLI_SPEC) -} - -pub fn parse_webhook_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(WEBHOOK_SEND_CLI_SPEC, raw) -} - -pub fn default_google_chat_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(GOOGLE_CHAT_SEND_CLI_SPEC) -} - -pub fn parse_google_chat_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(GOOGLE_CHAT_SEND_CLI_SPEC, raw) -} - -pub fn default_teams_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(TEAMS_SEND_CLI_SPEC) -} - -pub fn parse_teams_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(TEAMS_SEND_CLI_SPEC, raw) -} - -pub fn default_signal_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(SIGNAL_SEND_CLI_SPEC) -} - -pub fn parse_signal_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(SIGNAL_SEND_CLI_SPEC, raw) -} - -pub fn default_mattermost_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(MATTERMOST_SEND_CLI_SPEC) -} - -pub fn parse_mattermost_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(MATTERMOST_SEND_CLI_SPEC, raw) -} - -pub fn default_nextcloud_talk_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(NEXTCLOUD_TALK_SEND_CLI_SPEC) -} - -pub fn parse_nextcloud_talk_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(NEXTCLOUD_TALK_SEND_CLI_SPEC, raw) -} - -pub fn default_synology_chat_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(SYNOLOGY_CHAT_SEND_CLI_SPEC) -} - -pub fn parse_synology_chat_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(SYNOLOGY_CHAT_SEND_CLI_SPEC, raw) -} - -pub fn default_irc_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(IRC_SEND_CLI_SPEC) -} - -pub fn parse_irc_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(IRC_SEND_CLI_SPEC, raw) -} - -pub fn default_imessage_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(IMESSAGE_SEND_CLI_SPEC) -} - -pub fn parse_imessage_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(IMESSAGE_SEND_CLI_SPEC, raw) -} - -pub fn default_nostr_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { - default_channel_send_target_kind(NOSTR_SEND_CLI_SPEC) -} - -pub fn parse_nostr_send_target_kind( - raw: &str, -) -> Result { - parse_channel_send_target_kind(NOSTR_SEND_CLI_SPEC, raw) -} - -pub fn run_feishu_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - with_graceful_shutdown(mvp::channel::run_feishu_channel( - args.config_path, - args.account, - args.bind_override, - args.path_override, - )) - .await - }) -} - -pub fn run_matrix_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = (args.bind_override, args.path_override); - with_graceful_shutdown(mvp::channel::run_matrix_channel( - args.config_path, - args.once, - args.account, - )) - .await - }) -} - -pub fn run_wecom_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - // WeCom AIBot uses a long connection only. `args.once`, - // `args.bind_override`, and `args.path_override` are intentionally - // discarded because single-run mode and HTTP bind/path overrides do not - // apply to this transport. - let _ = (args.once, args.bind_override, args.path_override); - with_graceful_shutdown(mvp::channel::run_wecom_channel( - args.config_path, - args.account, - )) - .await - }) -} - -pub fn run_whatsapp_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { - Box::pin(async move { - let _ = args.once; - with_graceful_shutdown(mvp::channel::run_whatsapp_channel( - args.config_path, - args.account, - args.bind_override, - args.path_override, - )) - .await - }) -} - -pub async fn run_multi_channel_serve_cli( - config_path: Option<&str>, - session: &str, - channel_accounts: Vec, -) -> CliResult<()> { - gateway::service::run_multi_channel_serve_gateway_compat_cli( - config_path, - session, - channel_accounts, - ) - .await -} - -pub(crate) fn render_string_list<'a>(values: impl IntoIterator) -> String { - let rendered = values - .into_iter() - .filter(|value| !value.is_empty()) - .collect::>(); - if rendered.is_empty() { - "-".to_owned() - } else { - rendered.join(",") - } -} - -fn json_string_field<'a>(value: &'a Value, key: &str) -> &'a str { - value.get(key).and_then(Value::as_str).unwrap_or("-") -} - -pub fn context_engine_metadata_json( - metadata: &mvp::conversation::ContextEngineMetadata, - source: Option<&str>, -) -> Value { - let mut payload = serde_json::Map::new(); - payload.insert("id".to_owned(), json!(metadata.id)); - payload.insert("api_version".to_owned(), json!(metadata.api_version)); - payload.insert( - "capabilities".to_owned(), - json!(metadata.capability_names()), - ); - if let Some(source) = source { - payload.insert("source".to_owned(), json!(source)); - } - Value::Object(payload) -} - -pub fn memory_system_metadata_json( - metadata: &mvp::memory::MemorySystemMetadata, - source: Option<&str>, -) -> Value { - let supported_stage_families = metadata - .supported_stage_families - .iter() - .copied() - .map(mvp::memory::MemoryStageFamily::as_str) - .collect::>(); - let supported_pre_assembly_stage_families = metadata - .supported_pre_assembly_stage_families - .iter() - .copied() - .map(mvp::memory::MemoryStageFamily::as_str) - .collect::>(); - let supported_recall_modes = metadata - .supported_recall_modes - .iter() - .copied() - .map(mvp::memory::MemoryRecallMode::as_str) - .collect::>(); - let mut payload = serde_json::Map::new(); - payload.insert("id".to_owned(), json!(metadata.id)); - payload.insert("api_version".to_owned(), json!(metadata.api_version)); - payload.insert( - "capabilities".to_owned(), - json!(metadata.capability_names()), - ); - payload.insert( - "runtime_fallback_kind".to_owned(), - json!(metadata.runtime_fallback_kind.as_str()), - ); - payload.insert( - "supported_stage_families".to_owned(), - json!(supported_stage_families), - ); - payload.insert( - "supported_pre_assembly_stage_families".to_owned(), - json!(supported_pre_assembly_stage_families), - ); - payload.insert( - "supported_recall_modes".to_owned(), - json!(supported_recall_modes), - ); - payload.insert("summary".to_owned(), json!(metadata.summary)); - if let Some(source) = source { - payload.insert("source".to_owned(), json!(source)); - } - Value::Object(payload) -} - -fn format_memory_stage_family_names(families: &[mvp::memory::MemoryStageFamily]) -> String { - let names = families - .iter() - .copied() - .map(mvp::memory::MemoryStageFamily::as_str) - .collect::>(); - render_string_list(names) -} - -fn format_memory_recall_mode_names(recall_modes: &[mvp::memory::MemoryRecallMode]) -> String { - let names = recall_modes - .iter() - .copied() - .map(mvp::memory::MemoryRecallMode::as_str) - .collect::>(); - render_string_list(names) -} - -fn format_memory_core_operation_names(operations: &[mvp::memory::MemoryCoreOperation]) -> String { - let names = operations - .iter() - .copied() - .map(mvp::memory::MemoryCoreOperation::as_str) - .collect::>(); - render_string_list(names) -} - -pub fn memory_system_policy_json(policy: &mvp::memory::MemorySystemPolicySnapshot) -> Value { - json!({ - "backend": policy.backend.as_str(), - "profile": policy.profile.as_str(), - "mode": policy.mode.as_str(), - "ingest_mode": policy.ingest_mode.as_str(), - "fail_open": policy.fail_open, - "strict_mode_requested": policy.strict_mode_requested, - "strict_mode_active": policy.strict_mode_active, - "effective_fail_open": policy.effective_fail_open, - }) -} - -pub fn build_memory_systems_cli_json_payload( - config_path: &str, - snapshot: &mvp::memory::MemorySystemRuntimeSnapshot, -) -> Value { - json!({ - "config": config_path, - "selected": memory_system_metadata_json( - &snapshot.selected_metadata, - Some(snapshot.selected.source.as_str()) - ), - "available": snapshot - .available - .iter() - .map(|metadata| memory_system_metadata_json(metadata, None)) - .collect::>(), - "core_operations": snapshot - .core_operations - .iter() - .copied() - .map(mvp::memory::MemoryCoreOperation::as_str) - .collect::>(), - "policy": memory_system_policy_json(&snapshot.policy), - }) -} - -pub fn render_memory_system_snapshot_text( - config_path: &str, - snapshot: &mvp::memory::MemorySystemRuntimeSnapshot, -) -> String { - let selected_capabilities = snapshot.selected_metadata.capability_names(); - let selected_stage_families = - format_memory_stage_family_names(&snapshot.selected_metadata.supported_stage_families); - let selected_pre_assembly_stages = format_memory_stage_family_names( - &snapshot - .selected_metadata - .supported_pre_assembly_stage_families, - ); - let selected_recall_modes = - format_memory_recall_mode_names(&snapshot.selected_metadata.supported_recall_modes); - let core_operations = format_memory_core_operation_names(&snapshot.core_operations); - let mut lines = vec![ - format!("config={config_path}"), - format!( - "selected={} source={} api_version={} capabilities={} runtime_fallback_kind={} stages={} pre_assembly_stages={} recall_modes={} core_operations={} summary={}", - snapshot.selected_metadata.id, - snapshot.selected.source.as_str(), - snapshot.selected_metadata.api_version, - format_capability_names(&selected_capabilities), - snapshot.selected_metadata.runtime_fallback_kind.as_str(), - selected_stage_families, - selected_pre_assembly_stages, - selected_recall_modes, - core_operations, - snapshot.selected_metadata.summary - ), - format!( - "policy=backend:{} profile:{} mode:{} ingest_mode:{} fail_open:{} strict_mode_requested:{} strict_mode_active:{} effective_fail_open:{}", - snapshot.policy.backend.as_str(), - snapshot.policy.profile.as_str(), - snapshot.policy.mode.as_str(), - snapshot.policy.ingest_mode.as_str(), - snapshot.policy.fail_open, - snapshot.policy.strict_mode_requested, - snapshot.policy.strict_mode_active, - snapshot.policy.effective_fail_open, - ), - "available:".to_owned(), - ]; - - for metadata in &snapshot.available { - let capabilities = metadata.capability_names(); - let stage_families = format_memory_stage_family_names(&metadata.supported_stage_families); - let pre_assembly_stages = - format_memory_stage_family_names(&metadata.supported_pre_assembly_stage_families); - let recall_modes = format_memory_recall_mode_names(&metadata.supported_recall_modes); - lines.push(format!( - "- {} api_version={} capabilities={} runtime_fallback_kind={} stages={} pre_assembly_stages={} recall_modes={} summary={}", - metadata.id, - metadata.api_version, - format_capability_names(&capabilities), - metadata.runtime_fallback_kind.as_str(), - stage_families, - pre_assembly_stages, - recall_modes, - metadata.summary - )); - } - - lines.join("\n") -} - -pub fn acp_backend_metadata_json( - metadata: &mvp::acp::AcpBackendMetadata, - source: Option<&str>, -) -> Value { - let mut payload = serde_json::Map::new(); - payload.insert("id".to_owned(), json!(metadata.id)); - payload.insert("api_version".to_owned(), json!(metadata.api_version)); - payload.insert( - "capabilities".to_owned(), - json!(metadata.capability_names()), - ); - payload.insert("summary".to_owned(), json!(metadata.summary)); - if let Some(source) = source { - payload.insert("source".to_owned(), json!(source)); - } - Value::Object(payload) -} - -pub fn acp_control_plane_json(snapshot: &mvp::acp::AcpControlPlaneSnapshot) -> Value { - json!({ - "enabled": snapshot.enabled, - "dispatch_enabled": snapshot.dispatch_enabled, - "conversation_routing": snapshot.conversation_routing.as_str(), - "allowed_channels": snapshot.allowed_channels, - "allowed_account_ids": snapshot.allowed_account_ids, - "bootstrap_mcp_servers": snapshot.bootstrap_mcp_servers, - "working_directory": snapshot.working_directory, - "thread_routing": snapshot.thread_routing.as_str(), - "default_agent": snapshot.default_agent, - "allowed_agents": snapshot.allowed_agents, - "max_concurrent_sessions": snapshot.max_concurrent_sessions, - "session_idle_ttl_ms": snapshot.session_idle_ttl_ms, - "startup_timeout_ms": snapshot.startup_timeout_ms, - "turn_timeout_ms": snapshot.turn_timeout_ms, - "queue_owner_ttl_ms": snapshot.queue_owner_ttl_ms, - "bindings_enabled": snapshot.bindings_enabled, - "emit_runtime_events": snapshot.emit_runtime_events, - "allow_mcp_server_injection": snapshot.allow_mcp_server_injection, - }) -} - -pub fn acp_session_metadata_json(metadata: &mvp::acp::AcpSessionMetadata) -> Value { - json!({ - "session_key": metadata.session_key, - "conversation_id": metadata.conversation_id, - "binding": metadata.binding.as_ref().map(acp_binding_scope_json), - "activation_origin": metadata.activation_origin.map(mvp::acp::AcpRoutingOrigin::as_str), - "provenance": acp_session_activation_provenance_json(metadata.activation_origin), - "backend_id": metadata.backend_id, - "runtime_session_name": metadata.runtime_session_name, - "working_directory": metadata - .working_directory - .as_ref() - .map(|path| path.display().to_string()), - "backend_session_id": metadata.backend_session_id, - "agent_session_id": metadata.agent_session_id, - "mode": metadata.mode.map(acp_session_mode_label), - "state": acp_session_state_label(metadata.state), - "last_activity_ms": metadata.last_activity_ms, - "last_error": metadata.last_error, - }) -} - -pub fn acp_session_status_json(status: &mvp::acp::AcpSessionStatus) -> Value { - json!({ - "session_key": status.session_key, - "backend_id": status.backend_id, - "conversation_id": status.conversation_id, - "binding": status.binding.as_ref().map(acp_binding_scope_json), - "activation_origin": status.activation_origin.map(mvp::acp::AcpRoutingOrigin::as_str), - "provenance": acp_session_activation_provenance_json(status.activation_origin), - "state": acp_session_state_label(status.state), - "mode": status.mode.map(acp_session_mode_label), - "pending_turns": status.pending_turns, - "active_turn_id": status.active_turn_id, - "last_activity_ms": status.last_activity_ms, - "last_error": status.last_error, - }) -} - -pub fn acp_binding_scope_json(binding: &mvp::acp::AcpSessionBindingScope) -> Value { - json!({ - "route_session_id": binding.route_session_id, - "channel_id": binding.channel_id, - "account_id": binding.account_id, - "conversation_id": binding.conversation_id, - "thread_id": binding.thread_id, - }) -} - -pub fn acp_session_activation_provenance_json(origin: Option) -> Value { - json!({ - "surface": "session_activation", - "activation_origin": origin.map(mvp::acp::AcpRoutingOrigin::as_str), - }) -} - -pub fn acp_dispatch_prediction_provenance_json( - decision: &mvp::acp::AcpConversationDispatchDecision, -) -> Value { - json!({ - "surface": "dispatch_prediction", - "automatic_routing_origin": decision - .automatic_routing_origin - .map(mvp::acp::AcpRoutingOrigin::as_str), - }) -} - -pub fn acp_turn_provenance_json(summary: &mvp::acp::AcpTurnEventSummary) -> Value { - json!({ - "surface": "turn_execution", - "last_routing_intent": summary.last_routing_intent, - "last_routing_origin": summary.last_routing_origin, - "routing_intent_counts": summary.routing_intent_counts, - "routing_origin_counts": summary.routing_origin_counts, - }) -} - -pub fn acp_dispatch_decision_json( - session: &str, - decision: &mvp::acp::AcpConversationDispatchDecision, -) -> Value { - json!({ - "session": session, - "decision": { - "route_via_acp": decision.route_via_acp, - "reason": decision.reason.as_str(), - "automatic_routing_origin": decision - .automatic_routing_origin - .map(mvp::acp::AcpRoutingOrigin::as_str), - "provenance": acp_dispatch_prediction_provenance_json(decision), - "target": { - "original_session_id": decision.target.original_session_id, - "route_session_id": decision.target.route_session_id, - "prefixed_agent_id": decision.target.prefixed_agent_id, - "channel_id": decision.target.channel_id, - "account_id": decision.target.account_id, - "conversation_id": decision.target.conversation_id, - "thread_id": decision.target.thread_id, - "channel_path": decision.target.channel_path, - } - } - }) -} - -pub fn acp_manager_observability_json( - snapshot: &mvp::acp::AcpManagerObservabilitySnapshot, -) -> Value { - json!({ - "runtime_cache": { - "active_sessions": snapshot.runtime_cache.active_sessions, - "idle_ttl_ms": snapshot.runtime_cache.idle_ttl_ms, - "evicted_total": snapshot.runtime_cache.evicted_total, - "last_evicted_at_ms": snapshot.runtime_cache.last_evicted_at_ms, - }, - "sessions": { - "bound": snapshot.sessions.bound, - "unbound": snapshot.sessions.unbound, - "activation_origin_counts": snapshot.sessions.activation_origin_counts, - "provenance": { - "surface": "session_activation_aggregate", - "activation_origin_counts": snapshot.sessions.activation_origin_counts, - }, - "backend_counts": snapshot.sessions.backend_counts, - }, - "actors": { - "active": snapshot.actors.active, - "queue_depth": snapshot.actors.queue_depth, - "waiting": snapshot.actors.waiting, - }, - "turns": { - "active": snapshot.turns.active, - "queue_depth": snapshot.turns.queue_depth, - "completed": snapshot.turns.completed, - "failed": snapshot.turns.failed, - "average_latency_ms": snapshot.turns.average_latency_ms, - "max_latency_ms": snapshot.turns.max_latency_ms, - }, - "errors_by_code": snapshot.errors_by_code, - }) -} - -pub fn acp_event_summary_json( - session: &str, - limit: usize, - summary: &mvp::acp::AcpTurnEventSummary, -) -> Value { - json!({ - "session": session, - "limit": limit, - "provenance": acp_turn_provenance_json(summary), - "summary": summary, - }) -} - -pub fn format_acp_event_summary( - session: &str, - limit: usize, - summary: &mvp::acp::AcpTurnEventSummary, -) -> String { - format!( - concat!( - "acp_event_summary session={} limit={}\n", - "records turn_event_records={} final_records={}\n", - "events done={} error={} text={} usage_update={}\n", - "turns succeeded={} cancelled={} failed={}\n", - "latest backend_id={} agent_id={} routing_intent={} routing_origin={} session_key={} conversation_id={} binding_route_session_id={} channel_id={} account_id={} channel_conversation_id={} channel_thread_id={} trace_id={} source_message_id={} ack_cursor={} state={} stop_reason={} error={}\n", - "rollup event_types={} stop_reasons={} routing_intents={} routing_origins={}\n" - ), - session, - limit, - summary.turn_event_records, - summary.final_records, - summary.done_events, - summary.error_events, - summary.text_events, - summary.usage_update_events, - summary.turns_succeeded, - summary.turns_cancelled, - summary.turns_failed, - summary.last_backend_id.as_deref().unwrap_or("-"), - summary.last_agent_id.as_deref().unwrap_or("-"), - summary.last_routing_intent.as_deref().unwrap_or("-"), - summary.last_routing_origin.as_deref().unwrap_or("-"), - summary.last_session_key.as_deref().unwrap_or("-"), - summary.last_conversation_id.as_deref().unwrap_or("-"), - summary - .last_binding_route_session_id - .as_deref() - .unwrap_or("-"), - summary.last_channel_id.as_deref().unwrap_or("-"), - summary.last_account_id.as_deref().unwrap_or("-"), - summary - .last_channel_conversation_id - .as_deref() - .unwrap_or("-"), - summary.last_channel_thread_id.as_deref().unwrap_or("-"), - summary.last_trace_id.as_deref().unwrap_or("-"), - summary.last_source_message_id.as_deref().unwrap_or("-"), - summary.last_ack_cursor.as_deref().unwrap_or("-"), - summary.last_turn_state.as_deref().unwrap_or("-"), - summary.last_stop_reason.as_deref().unwrap_or("-"), - summary.last_error.as_deref().unwrap_or("-"), - format_u32_rollup(&summary.event_type_counts), - format_u32_rollup(&summary.stop_reason_counts), - format_u32_rollup(&summary.routing_intent_counts), - format_u32_rollup(&summary.routing_origin_counts) - ) -} - -pub fn acp_session_mode_label(mode: mvp::acp::AcpSessionMode) -> &'static str { - match mode { - mvp::acp::AcpSessionMode::Interactive => "interactive", - mvp::acp::AcpSessionMode::Background => "background", - mvp::acp::AcpSessionMode::Review => "review", - } -} - -pub fn acp_session_state_label(state: mvp::acp::AcpSessionState) -> &'static str { - match state { - mvp::acp::AcpSessionState::Initializing => "initializing", - mvp::acp::AcpSessionState::Ready => "ready", - mvp::acp::AcpSessionState::Busy => "busy", - mvp::acp::AcpSessionState::Cancelling => "cancelling", - mvp::acp::AcpSessionState::Error => "error", - mvp::acp::AcpSessionState::Closed => "closed", - } -} - -pub fn format_capability_names(names: &[&str]) -> String { - if names.is_empty() { - return "(none)".to_owned(); - } - names.join(",") -} - -pub fn format_u32_rollup(values: &BTreeMap) -> String { - if values.is_empty() { - return "-".to_owned(); - } - values - .iter() - .map(|(key, value)| format!("{key}:{value}")) - .collect::>() - .join(",") -} - -pub fn format_usize_rollup(values: &BTreeMap) -> String { - if values.is_empty() { - return "-".to_owned(); - } - values - .iter() - .map(|(key, value)| format!("{key}:{value}")) - .collect::>() - .join(",") -} - -pub fn read_spec_file(path: &str) -> CliResult { - read_spec_file_with_bridge_support_resolution(path, None).map(|resolved| resolved.spec) -} - -pub fn read_spec_file_with_bridge_support_selection( - path: &str, - bridge_support_selection_override: Option<&BridgeSupportSelectionInput>, -) -> CliResult { - read_spec_file_with_bridge_support_resolution(path, bridge_support_selection_override) - .map(|resolved| resolved.spec) -} - -pub fn read_spec_file_with_bridge_support_resolution( - path: &str, - bridge_support_selection_override: Option<&BridgeSupportSelectionInput>, -) -> CliResult { - let mut input = read_spec_file_input(path)?; - let spec_has_bridge_support_config = - input.spec.bridge_support.is_some() || input.bridge_support_selection.is_some(); - - if let Some(selection) = bridge_support_selection_override { - if spec_has_bridge_support_config { - return Err(format!( - "spec file {path} accepts either file-local bridge support configuration or CLI bridge support selection overrides, not both" - )); - } - let override_selection = resolve_process_relative_bridge_support_selection(selection)?; - input.bridge_support_selection = Some(override_selection); - } - - resolve_spec_file_input(path, input) -} - -fn resolve_process_relative_bridge_support_selection( - selection: &BridgeSupportSelectionInput, -) -> CliResult { - let path = selection - .path - .as_deref() - .map(resolve_process_relative_path) - .transpose()?; - let delta_artifact = selection - .delta_artifact - .as_deref() - .map(resolve_process_relative_path) - .transpose()?; - - Ok(BridgeSupportSelectionInput { - path, - bundled_profile: selection.bundled_profile.clone(), - delta_artifact, - expected_sha256: selection.expected_sha256.clone(), - expected_delta_sha256: selection.expected_delta_sha256.clone(), - }) -} - -fn read_spec_file_input(path: &str) -> CliResult { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read spec file {path}: {error}"))?; - serde_json::from_str(&raw).map_err(|error| format!("failed to parse spec file {path}: {error}")) -} - -fn resolve_spec_file_input( - path: &str, - mut input: RunnerSpecFileInput, -) -> CliResult { - if let Some(selection) = input.bridge_support_selection.take() { - if input.spec.bridge_support.is_some() { - return Err(format!( - "spec file {path} accepts either inline `bridge_support` or `bridge_support_selection`, not both" - )); - } - - let policy_path = selection - .path - .as_deref() - .map(|value| resolve_spec_relative_path(path, value)); - let delta_artifact_path = selection - .delta_artifact - .as_deref() - .map(|value| resolve_spec_relative_path(path, value)); - let resolved = resolve_bridge_support_selection( - policy_path.as_deref(), - selection.bundled_profile.as_deref(), - delta_artifact_path.as_deref(), - selection.expected_sha256.as_deref(), - selection.expected_delta_sha256.as_deref(), - ) - .map_err(|error| { - format!("failed to resolve bridge support selection in {path}: {error}") - })?; - let bridge_support_source = resolved - .as_ref() - .map(|selection| selection.policy.source.clone()); - let bridge_support_delta_source = resolved - .as_ref() - .and_then(|selection| selection.delta_source.clone()); - let bridge_support_delta_sha256 = resolved.as_ref().and_then(|selection| { - selection - .delta_artifact - .as_ref() - .map(|artifact| artifact.sha256.clone()) - }); - input.spec.bridge_support = resolved.map(|selection| selection.policy.profile); - return Ok(ResolvedRunnerSpecFile { - spec: input.spec, - bridge_support_source, - bridge_support_delta_source, - bridge_support_delta_sha256, - }); - } - - let bridge_support_source = input - .spec - .bridge_support - .as_ref() - .map(|_| format!("inline:{path}")); - - Ok(ResolvedRunnerSpecFile { - spec: input.spec, - bridge_support_source, - bridge_support_delta_source: None, - bridge_support_delta_sha256: None, - }) -} - -fn resolve_process_relative_path(value: &str) -> CliResult { - let candidate = Path::new(value); - if candidate.is_absolute() { - return Ok(value.to_owned()); - } - - let current_dir = std::env::current_dir() - .map_err(|error| format!("resolve current directory failed: {error}"))?; - let resolved = current_dir.join(candidate); - - Ok(resolved.display().to_string()) -} - -fn resolve_spec_relative_path(spec_path: &str, value: &str) -> String { - let candidate = Path::new(value); - if candidate.is_absolute() { - return value.to_owned(); - } - - Path::new(spec_path) - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(candidate) - .display() - .to_string() -} - -pub fn write_json_file(path: &str, value: &T) -> CliResult<()> { - let serialized = serde_json::to_string_pretty(value) - .map_err(|error| format!("serialize JSON value for output file failed: {error}"))?; - if let Some(parent) = Path::new(path).parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .map_err(|error| format!("create output directory failed: {error}"))?; - } - fs::write(path, serialized) - .map_err(|error| format!("write JSON output file failed: {error}"))?; - Ok(()) -} +#![allow( + clippy::print_stdout, + clippy::print_stderr, + clippy::expect_used, + private_interfaces +)] // CLI daemon binary +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + future::Future, + io::Write, + path::{Path, PathBuf}, + pin::Pin, + process, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; +use kernel::{ + BootstrapTaskStatus, Capability, ConnectorCommand, FixedClock, InMemoryAuditSink, + PluginActivationStatus, PluginScanner, PluginSetupReadinessContext, PluginTranslator, + TaskIntent, ToolCoreOutcome, ToolCoreRequest, evaluate_plugin_setup_requirements, +}; +use loongclaw_contracts::SecretRef; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +pub use loongclaw_app as mvp; +pub use loongclaw_spec::spec_execution::*; +pub use loongclaw_spec::spec_runtime::*; +pub use loongclaw_spec::{CliResult, DEFAULT_AGENT_ID, DEFAULT_PACK_ID, kernel_bootstrap}; + +pub use self::channel_send_target_kind::{ + default_twitch_send_target_kind, parse_twitch_send_target_kind, +}; +pub use self::cli_json::build_runtime_snapshot_cli_json_payload; +pub use self::delegate_child_cli::run_detached_delegate_child_cli; +pub use self::env_compat::make_env_compatible; +pub use self::mcp_cli::{ + build_mcp_server_detail_cli_json_payload, build_mcp_servers_cli_json_payload, + run_list_mcp_servers_cli, run_show_mcp_server_cli, +}; +pub use loongclaw_bench::{ + run_programmatic_pressure_baseline_lint_cli, run_programmatic_pressure_benchmark_cli, + run_wasm_cache_benchmark_cli, +}; +#[cfg(any(feature = "memory-sqlite", feature = "mvp"))] +pub use memory_context_benchmark::run_memory_context_benchmark_cli; +pub use runtime_trajectory_cli::{format_runtime_trajectory_summary, run_runtime_trajectory_cli}; +#[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] +pub fn run_memory_context_benchmark_cli( + output_path: &str, + temp_root: Option<&str>, + history_turns: usize, + sliding_window: usize, + summary_max_chars: usize, + words_per_turn: usize, + rebuild_iterations: usize, + hot_iterations: usize, + warmup_iterations: usize, + suite_repetitions: usize, + enforce_gate: bool, + min_steady_state_speedup_ratio: f64, +) -> CliResult<()> { + let _ = ( + output_path, + temp_root, + history_turns, + sliding_window, + summary_max_chars, + words_per_turn, + rebuild_iterations, + hot_iterations, + warmup_iterations, + suite_repetitions, + enforce_gate, + min_steady_state_speedup_ratio, + ); + Err("benchmark-memory-context requires the daemon `memory-sqlite` feature".to_owned()) +} + +pub use {base64, kernel, sha2}; + +pub mod audit_cli; +mod browser_companion_diagnostics; +pub mod browser_preview; +mod channel_bridge_render; +#[cfg(test)] +mod channel_send_cli_tests; +mod channel_send_target_kind; +mod cli_handoff; +mod cli_json; +mod command_kind; +pub mod completions_cli; +mod control_plane_server; +mod copilot_onboarding; +mod delegate_child_cli; +pub mod doctor_cli; +pub mod doctor_security_cli; +mod env_compat; +mod external_skills_policy_probe; +pub mod feishu_cli; +pub mod feishu_support; +pub mod gateway; +pub mod import_cli; +mod mcp_cli; +#[cfg(any(feature = "memory-sqlite", feature = "mvp"))] +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; +pub mod onboard_presentation; +mod onboard_types; +mod onboard_web_search; +mod onboarding_model_policy; +pub mod operator_prompt; +pub mod personalize_cli; +mod plugin_bridge_account_summary; +pub mod plugins_cli; +mod provider_credential_policy; +mod provider_model_probe_policy; +pub mod provider_presentation; +mod provider_route_diagnostics; +pub mod runtime_capability_cli; +pub mod runtime_experiment_cli; +pub mod runtime_restore_cli; +mod runtime_snapshot_render; +pub mod runtime_trajectory_cli; +pub mod session_cli; +pub mod sessions_cli; +pub mod skills_cli; +pub mod source_presentation; +pub mod status_cli; +pub mod supervisor; +mod task_execution; +pub mod tasks_cli; +mod tlon_cli; +#[path = "web/mod.rs"] +pub mod web_cli; +mod tool_calling_readiness; +pub mod trajectory_cli; +pub mod work_unit_cli; +use channel_bridge_render::{ + push_channel_surface_managed_plugin_bridge_discovery, + push_channel_surface_plugin_bridge_contract, +}; +pub(crate) use channel_bridge_render::{ + render_line_safe_optional_text_value, render_line_safe_text_value, render_line_safe_text_values, +}; +pub use gateway::read_models::{ChannelsCliJsonPayload, ChannelsCliJsonSchema}; +pub use loongclaw_spec::programmatic::{ + acquire_programmatic_circuit_slot, record_programmatic_circuit_outcome, +}; +pub use observability::{debug_variant_name, init_tracing, summarize_error}; +pub use runtime_snapshot_render::render_runtime_snapshot_text; +pub(crate) use runtime_snapshot_render::{ + runtime_snapshot_acp_json, runtime_snapshot_context_engine_json, + runtime_snapshot_external_skills_json, runtime_snapshot_memory_system_json, + runtime_snapshot_provider_json, runtime_snapshot_runtime_plugins_json, + runtime_snapshot_tool_runtime_json, +}; +pub use session_cli::{ + SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, SessionSearchArtifactDocument, + SessionSearchArtifactResult, SessionSearchArtifactSchema, collect_session_search_artifact, + format_session_search_inspect_text, format_session_search_text, load_session_search_artifact, + run_session_search_cli, run_session_search_inspect_cli, +}; +use task_execution::execute_daemon_task_with_supervisor; +pub use task_execution::{DaemonTaskExecution, run_demo, run_task_cli}; +pub use tlon_cli::TLON_SEND_CLI_SPEC; +use tlon_cli::{default_tlon_send_target_kind, parse_tlon_send_target_kind}; +#[rustfmt::skip] +use tool_calling_readiness::{RuntimeSnapshotToolCallingState, collect_runtime_snapshot_tool_calling_state}; +pub use trajectory_cli::{ + TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, TrajectoryExportArtifactDocument, + TrajectoryExportArtifactSchema, TrajectoryExportEvent, TrajectoryExportSessionSummary, + TrajectoryExportTurn, collect_trajectory_export_artifact, format_trajectory_export_text, + format_trajectory_inspect_text, load_trajectory_export_artifact, run_trajectory_export_cli, + run_trajectory_inspect_cli, +}; +#[allow( + clippy::expect_used, + clippy::panic, + clippy::unwrap_used, + clippy::missing_panics_doc +)] +#[doc(hidden)] +pub mod test_support; + +pub const PUBLIC_GITHUB_REPO: &str = "loongclaw-ai/loongclaw"; +pub const CLI_COMMAND_NAME: &str = mvp::config::CLI_COMMAND_NAME; +pub const LEGACY_CLI_COMMAND_NAME: &str = mvp::config::LEGACY_CLI_COMMAND_NAME; + +pub fn active_cli_command_name() -> &'static str { + mvp::config::active_cli_command_name() +} + +fn render_welcome_long_about(command_name: &str) -> String { + format!( + "Show the configured welcome banner and quick commands.\n\nquick commands:\n- {command_name} ask --config --message \"...\"\n- {command_name} chat --config \n- {command_name} personalize --config \n- {command_name} doctor --config \n- {command_name} --help\n\nReplace with your current config path, or set LOONGCLAW_CONFIG_PATH first." + ) +} + +fn render_import_long_about(command_name: &str) -> String { + format!( + "Power-user import flow for previewing or applying detected migration sources explicitly.\n\nUse this when you want exact CLI control over which source and domains are reused. If you want the guided path, use `{command_name} onboard` instead. When the same source kind resolves to multiple detected configs, rerun with `--source-path ` to choose one exact source." + ) +} + +fn render_migrate_long_about(command_name: &str) -> String { + format!( + "Power-user config import flow for discovering, previewing, or applying external workspace state explicitly.\n\nUse this when you want exact CLI control over import mode selection and output handling for compatibility sources and older workspace roots. If you want the guided path, use `{command_name} onboard` instead.\n\nMode quick reference:\n- discover, plan_many, recommend_primary, merge_profiles, map_external_skills: require `--input`\n- plan: requires `--input`; `--output` is optional preview target\n- apply: requires `--input` and `--output`\n- apply_selected: requires `--input` and `--output`; use `--source-id` to pin one discovered source, and `--apply-external-skills-plan` to bridge installable local external skills into the managed runtime\n- rollback_last_apply: requires `--output`" + ) +} + +fn render_ask_long_about(command_name: &str) -> String { + format!( + "Run one non-interactive one-shot assistant turn.\n\nUse this when you want a fast answer without entering the interactive `{command_name} chat` REPL. The command reuses the normal CLI conversation runtime, session memory, provider selection, and ACP options." + ) +} + +pub fn build_cli_command(command_name: &'static str) -> clap::Command { + Cli::command() + .name(command_name) + .bin_name(command_name) + .mut_subcommand("welcome", |command| { + command.long_about(render_welcome_long_about(command_name)) + }) + .mut_subcommand("import", |command| { + command.long_about(render_import_long_about(command_name)) + }) + .mut_subcommand("migrate", |command| { + command + .about("Preview or apply config import modes explicitly") + .long_about(render_migrate_long_about(command_name)) + }) + .mut_subcommand("ask", |command| { + command.long_about(render_ask_long_about(command_name)) + }) +} + +pub fn parse_cli() -> Cli { + let mut matches = build_cli_command(active_cli_command_name()).get_matches(); + Cli::from_arg_matches_mut(&mut matches).unwrap_or_else(|error| error.exit()) +} + +pub use control_plane_server::{build_control_plane_router, run_control_plane_serve_cli}; + +pub fn native_spec_tool_executor( + request: ToolCoreRequest, +) -> Option> { + if mvp::tools::canonical_tool_name(request.tool_name.as_str()) != "config.import" { + return None; + } + Some(mvp::tools::execute_tool_core(request)) +} + +pub type ChannelCliCommandFuture<'a> = Pin> + Send + 'a>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum BridgeSupportProfileArg { + NativeBalanced, + OpenclawEcosystemBalanced, +} + +impl BridgeSupportProfileArg { + fn as_str(self) -> &'static str { + match self { + Self::NativeBalanced => "native-balanced", + Self::OpenclawEcosystemBalanced => "openclaw-ecosystem-balanced", + } + } +} + +#[derive(clap::Args, Debug, Clone, Default)] +pub struct RunSpecBridgeSupportArgs { + /// Optional JSON file containing a bridge support policy override for this spec run + #[arg(long, conflicts_with_all = ["bridge_profile", "bridge_support_delta"])] + pub bridge_support: Option, + /// Optional bundled bridge support profile override for this spec run + #[arg(long, value_enum, conflicts_with_all = ["bridge_support", "bridge_support_delta"])] + pub bridge_profile: Option, + /// Optional delta artifact JSON file derived from a bundled bridge support profile + #[arg(long, conflicts_with_all = ["bridge_support", "bridge_profile"])] + pub bridge_support_delta: Option, + /// Optional sha256 pin for the resolved bridge support policy override + #[arg(long)] + pub bridge_support_sha256: Option, + /// Optional sha256 pin for the bridge support delta artifact override + #[arg(long)] + pub bridge_support_delta_sha256: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct ChannelSendCliArgs<'a> { + pub config_path: Option<&'a str>, + pub account: Option<&'a str>, + pub target: Option<&'a str>, + pub target_kind: mvp::channel::ChannelOutboundTargetKind, + pub text: &'a str, + pub as_card: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct ChannelServeCliArgs<'a> { + pub config_path: Option<&'a str>, + pub account: Option<&'a str>, + pub once: bool, + pub bind_override: Option<&'a str>, + pub path_override: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy)] +pub struct ChannelSendCliSpec { + pub family: mvp::channel::ChannelCatalogCommandFamilyDescriptor, + pub run: for<'a> fn(ChannelSendCliArgs<'a>) -> ChannelCliCommandFuture<'a>, +} + +#[derive(Debug, Clone, Copy)] +pub struct ChannelServeCliSpec { + pub family: mvp::channel::ChannelCommandFamilyDescriptor, + pub run: for<'a> fn(ChannelServeCliArgs<'a>) -> ChannelCliCommandFuture<'a>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MultiChannelServeChannelAccount { + pub channel_id: String, + pub account_id: String, +} + +impl std::str::FromStr for MultiChannelServeChannelAccount { + type Err = String; + + fn from_str(raw: &str) -> Result { + parse_multi_channel_serve_channel_account(raw) + } +} + +#[derive(Parser, Debug)] +#[command( + name = CLI_COMMAND_NAME, + about = "LoongClaw low-level runtime daemon", + version +)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)] +pub enum InitSpecPreset { + #[default] + Default, + PluginTrustGuard, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + #[command( + long_about = "Show the configured welcome banner and quick commands.\n\nquick commands:\n- loong ask --config --message \"...\"\n- loong chat --config \n- loong personalize --config \n- loong doctor --config \n- loong --help\n\nReplace with your current config path, or set LOONGCLAW_CONFIG_PATH first." + )] + /// Show a welcome banner for an already configured install + Welcome, + /// Run the original end-to-end bootstrap demo + Demo, + /// Execute one task through the kernel+harness path + RunTask { + #[arg(long)] + objective: String, + #[arg(long, default_value = "{}")] + payload: String, + }, + /// Invoke one connector operation through kernel policy gate + InvokeConnector { + #[arg(long)] + operation: String, + #[arg(long, default_value = "{}")] + payload: String, + }, + /// Demonstrate audit lifecycle with fixed clock and token revocation + AuditDemo, + /// Generate a runnable JSON spec template for quick vertical customization + InitSpec { + #[arg(long, default_value = "loongclaw.spec.json")] + output: String, + #[arg(long, value_enum, default_value_t = InitSpecPreset::Default)] + preset: InitSpecPreset, + }, + /// Run a full workflow from a JSON spec (task/connector/runtime/tool/memory) + RunSpec { + #[arg(long)] + spec: String, + #[arg(long, default_value_t = false)] + print_audit: bool, + #[arg(long, default_value_t = false)] + render_summary: bool, + #[command(flatten)] + bridge_support: RunSpecBridgeSupportArgs, + }, + /// Run pressure benchmarks for programmatic orchestration and optional regression gate checks + BenchmarkProgrammaticPressure { + #[arg( + long, + default_value = "examples/benchmarks/programmatic-pressure-matrix.json" + )] + matrix: String, + #[arg(long)] + baseline: Option, + #[arg( + long, + default_value = "target/benchmarks/programmatic-pressure-report.json" + )] + output: String, + #[arg(long, default_value_t = false)] + enforce_gate: bool, + #[arg(long, default_value_t = false)] + preflight_fail_on_warnings: bool, + }, + /// Lint pressure baseline coverage without running benchmark scenarios + BenchmarkProgrammaticPressureLint { + #[arg( + long, + default_value = "examples/benchmarks/programmatic-pressure-matrix.json" + )] + matrix: String, + #[arg(long)] + baseline: Option, + #[arg( + long, + default_value = "target/benchmarks/programmatic-pressure-baseline-lint-report.json" + )] + output: String, + #[arg(long, default_value_t = false)] + enforce_gate: bool, + #[arg(long, default_value_t = false)] + fail_on_warnings: bool, + }, + /// Benchmark Wasm compile cache behavior and enforce hot-path speedup gate + BenchmarkWasmCache { + #[arg(long, default_value = "examples/plugins-wasm/secure_echo.wasm")] + wasm: String, + #[arg( + long, + default_value = "target/benchmarks/wasm-cache-benchmark-report.json" + )] + output: String, + #[arg(long, default_value_t = 8)] + cold_iterations: usize, + #[arg(long, default_value_t = 24)] + hot_iterations: usize, + #[arg(long, default_value_t = 2)] + warmup_iterations: usize, + #[arg(long, default_value_t = false)] + enforce_gate: bool, + #[arg(long, default_value_t = 1.5)] + min_speedup_ratio: f64, + }, + /// Benchmark memory prompt-context hydration across window-only, rebuild, steady-state, and shrink catch-up summary paths + BenchmarkMemoryContext { + #[arg( + long, + default_value = "target/benchmarks/memory-context-benchmark-report.json" + )] + output: String, + #[arg(long)] + temp_root: Option, + #[arg(long, default_value_t = 256)] + history_turns: usize, + #[arg(long, default_value_t = 24)] + sliding_window: usize, + #[arg(long, default_value_t = 1024)] + summary_max_chars: usize, + #[arg(long, default_value_t = 24)] + words_per_turn: usize, + #[arg(long, default_value_t = 12)] + rebuild_iterations: usize, + #[arg(long, default_value_t = 32)] + hot_iterations: usize, + #[arg(long, default_value_t = 4)] + warmup_iterations: usize, + #[arg(long, default_value_t = 1)] + suite_repetitions: usize, + #[arg(long, default_value_t = false)] + enforce_gate: bool, + #[arg(long, default_value_t = 1.2)] + min_steady_state_speedup_ratio: f64, + }, + /// Validate config semantics and report structured diagnostics + ValidateConfig { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + #[arg(long, value_enum)] + output: Option, + #[arg(long, default_value = "en")] + locale: String, + #[arg(long, default_value_t = false)] + fail_on_diagnostics: bool, + }, + #[command( + about = "Guided onboarding for fast first-chat setup with preflight diagnostics", + long_about = "Guided onboarding for fast first-chat setup with preflight diagnostics.\n\nThis is the default path for most users. LoongClaw will detect reusable settings for provider, channels, or workspace guidance, suggest a starting point, and walk through quick review before first chat." + )] + Onboard { + /// Write the resulting config to a custom path instead of the default loongclaw config location + #[arg(long)] + output: Option, + /// Overwrite an existing target config path instead of stopping for manual review + #[arg(long, default_value_t = false)] + force: bool, + /// Use provided flags only and skip interactive prompts except required safety checks + #[arg(long, default_value_t = false)] + non_interactive: bool, + /// Confirm the onboarding risk acknowledgement in non-interactive mode + #[arg(long, default_value_t = false)] + accept_risk: bool, + #[arg( + long, + value_name = mvp::config::PROVIDER_SELECTOR_PLACEHOLDER, + help = mvp::config::PROVIDER_SELECTOR_HUMAN_SUMMARY + )] + provider: Option, + /// Preselect the model to use after the provider choice is resolved + #[arg(long)] + model: Option, + /// Provider credential environment variable name, for example OPENAI_API_KEY + #[arg(long = "api-key", alias = "api-key-env")] + api_key_env: Option, + #[arg( + long = "web-search-provider", + value_name = "PROVIDER", + help = mvp::config::WEB_SEARCH_PROVIDER_VALID_VALUES + )] + web_search_provider: Option, + /// Web search credential environment variable name, for example TAVILY_API_KEY + #[arg(long = "web-search-api-key", alias = "web-search-api-key-env")] + web_search_api_key_env: Option, + /// Select a native prompt personality in non-interactive mode + #[arg(long)] + personality: Option, + /// Select a memory profile in non-interactive mode + #[arg(long)] + memory_profile: Option, + /// Preseed the CLI system prompt instead of editing it interactively + #[arg(long)] + system_prompt: Option, + /// Skip probing the resolved provider model list during onboarding + #[arg(long, default_value_t = false)] + skip_model_probe: bool, + }, + #[command( + about = "Capture optional operator preferences for future sessions", + long_about = "Capture optional operator preferences for future sessions.\n\nThis command stores advisory working preferences such as preferred name, response density, initiative level, and standing boundaries. Rerun it any time to update or clear saved preferences. It does not replace runtime identity files, and it does not change the primary setup path. If you do not have a config yet, run `loong onboard` first." + )] + Personalize { + /// Config file path to update (defaults to auto-discovery) + #[arg(long)] + config: Option, + }, + #[command( + about = "Preview or apply migration sources explicitly", + long_about = "Power-user import flow for previewing or applying detected migration sources explicitly.\n\nUse this when you want exact CLI control over which source and domains are reused. If you want the guided path, use `loong onboard` instead. When the same source kind resolves to multiple detected configs, rerun with `--source-path ` to choose one exact source." + )] + Import { + /// Write the imported config to a custom path instead of the default loongclaw config location + #[arg(long)] + output: Option, + /// Overwrite an existing target config path instead of stopping for manual review + #[arg(long, default_value_t = false)] + force: bool, + /// Print the selected import candidate preview in text mode + #[arg(long, default_value_t = false)] + preview: bool, + /// Apply the selected import candidate to the target config path + #[arg(long, default_value_t = false)] + apply: bool, + /// Emit machine-readable preview JSON for scripting or automation + #[arg(long, default_value_t = false)] + json: bool, + /// Limit selection to one source kind such as recommended, existing, codex, or env + #[arg(long)] + from: Option, + /// Choose one exact detected source path when multiple candidates of the same kind exist + #[arg(long)] + source_path: Option, + #[arg( + long, + value_name = mvp::config::PROVIDER_SELECTOR_PLACEHOLDER, + help = mvp::config::PROVIDER_SELECTOR_HUMAN_SUMMARY + )] + provider: Option, + /// Reuse only the listed domains, for example provider,channels + #[arg(long, value_delimiter = ',')] + include: Vec, + /// Exclude the listed domains from the selected import candidate + #[arg(long, value_delimiter = ',')] + exclude: Vec, + }, + #[command( + about = "Preview or apply config import modes explicitly", + long_about = "Power-user config import flow for discovering, previewing, or applying external workspace state explicitly.\n\nUse this when you want exact CLI control over import mode selection and output handling for compatibility sources and older workspace roots. If you want the guided path, use `loong onboard` instead.\n\nMode quick reference:\n- discover, plan_many, recommend_primary, merge_profiles, map_external_skills: require `--input`\n- plan: requires `--input`; `--output` is optional preview target\n- apply: requires `--input` and `--output`\n- apply_selected: requires `--input` and `--output`; use `--source-id` to pin one discovered source, and `--apply-external-skills-plan` to bridge installable local external skills into the managed runtime\n- rollback_last_apply: requires `--output`" + )] + Migrate { + /// Path to the legacy agent workspace or root to inspect + #[arg(long)] + input: Option, + /// Target LoongClaw config path to preview, write, or roll back + #[arg(long)] + output: Option, + /// Hint the legacy claw-family source kind for single-source plan/apply modes + #[arg(long)] + source: Option, + /// Migration mode to run + #[arg(long, value_enum)] + mode: migrate_cli::MigrateMode, + /// Emit machine-readable JSON instead of text output + #[arg(long, default_value_t = false)] + json: bool, + /// Explicit discovered source id to apply for apply_selected mode + #[arg(long)] + source_id: Option, + /// Merge profile-lane content while keeping one prompt owner + #[arg(long, default_value_t = false)] + safe_profile_merge: bool, + /// Explicit primary source id when safe profile merge is enabled + #[arg(long)] + primary_source_id: Option, + /// Bridge installable local external skills into the managed runtime during apply_selected + #[arg(long, default_value_t = false)] + apply_external_skills_plan: bool, + /// Overwrite an existing target config path instead of stopping for manual review + #[arg(long, default_value_t = false)] + force: bool, + }, + /// Run configuration diagnostics and optionally apply safe config/path fixes + Doctor { + /// Config file path to validate (defaults to auto-discovery) + #[arg(long, global = true)] + config: Option, + /// Apply safe auto-fixes for detected diagnostics + #[arg(long, global = true, default_value_t = false)] + fix: bool, + /// Emit machine-readable JSON diagnostics + #[arg(long, global = true, default_value_t = false)] + json: bool, + /// Skip provider model probing during diagnostics + #[arg(long, global = true, default_value_t = false)] + skip_model_probe: bool, + #[command(subcommand)] + command: Option, + }, + /// Inspect the retained audit journal through a bounded CLI surface + Audit { + #[arg(long, global = true)] + config: Option, + #[arg(long, global = true, default_value_t = false)] + json: bool, + #[command(subcommand)] + command: audit_cli::AuditCommands, + }, + /// Manage installed external skills through an operator-facing CLI surface + Skills { + #[arg(long, global = true)] + config: Option, + #[arg(long, global = true, default_value_t = false)] + json: bool, + #[command(subcommand)] + command: skills_cli::SkillsCommands, + }, + /// Manage async background tasks on top of the current session runtime + Tasks { + #[arg(long, global = true)] + config: Option, + #[arg(long, global = true, default_value_t = false)] + json: bool, + #[arg(long, global = true, default_value = "default")] + session: String, + #[command(subcommand)] + command: tasks_cli::TasksCommands, + }, + #[command(hide = true)] + DelegateChildRun { + #[arg(long)] + config_path: String, + #[arg(long)] + payload_file: String, + }, + #[command( + about = "Inspect and manage persisted runtime sessions through an operator-facing session shell", + long_about = "Bounded operator-facing session shell for persisted runtime sessions.\n\nUse this surface to list visible sessions, inspect one session's workflow metadata, review lifecycle events, inspect transcript history, and apply bounded recover, cancel, or archive actions without inventing a second session model." + )] + Sessions { + #[arg(long, global = true)] + config: Option, + #[arg(long, global = true, default_value_t = false)] + json: bool, + #[arg(long, global = true, default_value = "default")] + session: String, + #[command(subcommand)] + command: sessions_cli::SessionsCommands, + }, + /// Print one operator-readable runtime summary over gateway, ACP, and durable work-unit health + #[rustfmt::skip] + Status { #[arg(long)] config: Option, #[arg(long, default_value_t = false)] json: bool }, + #[command( + visible_alias = "plugin", + about = "Author manifest-first plugin packages and inspect shared plugin governance truth", + long_about = "Manifest-first plugin namespace for bounded authoring bootstrap, inspecting manifest-first package inventory, diagnosing package-author contract issues, evaluating profile-aware preflight, and consuming the deduplicated operator action plan.\n\nThis command does not introduce a second policy engine. It reuses the existing spec `plugin_inventory` and `plugin_preflight` surfaces for shared plugin truth and adds thin author-facing surfaces for external package roots." + )] + Plugins { + #[arg(long, global = true, default_value_t = false)] + json: bool, + #[command(subcommand)] + command: plugins_cli::PluginsCommands, + }, + /// List compiled channel surfaces, aliases, and readiness status + Channels { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Fetch and print currently available provider model list + ListModels { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Print a unified runtime snapshot for experiment reproducibility and lineage capture + RuntimeSnapshot { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + #[arg(long)] + output: Option, + #[arg(long)] + label: Option, + #[arg(long)] + experiment_id: Option, + #[arg(long)] + parent_snapshot_id: Option, + }, + #[command( + long_about = "Restore a persisted runtime snapshot artifact into the current config and managed skill state.\n\nDry-run by default; pass --apply to mutate config or managed skills." + )] + /// Restore a persisted runtime snapshot artifact into the current config and managed skill state + RuntimeRestore { + #[arg(long)] + config: Option, + #[arg(long)] + snapshot: String, + #[arg(long, default_value_t = false)] + json: bool, + #[arg(long, default_value_t = false)] + apply: bool, + }, + /// Manage snapshot-linked experiment run records + RuntimeExperiment { + #[command(subcommand)] + command: runtime_experiment_cli::RuntimeExperimentCommands, + }, + /// Manage run-derived capability candidates, family readiness, promotion plans, and governed apply outputs + RuntimeCapability { + #[command(subcommand)] + command: runtime_capability_cli::RuntimeCapabilityCommands, + }, + /// Manage durable work units for long-running runtime orchestration + WorkUnit { + #[command(subcommand)] + command: work_unit_cli::WorkUnitCommands, + }, + /// List available conversation context engines and selected runtime engine + ListContextEngines { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// List available memory systems and selected runtime memory system + ListMemorySystems { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// List configured MCP servers and their runtime-visible inventory state + ListMcpServers { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Show one configured MCP server and its runtime-visible inventory state + ShowMcpServer { + #[arg(long)] + config: Option, + #[arg(long)] + name: String, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// List available ACP runtime backends and current control-plane selection + ListAcpBackends { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// List persisted ACP session metadata from the local control-plane store + ListAcpSessions { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Inspect live ACP session status by session key or conversation identity + AcpStatus { + #[arg(long)] + config: Option, + #[arg(long, conflicts_with_all = ["conversation_id", "route_session_id"])] + session: Option, + #[arg(long, conflicts_with_all = ["session", "route_session_id"])] + conversation_id: Option, + #[arg(long, conflicts_with_all = ["session", "conversation_id"])] + route_session_id: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Inspect ACP control-plane observability snapshot from the shared session manager + AcpObservability { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Print ACP runtime event summary for a conversation session + AcpEventSummary { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long, default_value_t = 200)] + limit: usize, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Evaluate ACP conversation dispatch policy for a session or structured channel address + AcpDispatch { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long)] + channel: Option, + #[arg(long)] + conversation_id: Option, + #[arg(long)] + account_id: Option, + #[arg(long)] + thread_id: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Run ACP backend readiness diagnostics for the selected or requested backend + AcpDoctor { + #[arg(long)] + config: Option, + #[arg(long)] + backend: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + #[command( + about = "Run the loopback-only internal control-plane skeleton", + long_about = "Run the internal control-plane skeleton.\n\nBy default this control-plane listener binds 127.0.0.1 only. You may provide `--bind ` to override the listener address, but non-loopback binds require `--config` plus `control_plane.allow_remote=true` and a configured `control_plane.shared_token`. Baseline endpoints are `/readyz`, `/healthz`, `/control/challenge`, `/control/connect`, `/control/subscribe`, `/control/snapshot`, and `/control/events`. When `--config` is provided, repository-backed `/session/list`, `/session/read`, `/approval/list`, `/pairing/list`, `/pairing/resolve`, `/acp/session/list`, and `/acp/session/read` views become available for the selected session root." + )] + ControlPlaneServe { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long)] + bind: Option, + #[arg(long, default_value_t = 0)] + port: u16, + }, + #[command( + about = "Run one non-interactive assistant turn", + long_about = "Run one non-interactive one-shot assistant turn.\n\nUse this when you want a fast answer without entering the interactive `loong chat` REPL. The command reuses the normal CLI conversation runtime, session memory, provider selection, and ACP options." + )] + Ask { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long)] + message: String, + #[arg(long, default_value_t = false)] + acp: bool, + #[arg(long, default_value_t = false)] + acp_event_stream: bool, + #[arg(long = "acp-bootstrap-mcp-server")] + acp_bootstrap_mcp_server: Vec, + #[arg(long = "acp-cwd")] + acp_cwd: Option, + }, + /// Start interactive CLI chat channel with sliding-window memory + Chat { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long, default_value_t = false)] + acp: bool, + #[arg(long, default_value_t = false)] + acp_event_stream: bool, + #[arg(long = "acp-bootstrap-mcp-server")] + acp_bootstrap_mcp_server: Vec, + #[arg(long = "acp-cwd")] + acp_cwd: Option, + }, + /// Print safe-lane runtime event summary for a session + SafeLaneSummary { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long, default_value_t = 200)] + limit: usize, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Search transcript turns across visible sessions + SessionSearch { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long)] + query: String, + #[arg(long, default_value_t = 20)] + limit: usize, + #[arg(long)] + output: Option, + #[arg(long, default_value_t = false)] + include_archived: bool, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Inspect one exported session-search artifact + SessionSearchInspect { + #[arg(long)] + artifact: String, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Export one session trajectory artifact with transcript turns and session events + TrajectoryExport { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long)] + output: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Inspect one exported trajectory artifact + TrajectoryInspect { + #[arg(long)] + artifact: String, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Export or inspect runtime trajectory artifacts for replay, evaluation, or research workflows + RuntimeTrajectory { + #[command(subcommand)] + command: runtime_trajectory_cli::RuntimeTrajectoryCommands, + }, + /// Send one Telegram message + TelegramSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_telegram_send_target_kind(), + value_parser = parse_telegram_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Run Telegram channel polling/response loop + TelegramServe { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + once: bool, + #[arg(long)] + account: Option, + }, + /// Send one Feishu message or card + FeishuSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long)] + receive_id_type: Option, + #[arg(long = "target", visible_alias = "receive-id")] + target: String, + #[arg( + long, + default_value_t = default_feishu_send_target_kind(), + value_parser = parse_feishu_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: Option, + #[arg(long = "post-json")] + post_json: Option, + #[arg(long)] + image_key: Option, + #[arg(long)] + file_key: Option, + #[arg(long)] + image_path: Option, + #[arg(long)] + file_path: Option, + #[arg(long)] + file_type: Option, + #[arg(long, default_value_t = false)] + card: bool, + #[arg(long)] + uuid: Option, + }, + /// Run Feishu event callback server and auto-reply via provider + FeishuServe { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long)] + bind: Option, + #[arg(long)] + path: Option, + }, + /// Send one Matrix room message + MatrixSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_matrix_send_target_kind(), + value_parser = parse_matrix_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Run Matrix sync reply loop + MatrixServe { + #[arg(long)] + config: Option, + #[arg(long, default_value_t = false)] + once: bool, + #[arg(long)] + account: Option, + }, + /// Send one WeCom AIBot proactive message + WecomSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_wecom_send_target_kind(), + value_parser = parse_wecom_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Run WeCom AIBot long-connection reply loop + WecomServe { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + }, + /// Run WhatsApp Cloud API webhook server and auto-reply via provider + WhatsappServe { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long)] + bind: Option, + #[arg(long)] + path: Option, + }, + /// Send one Discord channel message + DiscordSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_discord_send_target_kind(), + value_parser = parse_discord_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one DingTalk custom robot webhook message + DingtalkSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: Option, + #[arg( + long, + default_value_t = default_dingtalk_send_target_kind(), + value_parser = parse_dingtalk_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Slack channel message + SlackSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_slack_send_target_kind(), + value_parser = parse_slack_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one LINE push message + LineSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_line_send_target_kind(), + value_parser = parse_line_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one WhatsApp business message + WhatsappSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_whatsapp_send_target_kind(), + value_parser = parse_whatsapp_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one SMTP email message + EmailSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_email_send_target_kind(), + value_parser = parse_email_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one generic webhook POST message + WebhookSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: Option, + #[arg( + long, + default_value_t = default_webhook_send_target_kind(), + value_parser = parse_webhook_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Google Chat incoming webhook message + GoogleChatSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: Option, + #[arg( + long, + default_value_t = default_google_chat_send_target_kind(), + value_parser = parse_google_chat_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Microsoft Teams incoming webhook message + TeamsSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: Option, + #[arg( + long, + default_value_t = default_teams_send_target_kind(), + value_parser = parse_teams_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Tlon direct message or group post + TlonSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_tlon_send_target_kind(), + value_parser = parse_tlon_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Signal direct message + SignalSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_signal_send_target_kind(), + value_parser = parse_signal_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Twitch chat message + TwitchSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_twitch_send_target_kind(), + value_parser = parse_twitch_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Mattermost channel post + MattermostSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_mattermost_send_target_kind(), + value_parser = parse_mattermost_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Nextcloud Talk bot room message + NextcloudTalkSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_nextcloud_talk_send_target_kind(), + value_parser = parse_nextcloud_talk_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one Synology Chat incoming webhook message + SynologyChatSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: Option, + #[arg( + long, + default_value_t = default_synology_chat_send_target_kind(), + value_parser = parse_synology_chat_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one IRC message to a channel or nick + IrcSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_irc_send_target_kind(), + value_parser = parse_irc_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Send one iMessage chat through BlueBubbles + ImessageSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: String, + #[arg( + long, + default_value_t = default_imessage_send_target_kind(), + value_parser = parse_imessage_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Publish one signed Nostr text note + NostrSend { + #[arg(long)] + config: Option, + #[arg(long)] + account: Option, + #[arg(long = "target")] + target: Option, + #[arg( + long, + default_value_t = default_nostr_send_target_kind(), + value_parser = parse_nostr_send_target_kind + )] + target_kind: mvp::channel::ChannelOutboundTargetKind, + #[arg(long)] + text: String, + }, + /// Run the multi-channel supervisor for coordinated runtime-backed service-channel serving + MultiChannelServe { + #[arg(long)] + config: Option, + #[arg(long)] + session: String, + #[arg(long = "channel-account", value_name = "CHANNEL=ACCOUNT")] + channel_account: Vec, + }, + /// Run the gateway lifecycle namespace + Gateway { + #[command(subcommand)] + command: gateway::service::GatewayCommand, + }, + /// Run the Feishu integration namespace + Feishu { + #[command(subcommand)] + command: feishu_cli::FeishuCommand, + }, + /// Run the Web Console API surface + Web { + #[command(subcommand)] + command: web_cli::WebCommand, + }, + /// Print a shell completion script to stdout + Completions { + /// Target shell (bash, zsh, fish, powershell, elvish) + shell: clap_complete::Shell, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ValidateConfigOutput { + Text, + Json, + ProblemJson, +} + +fn parse_multi_channel_serve_channel_account( + raw: &str, +) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("multi-channel channel-account entries cannot be empty".to_owned()); + } + + let (raw_channel_id, raw_account_id) = trimmed.split_once('=').ok_or_else(|| { + format!("multi-channel channel-account `{trimmed}` must use CHANNEL=ACCOUNT syntax") + })?; + + let channel_token = raw_channel_id.trim(); + if channel_token.is_empty() { + return Err(format!( + "multi-channel channel-account `{trimmed}` is missing a channel id" + )); + } + + let supported_channel_ids = supported_multi_channel_serve_channel_ids(); + let supported_channels = supported_channel_ids.join(", "); + let runtime_descriptor = mvp::channel::resolve_channel_runtime_command_descriptor(channel_token) + .ok_or_else(|| { + format!( + "unrecognized multi-channel service channel `{channel_token}` (available runtime-backed channels: {supported_channels})" + ) + })?; + let runtime_channel_id = runtime_descriptor.channel_id; + let runtime_is_supported = supported_channel_ids.contains(&runtime_channel_id); + if !runtime_is_supported { + return Err(format!( + "multi-channel service channel `{channel_token}` resolves to `{runtime_channel_id}` but is not supported in this build (expected one of: {supported_channels})" + )); + } + + let account_token = raw_account_id.trim(); + if account_token.is_empty() { + return Err(format!( + "multi-channel channel-account `{trimmed}` is missing an account id" + )); + } + + Ok(MultiChannelServeChannelAccount { + channel_id: runtime_descriptor.channel_id.to_owned(), + account_id: account_token.to_owned(), + }) +} + +fn supported_multi_channel_serve_channel_ids() -> Vec<&'static str> { + let supported_channels = mvp::channel::background_channel_runtime_descriptors() + .into_iter() + .map(|descriptor| descriptor.channel_id) + .collect::>(); + supported_channels.into_iter().collect() +} + +#[cfg(test)] +mod multi_channel_serve_tests { + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn supported_multi_channel_serve_channel_ids_follow_background_runtime_registry() { + let expected_ids = mvp::channel::background_channel_runtime_descriptors() + .into_iter() + .map(|descriptor| descriptor.channel_id) + .collect::>() + .into_iter() + .collect::>(); + let actual_ids = supported_multi_channel_serve_channel_ids(); + + assert_eq!(actual_ids, expected_ids); + } + + #[test] + fn parse_multi_channel_serve_channel_account_rejects_compiled_out_matrix_runtime() { + let supported_channel_ids = supported_multi_channel_serve_channel_ids(); + let matrix_is_supported = supported_channel_ids.contains(&"matrix"); + if matrix_is_supported { + return; + } + + let error = parse_multi_channel_serve_channel_account("matrix=bridge-sync") + .expect_err("compiled-out matrix runtime should be rejected"); + + assert!( + error.contains( + "multi-channel service channel `matrix` resolves to `matrix` but is not supported in this build" + ) + ); + } + + #[test] + fn parse_multi_channel_serve_channel_account_rejects_unknown_runtime_channel() { + let error = parse_multi_channel_serve_channel_account("unknown=bridge-sync") + .expect_err("unknown runtime channel should be rejected"); + + assert!(error.contains("unrecognized multi-channel service channel `unknown`")); + } +} + +fn resolved_default_entry_config_path() -> PathBuf { + std::env::var_os("LOONGCLAW_CONFIG_PATH") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(mvp::config::default_config_path) +} + +fn default_onboard_command() -> Commands { + Commands::Onboard { + output: None, + force: false, + non_interactive: false, + accept_risk: false, + provider: None, + model: None, + api_key_env: None, + web_search_provider: None, + web_search_api_key_env: None, + personality: None, + memory_profile: None, + system_prompt: None, + skip_model_probe: false, + } +} + +pub fn resolve_default_entry_command() -> Commands { + if resolved_default_entry_config_path().is_file() { + Commands::Welcome + } else { + default_onboard_command() + } +} + +pub fn redacted_command_name(command: &Commands) -> &'static str { + command.command_kind_for_logging() +} + +fn resolve_welcome_config_path() -> CliResult { + let config_path = resolved_default_entry_config_path(); + if config_path.is_file() { + Ok(config_path) + } else { + Err(format!( + "Config file not found at {}. Run `{} onboard` to set up LoongClaw.", + config_path.display(), + active_cli_command_name(), + )) + } +} + +fn render_welcome_banner(config_path: &Path, config: &mvp::config::LoongClawConfig) -> String { + let config_path_display = config_path.display().to_string(); + let next_actions = next_actions::collect_setup_next_actions(config, &config_path_display); + let mut quick_command_lines = Vec::new(); + + for action in next_actions { + let action_label = action.label; + let action_command = action.command; + let quick_command_line = format!("- {action_label}: {action_command}"); + quick_command_lines.push(quick_command_line); + } + + quick_command_lines.push(format!("- Help: {} --help", CLI_COMMAND_NAME)); + let quick_commands = quick_command_lines.join("\n"); + + format!( + "LoongClaw is configured and ready.\nVersion: {}\nConfig: {}\n\nQuick commands:\n{}", + env!("CARGO_PKG_VERSION"), + config_path_display, + quick_commands, + ) +} + +pub fn run_welcome_cli() -> CliResult<()> { + let config_path = resolve_welcome_config_path()?; + let config_path_string = config_path.display().to_string(); + let load_result = mvp::config::load(Some(config_path_string.as_str()))?; + let (_resolved_path, config) = load_result; + println!("{}", render_welcome_banner(config_path.as_path(), &config)); + Ok(()) +} + +#[cfg(test)] +mod first_run_entry_tests { + use super::*; + use crate::test_support::ScopedEnv; + use std::{ + fs, + path::{Path, PathBuf}, + process, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + static UNIQUE_TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let pid = process::id(); + let counter = UNIQUE_TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("{prefix}-{pid}-{nanos}-{counter}")) + } + + fn isolated_home(prefix: &str) -> (ScopedEnv, PathBuf) { + let mut env = ScopedEnv::new(); + let home = unique_temp_dir(prefix); + fs::create_dir_all(&home).expect("create isolated home"); + env.set("HOME", &home); + env.remove("LOONG_HOME"); + env.remove("LOONGCLAW_CONFIG_PATH"); + (env, home) + } + + #[test] + fn resolve_default_entry_command_routes_to_onboard_when_config_is_missing() { + let (_env, _home) = isolated_home("loongclaw-default-entry-missing"); + + assert!( + matches!(resolve_default_entry_command(), Commands::Onboard { .. }), + "missing config should route to onboard" + ); + } + + #[test] + fn resolve_default_entry_command_routes_to_welcome_when_default_config_exists() { + let (_env, _home) = isolated_home("loongclaw-default-entry-present"); + let config_path = mvp::config::default_config_path(); + mvp::config::write( + Some(config_path.to_str().expect("utf8 config path")), + &mvp::config::LoongClawConfig::default(), + true, + ) + .expect("write default config"); + + assert!( + matches!(resolve_default_entry_command(), Commands::Welcome), + "present config should route to welcome" + ); + } + + #[test] + fn resolve_default_entry_command_honors_loongclaw_config_path_override() { + let mut env = ScopedEnv::new(); + let config_path = unique_temp_dir("loongclaw-default-entry-env").join("custom-config.toml"); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).expect("create config parent"); + } + mvp::config::write( + Some(config_path.to_str().expect("utf8 config path")), + &mvp::config::LoongClawConfig::default(), + true, + ) + .expect("write explicit config"); + env.set("LOONGCLAW_CONFIG_PATH", &config_path); + + assert!( + matches!(resolve_default_entry_command(), Commands::Welcome), + "env override config should route to welcome" + ); + } + + #[test] + fn resolve_default_entry_command_routes_to_onboard_when_config_path_is_a_directory() { + let mut env = ScopedEnv::new(); + let config_dir = unique_temp_dir("loongclaw-default-entry-dir"); + fs::create_dir_all(&config_dir).expect("create config directory"); + env.set("LOONGCLAW_CONFIG_PATH", &config_dir); + + assert!( + matches!(resolve_default_entry_command(), Commands::Onboard { .. }), + "directory config path should still route to onboard" + ); + } + + #[test] + fn redacted_command_name_omits_sensitive_command_payloads() { + let command = Commands::RunTask { + objective: "secret objective".to_owned(), + payload: "{\"api_key\":\"secret\"}".to_owned(), + }; + + let redacted_name = redacted_command_name(&command); + + assert_eq!(redacted_name, "run_task"); + } + + #[test] + fn run_welcome_cli_rejects_missing_config_file() { + let mut env = ScopedEnv::new(); + let config_path = unique_temp_dir("loongclaw-welcome-missing").join("missing-config.toml"); + env.set("LOONGCLAW_CONFIG_PATH", &config_path); + + let error = run_welcome_cli().expect_err("missing config should fail welcome"); + + assert!( + error.contains("Config file not found"), + "welcome should explain the missing config file: {error}" + ); + assert!( + error.contains("loong onboard"), + "welcome should point users back to onboarding: {error}" + ); + } + + #[test] + fn run_welcome_cli_rejects_directory_config_path() { + let mut env = ScopedEnv::new(); + let config_dir = unique_temp_dir("loongclaw-welcome-dir"); + fs::create_dir_all(&config_dir).expect("create config directory"); + env.set("LOONGCLAW_CONFIG_PATH", &config_dir); + + let error = run_welcome_cli().expect_err("directory config path should fail welcome"); + + assert!( + error.contains("Config file not found"), + "welcome should reject directory config paths as missing config files: {error}" + ); + } + + #[test] + fn render_welcome_banner_includes_version_and_next_commands() { + let config = mvp::config::LoongClawConfig::default(); + let rendered = render_welcome_banner(Path::new("/tmp/loongclaw's config.toml"), &config); + + assert!( + rendered.contains(env!("CARGO_PKG_VERSION")), + "welcome banner should include the current version: {rendered}" + ); + assert!( + rendered.contains("loong ask --config '/tmp/loongclaw'\"'\"'s config.toml'"), + "welcome banner should include a quoted ask command: {rendered}" + ); + assert!( + rendered.contains("loong chat --config '/tmp/loongclaw'\"'\"'s config.toml'"), + "welcome banner should include a quoted chat command: {rendered}" + ); + assert!( + rendered.contains("loong personalize --config '/tmp/loongclaw'\"'\"'s config.toml'"), + "welcome banner should include a quoted personalize command: {rendered}" + ); + assert!( + rendered.contains("loong --help"), + "welcome banner should point users to root help: {rendered}" + ); + assert!( + rendered.contains("- first answer:"), + "welcome banner should preserve the shared next-action label for ask: {rendered}" + ); + assert!( + rendered.contains("- working preferences:"), + "welcome banner should preserve the shared next-action label for personalize: {rendered}" + ); + } +} + +pub async fn invoke_connector_cli(operation: &str, payload_raw: &str) -> CliResult<()> { + let payload = cli_json::parse_json_payload(payload_raw, "invoke-connector payload")?; + + let kernel = kernel_bootstrap::KernelBuilder::default().build(); + let token = kernel + .issue_token(DEFAULT_PACK_ID, DEFAULT_AGENT_ID, 120) + .map_err(|error| format!("token issue failed: {error}"))?; + + let dispatch = kernel + .execute_connector_core( + DEFAULT_PACK_ID, + &token, + None, + ConnectorCommand { + connector_name: "webhook".to_owned(), + operation: operation.to_owned(), + required_capabilities: BTreeSet::from([Capability::InvokeConnector]), + payload, + }, + ) + .await + .map_err(|error| format!("connector dispatch failed: {error}"))?; + + let pretty = serde_json::to_string_pretty(&dispatch.outcome) + .map_err(|error| format!("serialize connector outcome failed: {error}"))?; + println!("{pretty}"); + Ok(()) +} + +pub async fn run_audit_demo() -> CliResult<()> { + let fixed_clock = Arc::new(FixedClock::new(1_700_000_000)); + let audit_sink = Arc::new(InMemoryAuditSink::default()); + + let kernel = kernel_bootstrap::KernelBuilder::default() + .clock(fixed_clock.clone()) + .audit(audit_sink.clone()) + .build(); + + let token = kernel + .issue_token(DEFAULT_PACK_ID, DEFAULT_AGENT_ID, 30) + .map_err(|error| format!("token issue failed: {error}"))?; + + let _ = execute_daemon_task_with_supervisor( + &kernel, + DEFAULT_PACK_ID, + &token, + TaskIntent { + task_id: "task-audit-01".to_owned(), + objective: "produce audit evidence".to_owned(), + required_capabilities: BTreeSet::from([Capability::InvokeTool]), + payload: json!({}), + }, + ) + .await?; + + fixed_clock.advance_by(5); + + let _ = kernel + .execute_connector_core( + DEFAULT_PACK_ID, + &token, + None, + ConnectorCommand { + connector_name: "webhook".to_owned(), + operation: "notify".to_owned(), + required_capabilities: BTreeSet::from([Capability::InvokeConnector]), + payload: json!({"channel": "audit"}), + }, + ) + .await + .map_err(|error| format!("connector invoke failed: {error}"))?; + + kernel + .revoke_token(&token.token_id, Some(DEFAULT_AGENT_ID)) + .map_err(|error| format!("token revoke failed: {error}"))?; + + let pretty = serde_json::to_string_pretty(&audit_sink.snapshot()) + .map_err(|error| format!("serialize audit events failed: {error}"))?; + println!("{pretty}"); + Ok(()) +} + +pub fn init_spec_cli(output_path: &str, preset: InitSpecPreset) -> CliResult<()> { + let spec = match preset { + InitSpecPreset::Default => RunnerSpec::template(), + InitSpecPreset::PluginTrustGuard => RunnerSpec::plugin_trust_guard_template(), + }; + write_json_file(output_path, &spec)?; + println!("spec template written to {}", output_path); + Ok(()) +} + +pub async fn run_spec_cli( + spec_path: &str, + print_audit: bool, + render_summary: bool, + bridge_support: &RunSpecBridgeSupportArgs, +) -> CliResult<()> { + validate_run_spec_bridge_support_args(bridge_support)?; + let resolved = read_spec_file_with_bridge_support_resolution( + spec_path, + run_spec_bridge_support_selection(bridge_support).as_ref(), + )?; + let report = execute_spec_with_native_tool_executor_and_bridge_support_provenance( + &resolved.spec, + print_audit, + Some(native_spec_tool_executor), + resolved.bridge_support_source, + resolved.bridge_support_delta_source, + resolved.bridge_support_delta_sha256, + ) + .await; + if render_summary { + eprintln!("{}", render_spec_run_summary(&report)); + } + let pretty = serde_json::to_string_pretty(&report) + .map_err(|error| format!("serialize spec run report failed: {error}"))?; + println!("{pretty}"); + Ok(()) +} + +fn validate_run_spec_bridge_support_args(args: &RunSpecBridgeSupportArgs) -> CliResult<()> { + let has_policy_source = args.bridge_support.is_some() + || args.bridge_profile.is_some() + || args.bridge_support_delta.is_some(); + let has_sha256_pin = + args.bridge_support_sha256.is_some() || args.bridge_support_delta_sha256.is_some(); + + if has_policy_source || !has_sha256_pin { + return Ok(()); + } + + Err( + "run-spec bridge support sha256 pins require --bridge-support, --bridge-profile, or --bridge-support-delta" + .to_owned(), + ) +} + +fn render_spec_run_summary(report: &SpecRunReport) -> String { + let mut lines = vec![format!( + "run-spec summary pack={} agent={} status={} operation={}", + report.pack_id, + report.agent_id, + spec_run_status_label(report), + report.operation_kind + )]; + + if let Some(blocked_reason) = report.blocked_reason.as_deref() { + lines.push(format!( + "blocked_reason={}", + sanitize_summary_field(blocked_reason) + )); + } + + if report.plugin_trust_summary.scanned_plugins > 0 { + let trust = &report.plugin_trust_summary; + lines.push(format!( + "plugin_trust scanned={} official={} verified_community={} unverified={} high_risk={} high_risk_unverified={} blocked_auto_apply={} review_required={}", + trust.scanned_plugins, + trust.official_plugins, + trust.verified_community_plugins, + trust.unverified_plugins, + trust.high_risk_plugins, + trust.high_risk_unverified_plugins, + trust.blocked_auto_apply_plugins, + trust.review_required_plugins.len() + )); + + for entry in trust.review_required_plugins.iter().take(3) { + lines.push(render_plugin_trust_review_summary(entry)); + } + if trust.review_required_plugins.len() > 3 { + lines.push(format!( + "plugin_review remaining={}", + trust.review_required_plugins.len() - 3 + )); + } + } + + if let Some(summary) = report.tool_search_summary.as_ref() { + lines.push(format!( + "tool_search {}", + sanitize_summary_field(&summary.headline) + )); + + if summary.trust_filter_summary.applied { + lines.push(format!( + "tool_search_filters query_requested={} structured_requested={} effective={} conflicting={} filtered_out_by_tier={}", + format_string_list_or_dash(&summary.trust_filter_summary.query_requested_tiers), + format_string_list_or_dash(&summary.trust_filter_summary.structured_requested_tiers), + format_string_list_or_dash(&summary.trust_filter_summary.effective_tiers), + summary.trust_filter_summary.conflicting_requested_tiers, + format_usize_rollup(&summary.trust_filter_summary.filtered_out_tier_counts) + )); + } + + for (index, entry) in summary.top_results.iter().enumerate() { + lines.push(format!( + "tool_search_top[{}] provider={} connector={} tool_id={} trust={} bridge={} score={} setup_ready={} loaded={} deferred={}", + index + 1, + entry.provider_id, + entry.connector_name, + entry.tool_id, + entry.trust_tier.as_deref().unwrap_or("-"), + entry.bridge_kind, + entry.score, + entry.setup_ready, + entry.loaded, + entry.deferred + )); + } + } + + lines.join("\n") +} + +fn spec_run_status_label(report: &SpecRunReport) -> &'static str { + if report.blocked_reason.is_some() || report.operation_kind == "blocked" { + "blocked" + } else { + "ok" + } +} + +fn render_plugin_trust_review_summary(entry: &PluginTrustReviewEntry) -> String { + format!( + "plugin_review plugin={} tier={} bridge={} activation={} bootstrap={} source={} provenance={} reason={}", + entry.plugin_id, + entry.trust_tier.as_str(), + entry.bridge_kind.as_str(), + plugin_activation_status_label(entry.activation_status), + entry + .bootstrap_status + .map(bootstrap_task_status_label) + .unwrap_or("-"), + sanitize_summary_field(&entry.source_path), + sanitize_summary_field(&entry.provenance_summary), + sanitize_summary_field(&entry.reason) + ) +} + +fn plugin_activation_status_label(status: PluginActivationStatus) -> &'static str { + match status { + PluginActivationStatus::Ready => "ready", + PluginActivationStatus::SetupIncomplete => "setup_incomplete", + PluginActivationStatus::BlockedInvalidManifestContract => { + "blocked_invalid_manifest_contract" + } + PluginActivationStatus::BlockedUnsupportedBridge => "blocked_unsupported_bridge", + PluginActivationStatus::BlockedUnsupportedAdapterFamily => { + "blocked_unsupported_adapter_family" + } + PluginActivationStatus::BlockedCompatibilityMode => "blocked_compatibility_mode", + PluginActivationStatus::BlockedIncompatibleHost => "blocked_incompatible_host", + PluginActivationStatus::BlockedSlotClaimConflict => "blocked_slot_claim_conflict", + PluginActivationStatus::Unknown => "unknown", + } +} + +fn bootstrap_task_status_label(status: BootstrapTaskStatus) -> &'static str { + match status { + BootstrapTaskStatus::Applied => "applied", + BootstrapTaskStatus::DeferredUnsupportedAutoApply => "deferred_unsupported_auto_apply", + BootstrapTaskStatus::SkippedNotReady => "skipped_not_ready", + BootstrapTaskStatus::SkippedByPolicyLimit => "skipped_by_policy_limit", + } +} + +fn format_string_list_or_dash(values: &[String]) -> String { + if values.is_empty() { + return "-".to_owned(); + } + + values.join(",") +} + +fn sanitize_summary_field(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn run_spec_bridge_support_selection( + args: &RunSpecBridgeSupportArgs, +) -> Option { + let selection = BridgeSupportSelectionInput { + path: args.bridge_support.clone(), + bundled_profile: args + .bridge_profile + .map(BridgeSupportProfileArg::as_str) + .map(str::to_owned), + delta_artifact: args.bridge_support_delta.clone(), + expected_sha256: args.bridge_support_sha256.clone(), + expected_delta_sha256: args.bridge_support_delta_sha256.clone(), + }; + (selection.path.is_some() + || selection.bundled_profile.is_some() + || selection.delta_artifact.is_some()) + .then_some(selection) +} + +#[derive(Debug, Clone, Deserialize)] +struct RunnerSpecFileInput { + #[serde(flatten)] + spec: RunnerSpec, + #[serde(default)] + bridge_support_selection: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BridgeSupportSelectionInput { + #[serde(default)] + pub path: Option, + #[serde(default)] + pub bundled_profile: Option, + #[serde(default)] + pub delta_artifact: Option, + #[serde(default)] + pub expected_sha256: Option, + #[serde(default)] + pub expected_delta_sha256: Option, +} + +#[derive(Debug, Clone)] +pub struct ResolvedRunnerSpecFile { + pub spec: RunnerSpec, + pub bridge_support_source: Option, + pub bridge_support_delta_source: Option, + pub bridge_support_delta_sha256: Option, +} + +pub fn run_validate_config_cli( + config_path: Option<&str>, + as_json: bool, + output: Option, + locale: &str, + fail_on_diagnostics: bool, +) -> CliResult<()> { + let output = resolve_validate_output(as_json, output)?; + let normalized_locale = mvp::config::normalize_validation_locale(locale); + let supported_locales = mvp::config::supported_validation_locales(); + let (resolved_path, diagnostics) = + mvp::config::validate_file_with_locale(config_path, &normalized_locale)?; + let diagnostics_count = diagnostics.len(); + let diagnostics_summary = summarize_validation_diagnostics(&diagnostics); + + match output { + ValidateConfigOutput::Text => { + if diagnostics.is_empty() { + println!("config={} valid=true", resolved_path.display()); + } else { + println!( + "config={} valid={} diagnostics={} errors={} warnings={}", + resolved_path.display(), + diagnostics_summary.valid, + diagnostics_count, + diagnostics_summary.error_count, + diagnostics_summary.warning_count, + ); + for diagnostic in &diagnostics { + println!("{}", diagnostic.message); + } + } + } + ValidateConfigOutput::Json => { + let payload = json!({ + "diagnostics_schema_version": 1, + "config": resolved_path.display().to_string(), + "valid": diagnostics_summary.valid, + "error_count": diagnostics_summary.error_count, + "warning_count": diagnostics_summary.warning_count, + "locale": normalized_locale, + "supported_locales": supported_locales.clone(), + "diagnostics": diagnostics, + }); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize config validation output failed: {error}"))?; + println!("{pretty}"); + } + ValidateConfigOutput::ProblemJson => { + let payload = if diagnostics.is_empty() { + json!({ + "type": "urn:loongclaw:problem:none", + "title": "Configuration Valid", + "detail": "No configuration diagnostics were reported.", + "instance": resolved_path.display().to_string(), + "valid": true, + "error_count": 0, + "warning_count": 0, + "locale": normalized_locale, + "supported_locales": supported_locales.clone(), + "diagnostics_schema_version": 1, + "errors": [], + }) + } else { + json!({ + "type": if diagnostics_summary.valid { + "urn:loongclaw:problem:config.validation_warning" + } else { + "urn:loongclaw:problem:config.validation_failed" + }, + "title": if diagnostics_summary.valid { + "Configuration Warnings Reported" + } else { + "Configuration Validation Failed" + }, + "detail": format!("{} configuration diagnostic(s) were reported.", diagnostics_count), + "instance": resolved_path.display().to_string(), + "valid": diagnostics_summary.valid, + "error_count": diagnostics_summary.error_count, + "warning_count": diagnostics_summary.warning_count, + "locale": normalized_locale, + "supported_locales": supported_locales.clone(), + "diagnostics_schema_version": 1, + "errors": diagnostics, + }) + }; + let pretty = serde_json::to_string_pretty(&payload).map_err(|error| { + format!("serialize config validation problem output failed: {error}") + })?; + println!("{pretty}"); + } + } + + if fail_on_diagnostics && diagnostics_count > 0 { + return Err(format!( + "config validation failed with {diagnostics_count} diagnostic(s)" + )); + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidationDiagnosticSummary { + pub valid: bool, + pub error_count: usize, + pub warning_count: usize, +} + +pub fn summarize_validation_diagnostics( + diagnostics: &[mvp::config::ConfigValidationDiagnostic], +) -> ValidationDiagnosticSummary { + let error_count = diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == "error") + .count(); + let warning_count = diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == "warn") + .count(); + ValidationDiagnosticSummary { + valid: error_count == 0, + error_count, + warning_count, + } +} + +pub fn resolve_validate_output( + as_json: bool, + output: Option, +) -> CliResult { + if as_json && output.is_some() { + return Err( + "validate-config: `--json` conflicts with `--output`; use one of them".to_owned(), + ); + } + if as_json { + return Ok(ValidateConfigOutput::Json); + } + Ok(output.unwrap_or(ValidateConfigOutput::Text)) +} + +pub async fn run_list_models_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let models = mvp::provider::fetch_available_models(&config).await?; + if as_json { + let payload = json!({ + "config": resolved_path.display().to_string(), + "provider_kind": config.provider.kind, + "models_endpoint": config.provider.models_endpoint(), + "models": models, + }); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize model-list output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!( + "config={} provider_kind={:?} models_endpoint={}", + resolved_path.display(), + config.provider.kind, + config.provider.models_endpoint() + ); + for model in models { + println!("{model}"); + } + Ok(()) +} + +pub const RUNTIME_SNAPSHOT_CLI_JSON_SCHEMA_VERSION: u32 = 1; +pub const RUNTIME_SNAPSHOT_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 2; +#[derive(Debug, Clone)] +pub struct RuntimeSnapshotCliState { + pub config: String, + pub provider: RuntimeSnapshotProviderState, + pub context_engine: mvp::conversation::ContextEngineRuntimeSnapshot, + pub memory_system: mvp::memory::MemorySystemRuntimeSnapshot, + pub acp: mvp::acp::AcpRuntimeSnapshot, + pub enabled_channel_ids: Vec, + pub enabled_service_channel_ids: Vec, + pub channels: mvp::channel::ChannelInventory, + pub tool_runtime: mvp::tools::runtime_config::ToolRuntimeConfig, + pub visible_tool_names: Vec, + pub capability_snapshot: String, + pub capability_snapshot_sha256: String, + pub tool_calling: RuntimeSnapshotToolCallingState, + pub runtime_plugins: RuntimeSnapshotRuntimePluginsState, + pub external_skills: RuntimeSnapshotExternalSkillsState, + pub restore_spec: RuntimeSnapshotRestoreSpec, +} + +#[derive(Debug, Clone)] +pub struct RuntimeSnapshotProviderState { + pub active_profile_id: String, + pub active_label: String, + pub last_provider_id: Option, + pub saved_profile_ids: Vec, + pub profiles: Vec, +} + +#[derive(Debug, Clone)] +pub struct RuntimeSnapshotProviderProfileState { + pub profile_id: String, + pub is_active: bool, + pub default_for_kind: bool, + pub descriptor: mvp::config::ProviderDescriptorDocument, + pub kind: mvp::config::ProviderKind, + pub model: String, + pub wire_api: mvp::config::ProviderWireApi, + pub base_url: String, + pub endpoint: String, + pub models_endpoint: String, + pub protocol_family: &'static str, + pub credential_resolved: bool, + pub auth_env: Option, + pub reasoning_effort: Option, + pub temperature: f64, + pub max_tokens: Option, + pub request_timeout_ms: u64, + pub retry_max_attempts: usize, + pub header_names: Vec, + pub preferred_models: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeSnapshotInventoryStatus { + Ok, + Disabled, + Error, +} + +impl RuntimeSnapshotInventoryStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Disabled => "disabled", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone)] +pub struct RuntimeSnapshotExternalSkillsState { + pub policy: mvp::tools::runtime_config::ExternalSkillsRuntimePolicy, + pub override_active: bool, + pub inventory_status: RuntimeSnapshotInventoryStatus, + pub inventory_error: Option, + pub inventory: Value, + pub resolved_skill_count: usize, + pub shadowed_skill_count: usize, +} + +#[derive(Debug, Clone)] +pub struct RuntimeSnapshotRuntimePluginsState { + pub enabled: bool, + pub roots: Vec, + pub supported_bridges: Vec, + pub supported_adapter_families: Vec, + pub inventory_status: RuntimeSnapshotInventoryStatus, + pub inventory_error: Option, + pub readiness_evaluation: String, + pub scanned_root_count: usize, + pub scanned_file_count: usize, + pub discovered_plugin_count: usize, + pub translated_plugin_count: usize, + pub ready_plugin_count: usize, + pub setup_incomplete_plugin_count: usize, + pub blocked_plugin_count: usize, + pub plugins: Vec, +} + +#[derive(Debug, Clone)] +pub struct RuntimeSnapshotRuntimePluginState { + pub plugin_id: String, + pub provider_id: String, + pub connector_name: String, + pub source_path: String, + pub source_kind: String, + pub package_root: String, + pub package_manifest_path: Option, + pub bridge_kind: String, + pub adapter_family: String, + pub setup_mode: Option, + pub setup_surface: Option, + pub slot_claims: Vec, + pub conflicting_slot_claims: Vec, + pub status: String, + pub reason: String, + pub missing_required_env_vars: Vec, + pub missing_required_config_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeSnapshotArtifactMetadata { + pub created_at: String, + pub label: Option, + pub experiment_id: Option, + pub parent_snapshot_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeSnapshotArtifactLineage { + pub snapshot_id: String, + pub created_at: String, + pub label: Option, + pub experiment_id: Option, + pub parent_snapshot_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeSnapshotRestoreSpec { + pub provider: RuntimeSnapshotRestoreProviderSpec, + pub conversation: mvp::config::ConversationConfig, + pub memory: mvp::config::MemoryConfig, + pub acp: mvp::config::AcpConfig, + pub tools: mvp::config::ToolConfig, + pub external_skills: mvp::config::ExternalSkillsConfig, + #[serde(default)] + pub runtime_plugins: mvp::config::RuntimePluginsConfig, + pub managed_skills: RuntimeSnapshotRestoreManagedSkillsSpec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeSnapshotRestoreProviderSpec { + pub active_provider: Option, + pub last_provider: Option, + pub profiles: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct RuntimeSnapshotRestoreManagedSkillsSpec { + pub skills: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeSnapshotRestoreManagedSkillSpec { + pub skill_id: String, + pub display_name: String, + pub summary: String, + pub source_kind: String, + pub source_path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeSnapshotArtifactSchema { + pub version: u32, + pub surface: String, + pub purpose: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeSnapshotArtifactDocument { + pub config: String, + pub schema: RuntimeSnapshotArtifactSchema, + pub lineage: RuntimeSnapshotArtifactLineage, + pub provider: Value, + pub context_engine: Value, + pub memory_system: Value, + pub acp: Value, + pub channels: Value, + pub tool_runtime: Value, + pub tools: Value, + #[serde(default)] + pub runtime_plugins: Value, + pub external_skills: Value, + pub restore_spec: RuntimeSnapshotRestoreSpec, +} + +pub fn run_runtime_snapshot_cli( + config_path: Option<&str>, + as_json: bool, + output_path: Option<&str>, + label: Option<&str>, + experiment_id: Option<&str>, + parent_snapshot_id: Option<&str>, +) -> CliResult<()> { + let snapshot = collect_runtime_snapshot_cli_state(config_path)?; + let metadata = + runtime_snapshot_artifact_metadata_now(label, experiment_id, parent_snapshot_id)?; + let artifact_payload = build_runtime_snapshot_artifact_json_payload(&snapshot, &metadata)?; + + if let Some(output_path) = output_path { + persist_json_artifact(output_path, &artifact_payload, "runtime snapshot artifact")?; + } + + if as_json { + let pretty = serde_json::to_string_pretty(&artifact_payload).map_err(|error| { + format!("serialize runtime snapshot artifact output failed: {error}") + })?; + println!("{pretty}"); + return Ok(()); + } + + println!( + "{}", + render_runtime_snapshot_artifact_text(&snapshot, &artifact_payload) + ); + Ok(()) +} + +pub fn collect_runtime_snapshot_cli_state( + config_path: Option<&str>, +) -> CliResult { + let (resolved_path, config) = mvp::config::load(config_path)?; + collect_runtime_snapshot_cli_state_from_parts(resolved_path.as_path(), &config) +} + +pub(crate) fn collect_runtime_snapshot_cli_state_from_loaded_config( + loaded_config: &supervisor::LoadedSupervisorConfig, +) -> CliResult { + let resolved_path = loaded_config.resolved_path.as_path(); + let config = &loaded_config.config; + collect_runtime_snapshot_cli_state_from_parts(resolved_path, config) +} + +fn collect_runtime_snapshot_cli_state_from_parts( + resolved_path: &Path, + config: &mvp::config::LoongClawConfig, +) -> CliResult { + let config_display = resolved_path.display().to_string(); + let provider = collect_runtime_snapshot_provider_state(config); + let context_engine = mvp::conversation::collect_context_engine_runtime_snapshot(config)?; + let memory_system = mvp::memory::collect_memory_system_runtime_snapshot(config)?; + let acp = mvp::acp::collect_acp_runtime_snapshot(config)?; + let enabled_channel_ids = config.enabled_channel_ids(); + let enabled_service_channel_ids = config.enabled_service_channel_ids(); + let channels = mvp::channel::channel_inventory(config); + let tool_runtime = mvp::tools::runtime_config::ToolRuntimeConfig::from_loongclaw_config( + config, + Some(resolved_path), + ); + let (external_skills, snapshot_tool_runtime) = + collect_runtime_snapshot_external_skills_state(&tool_runtime); + let tool_view = mvp::tools::runtime_tool_view_for_runtime_config(&snapshot_tool_runtime); + let visible_tools = tool_view + .tool_names() + .map(str::to_owned) + .collect::>(); + let capability_snapshot = mvp::tools::capability_snapshot_with_config(&snapshot_tool_runtime); + let capability_snapshot_sha256 = + runtime_snapshot_tool_digest(&visible_tools, &capability_snapshot)?; + let tool_calling = collect_runtime_snapshot_tool_calling_state(config, visible_tools.len()); + let runtime_plugins = collect_runtime_snapshot_runtime_plugins_state(config); + let restore_spec = build_runtime_snapshot_restore_spec(config, &external_skills); + Ok(RuntimeSnapshotCliState { + config: config_display, + provider, + context_engine, + memory_system, + acp, + enabled_channel_ids, + enabled_service_channel_ids, + channels, + tool_runtime: snapshot_tool_runtime, + visible_tool_names: visible_tools, + capability_snapshot, + capability_snapshot_sha256, + tool_calling, + runtime_plugins, + external_skills, + restore_spec, + }) +} + +fn collect_runtime_snapshot_provider_state( + config: &mvp::config::LoongClawConfig, +) -> RuntimeSnapshotProviderState { + let active_profile_id = config + .active_provider_id() + .unwrap_or(config.provider.kind.profile().id) + .to_owned(); + let saved_profile_ids = provider_presentation::saved_provider_profile_ids(config); + let profiles = if config.providers.is_empty() { + vec![build_runtime_snapshot_provider_profile_state( + active_profile_id.as_str(), + &mvp::config::ProviderProfileConfig { + default_for_kind: true, + provider: config.provider.clone(), + }, + true, + )] + } else { + saved_profile_ids + .iter() + .filter_map(|profile_id| { + config.providers.get(profile_id).map(|profile| { + build_runtime_snapshot_provider_profile_state( + profile_id, + profile, + profile_id == &active_profile_id, + ) + }) + }) + .collect::>() + }; + + RuntimeSnapshotProviderState { + active_profile_id, + active_label: provider_presentation::active_provider_detail_label(config), + last_provider_id: config.last_provider_id().map(str::to_owned), + saved_profile_ids, + profiles, + } +} + +fn build_runtime_snapshot_provider_profile_state( + profile_id: &str, + profile: &mvp::config::ProviderProfileConfig, + is_active: bool, +) -> RuntimeSnapshotProviderProfileState { + let provider = &profile.provider; + let descriptor = provider.descriptor_document(); + let mut header_names = provider.headers.keys().cloned().collect::>(); + header_names.sort(); + + RuntimeSnapshotProviderProfileState { + profile_id: profile_id.to_owned(), + is_active, + default_for_kind: profile.default_for_kind, + descriptor, + kind: provider.kind, + model: provider.model.clone(), + wire_api: provider.wire_api, + base_url: provider.resolved_base_url(), + endpoint: provider.endpoint(), + models_endpoint: provider.models_endpoint(), + protocol_family: provider.kind.profile().protocol_family.as_str(), + credential_resolved: runtime_snapshot_provider_credentials_resolved(provider), + auth_env: provider.resolved_auth_env_name(), + reasoning_effort: provider + .reasoning_effort + .map(|value| value.as_str().to_owned()), + temperature: provider.temperature, + max_tokens: provider.max_tokens, + request_timeout_ms: provider.request_timeout_ms, + retry_max_attempts: provider.retry_max_attempts, + header_names, + preferred_models: provider.preferred_models.clone(), + } +} + +fn runtime_snapshot_provider_credentials_resolved(provider: &mvp::config::ProviderConfig) -> bool { + provider_credential_policy::provider_has_locally_available_credentials(provider) +} + +fn collect_runtime_snapshot_external_skills_state( + tool_runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, +) -> ( + RuntimeSnapshotExternalSkillsState, + mvp::tools::runtime_config::ToolRuntimeConfig, +) { + let empty_inventory = json!({ + "skills": [], + "shadowed_skills": [], + }); + + let (effective_policy, override_active) = + match runtime_snapshot_effective_external_skills_policy(tool_runtime) { + Ok(policy_state) => policy_state, + Err(error) => { + return ( + RuntimeSnapshotExternalSkillsState { + policy: tool_runtime.external_skills.clone(), + override_active: false, + inventory_status: RuntimeSnapshotInventoryStatus::Error, + inventory_error: Some(error.clone()), + inventory: json!({ + "skills": [], + "shadowed_skills": [], + "error": error, + }), + resolved_skill_count: 0, + shadowed_skill_count: 0, + }, + tool_runtime.clone(), + ); + } + }; + + let mut effective_tool_runtime = tool_runtime.clone(); + effective_tool_runtime.external_skills = effective_policy.clone(); + + if !effective_policy.enabled { + return ( + RuntimeSnapshotExternalSkillsState { + policy: effective_policy, + override_active, + inventory_status: RuntimeSnapshotInventoryStatus::Disabled, + inventory_error: None, + inventory: empty_inventory, + resolved_skill_count: 0, + shadowed_skill_count: 0, + }, + effective_tool_runtime, + ); + } + + match mvp::tools::execute_tool_core_with_config( + ToolCoreRequest { + tool_name: "external_skills.list".to_owned(), + payload: json!({}), + }, + &effective_tool_runtime, + ) { + Ok(outcome) => ( + RuntimeSnapshotExternalSkillsState { + policy: effective_policy, + override_active, + inventory_status: RuntimeSnapshotInventoryStatus::Ok, + inventory_error: None, + resolved_skill_count: json_array_len(outcome.payload.get("skills")), + shadowed_skill_count: json_array_len(outcome.payload.get("shadowed_skills")), + inventory: outcome.payload, + }, + effective_tool_runtime, + ), + Err(error) => ( + RuntimeSnapshotExternalSkillsState { + policy: effective_policy, + override_active, + inventory_status: RuntimeSnapshotInventoryStatus::Error, + inventory_error: Some(error.clone()), + inventory: json!({ + "skills": [], + "shadowed_skills": [], + "error": error, + }), + resolved_skill_count: 0, + shadowed_skill_count: 0, + }, + effective_tool_runtime, + ), + } +} + +pub(crate) fn collect_runtime_snapshot_runtime_plugins_state( + config: &mvp::config::LoongClawConfig, +) -> RuntimeSnapshotRuntimePluginsState { + let readiness_evaluation = config + .runtime_plugins + .readiness_evaluation_label() + .to_owned(); + let roots = config + .runtime_plugins + .resolved_roots() + .into_iter() + .map(|root| root.display().to_string()) + .collect::>(); + let supported_bridges = config + .runtime_plugins + .resolved_supported_bridges() + .unwrap_or_default() + .into_iter() + .map(|bridge_kind| bridge_kind.as_str().to_owned()) + .collect::>(); + let supported_adapter_families = config + .runtime_plugins + .normalized_supported_adapter_families(); + + if !config.runtime_plugins.enabled { + return RuntimeSnapshotRuntimePluginsState { + enabled: false, + roots, + supported_bridges, + supported_adapter_families, + inventory_status: RuntimeSnapshotInventoryStatus::Disabled, + inventory_error: None, + readiness_evaluation, + scanned_root_count: 0, + scanned_file_count: 0, + discovered_plugin_count: 0, + translated_plugin_count: 0, + ready_plugin_count: 0, + setup_incomplete_plugin_count: 0, + blocked_plugin_count: 0, + plugins: Vec::new(), + }; + } + + let resolved_roots = config.runtime_plugins.resolved_roots(); + if resolved_roots.is_empty() { + return RuntimeSnapshotRuntimePluginsState { + enabled: true, + roots, + supported_bridges, + supported_adapter_families, + inventory_status: RuntimeSnapshotInventoryStatus::Error, + inventory_error: Some( + "runtime_plugins.enabled=true but no runtime plugin roots are configured" + .to_owned(), + ), + readiness_evaluation, + scanned_root_count: 0, + scanned_file_count: 0, + discovered_plugin_count: 0, + translated_plugin_count: 0, + ready_plugin_count: 0, + setup_incomplete_plugin_count: 0, + blocked_plugin_count: 0, + plugins: Vec::new(), + }; + } + + let scanner = PluginScanner::new(); + let mut combined = kernel::PluginScanReport::default(); + for root in &resolved_roots { + let report = match scanner.scan_path(root) { + Ok(report) => report, + Err(error) => { + return RuntimeSnapshotRuntimePluginsState { + enabled: true, + roots, + supported_bridges, + supported_adapter_families, + inventory_status: RuntimeSnapshotInventoryStatus::Error, + inventory_error: Some(format!( + "runtime plugin scan failed for {}: {error}", + root.display() + )), + readiness_evaluation, + scanned_root_count: 0, + scanned_file_count: 0, + discovered_plugin_count: 0, + translated_plugin_count: 0, + ready_plugin_count: 0, + setup_incomplete_plugin_count: 0, + blocked_plugin_count: 0, + plugins: Vec::new(), + }; + } + }; + merge_plugin_scan_report(&mut combined, report); + } + + let bridge_matrix = match config.runtime_plugins.resolved_bridge_support_matrix() { + Ok(matrix) => matrix, + Err(error) => { + return RuntimeSnapshotRuntimePluginsState { + enabled: true, + roots, + supported_bridges, + supported_adapter_families, + inventory_status: RuntimeSnapshotInventoryStatus::Error, + inventory_error: Some(error), + readiness_evaluation, + scanned_root_count: resolved_roots.len(), + scanned_file_count: combined.scanned_files, + discovered_plugin_count: combined.matched_plugins, + translated_plugin_count: 0, + ready_plugin_count: 0, + setup_incomplete_plugin_count: 0, + blocked_plugin_count: 0, + plugins: Vec::new(), + }; + } + }; + + let translator = PluginTranslator::new(); + let translation = translator.translate_scan_report(&combined); + let readiness_context = runtime_plugin_setup_readiness_context(config); + let activation = translator.plan_activation(&translation, &bridge_matrix, &readiness_context); + let inventory_entries = activation.inventory_entries(&translation); + let inventory_by_key = inventory_entries + .into_iter() + .map(|entry| ((entry.source_path.clone(), entry.plugin_id.clone()), entry)) + .collect::>(); + + let plugins = translation + .entries + .iter() + .map(|entry| { + let entry_key = (entry.source_path.clone(), entry.plugin_id.clone()); + let inventory_entry = inventory_by_key.get(&entry_key); + let setup_mode = entry + .setup + .as_ref() + .map(|setup| setup.mode.as_str().to_owned()); + let setup_surface = entry.setup.as_ref().and_then(|setup| setup.surface.clone()); + let setup_requirements = evaluate_plugin_setup_requirements( + entry + .setup + .as_ref() + .map(|setup| setup.required_env_vars.as_slice()) + .unwrap_or(&[]), + entry + .setup + .as_ref() + .map(|setup| setup.required_config_keys.as_slice()) + .unwrap_or(&[]), + &readiness_context, + ); + let activation_status = inventory_entry.and_then(|item| item.activation_status); + let slot_claims = entry + .slot_claims + .iter() + .map(kernel::PluginSlotClaim::canonical_label) + .collect::>(); + let conflicting_slot_claims = if matches!( + activation_status, + Some(PluginActivationStatus::BlockedSlotClaimConflict) + ) { + slot_claims.clone() + } else { + Vec::new() + }; + let status = activation_status + .map(runtime_plugin_activation_status) + .unwrap_or("unknown") + .to_owned(); + let reason = inventory_entry + .and_then(|item| item.activation_reason.clone()) + .unwrap_or_else(|| "-".to_owned()); + let missing_required_env_vars = if matches!( + activation_status, + Some(PluginActivationStatus::SetupIncomplete) + ) { + setup_requirements.missing_required_env_vars + } else { + Vec::new() + }; + let missing_required_config_keys = if matches!( + activation_status, + Some(PluginActivationStatus::SetupIncomplete) + ) { + setup_requirements.missing_required_config_keys + } else { + Vec::new() + }; + + RuntimeSnapshotRuntimePluginState { + plugin_id: entry.plugin_id.clone(), + provider_id: entry.provider_id.clone(), + connector_name: entry.connector_name.clone(), + source_path: entry.source_path.clone(), + source_kind: entry.source_kind.as_str().to_owned(), + package_root: entry.package_root.clone(), + package_manifest_path: entry.package_manifest_path.clone(), + bridge_kind: entry.runtime.bridge_kind.as_str().to_owned(), + adapter_family: entry.runtime.adapter_family.clone(), + setup_mode, + setup_surface, + slot_claims, + conflicting_slot_claims, + status, + reason, + missing_required_env_vars, + missing_required_config_keys, + } + }) + .collect::>(); + + RuntimeSnapshotRuntimePluginsState { + enabled: true, + roots, + supported_bridges, + supported_adapter_families, + inventory_status: RuntimeSnapshotInventoryStatus::Ok, + inventory_error: None, + readiness_evaluation, + scanned_root_count: resolved_roots.len(), + scanned_file_count: combined.scanned_files, + discovered_plugin_count: combined.matched_plugins, + translated_plugin_count: translation.translated_plugins, + ready_plugin_count: activation.ready_plugins, + setup_incomplete_plugin_count: activation.setup_incomplete_plugins, + blocked_plugin_count: activation.blocked_plugins, + plugins, + } +} + +fn merge_plugin_scan_report( + combined: &mut kernel::PluginScanReport, + report: kernel::PluginScanReport, +) { + let kernel::PluginScanReport { + scanned_files, + matched_plugins, + descriptors, + diagnostic_findings, + } = report; + + combined.scanned_files += scanned_files; + combined.matched_plugins += matched_plugins; + combined.descriptors.extend(descriptors); + combined.diagnostic_findings.extend(diagnostic_findings); +} + +fn runtime_plugin_setup_readiness_context( + config: &mvp::config::LoongClawConfig, +) -> PluginSetupReadinessContext { + let verified_env_vars = std::env::vars_os() + .filter_map(|(key, value)| { + let value_string = value.to_string_lossy(); + let trimmed_value = value_string.trim(); + if trimmed_value.is_empty() { + return None; + } + + Some(key.to_string_lossy().to_string()) + }) + .collect(); + let mut verified_config_keys = BTreeSet::new(); + if let Ok(value) = serde_json::to_value(config) { + collect_config_paths(&value, None, &mut verified_config_keys); + } + + PluginSetupReadinessContext { + verified_env_vars, + verified_config_keys, + } +} + +fn collect_config_paths(value: &Value, prefix: Option<&str>, out: &mut BTreeSet) { + match value { + Value::Object(map) => { + for (key, child) in map { + let next_prefix = match prefix { + Some(prefix) => format!("{prefix}.{key}"), + None => key.clone(), + }; + + match child { + Value::Null => {} + Value::Object(_) + | Value::Array(_) + | Value::Bool(_) + | Value::Number(_) + | Value::String(_) => { + out.insert(next_prefix.clone()); + collect_config_paths(child, Some(next_prefix.as_str()), out); + } + } + } + } + Value::Array(items) => { + for child in items { + collect_config_paths(child, prefix, out); + } + } + Value::Null => {} + Value::Bool(_) | Value::Number(_) | Value::String(_) => { + if let Some(prefix) = prefix { + out.insert(prefix.to_owned()); + } + } + } +} + +fn runtime_snapshot_effective_external_skills_policy( + tool_runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, +) -> Result< + ( + mvp::tools::runtime_config::ExternalSkillsRuntimePolicy, + bool, + ), + String, +> { + let outcome = mvp::tools::execute_tool_core_with_config( + ToolCoreRequest { + tool_name: "external_skills.policy".to_owned(), + payload: json!({ + "action": "get", + }), + }, + tool_runtime, + ) + .map_err(|error| format!("resolve effective external skills policy failed: {error}"))?; + + let policy = runtime_snapshot_external_skills_policy_from_payload(&outcome.payload)?; + let override_active = outcome + .payload + .get("override_active") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok((policy, override_active)) +} + +fn runtime_snapshot_external_skills_policy_from_payload( + payload: &Value, +) -> Result { + let policy = payload + .get("policy") + .and_then(Value::as_object) + .ok_or_else(|| { + "runtime snapshot external skills policy payload missing `policy`".to_owned() + })?; + + Ok(mvp::tools::runtime_config::ExternalSkillsRuntimePolicy { + enabled: policy + .get("enabled") + .and_then(Value::as_bool) + .ok_or_else(|| { + "runtime snapshot external skills policy missing `enabled`".to_owned() + })?, + require_download_approval: policy + .get("require_download_approval") + .and_then(Value::as_bool) + .ok_or_else(|| { + "runtime snapshot external skills policy missing `require_download_approval`" + .to_owned() + })?, + allowed_domains: json_string_array_to_set( + policy.get("allowed_domains"), + "runtime snapshot external skills policy.allowed_domains", + )?, + blocked_domains: json_string_array_to_set( + policy.get("blocked_domains"), + "runtime snapshot external skills policy.blocked_domains", + )?, + install_root: policy + .get("install_root") + .and_then(Value::as_str) + .map(Path::new) + .map(Path::to_path_buf), + auto_expose_installed: policy + .get("auto_expose_installed") + .and_then(Value::as_bool) + .ok_or_else(|| { + "runtime snapshot external skills policy missing `auto_expose_installed`".to_owned() + })?, + }) +} + +fn runtime_snapshot_tool_digest( + visible_tool_names: &[String], + capability_snapshot: &str, +) -> CliResult { + let serialized = serde_json::to_vec(&json!({ + "visible_tool_names": visible_tool_names, + "capability_snapshot": capability_snapshot, + })) + .map_err(|error| format!("serialize runtime snapshot tool digest input failed: {error}"))?; + Ok(hex::encode(Sha256::digest(serialized))) +} + +fn json_array_len(value: Option<&Value>) -> usize { + value.and_then(Value::as_array).map_or(0, Vec::len) +} + +fn runtime_plugin_activation_status(status: PluginActivationStatus) -> &'static str { + status.as_str() +} + +fn json_string_array_to_set( + value: Option<&Value>, + context: &str, +) -> Result, String> { + let items = value + .and_then(Value::as_array) + .ok_or_else(|| format!("{context} must be an array"))?; + items + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{context} must contain only strings")) + }) + .collect() +} + +fn build_runtime_snapshot_restore_spec( + config: &mvp::config::LoongClawConfig, + external_skills: &RuntimeSnapshotExternalSkillsState, +) -> RuntimeSnapshotRestoreSpec { + let mut warnings = Vec::new(); + let mut profiles = runtime_snapshot_restore_provider_profiles(config); + for (profile_id, profile) in &mut profiles { + normalize_runtime_snapshot_restore_provider_profile(profile_id, profile, &mut warnings); + } + + RuntimeSnapshotRestoreSpec { + provider: RuntimeSnapshotRestoreProviderSpec { + active_provider: config.active_provider_id().map(str::to_owned), + last_provider: config.last_provider_id().map(str::to_owned), + profiles, + }, + conversation: config.conversation.clone(), + memory: config.memory.clone(), + acp: config.acp.clone(), + tools: config.tools.clone(), + external_skills: config.external_skills.clone(), + runtime_plugins: config.runtime_plugins.clone(), + managed_skills: build_runtime_snapshot_restore_managed_skills_spec( + external_skills, + &mut warnings, + ), + warnings, + } +} + +fn runtime_snapshot_restore_provider_profiles( + config: &mvp::config::LoongClawConfig, +) -> BTreeMap { + if !config.providers.is_empty() { + return config.providers.clone(); + } + + let profile_id = config + .active_provider_id() + .unwrap_or(config.provider.kind.profile().id) + .to_owned(); + BTreeMap::from([( + profile_id, + mvp::config::ProviderProfileConfig { + default_for_kind: true, + provider: config.provider.clone(), + }, + )]) +} + +fn normalize_runtime_snapshot_restore_provider_profile( + profile_id: &str, + profile: &mut mvp::config::ProviderProfileConfig, + warnings: &mut Vec, +) { + runtime_snapshot_migrate_provider_env_reference( + &mut profile.provider.api_key, + &mut profile.provider.api_key_env, + ); + runtime_snapshot_migrate_provider_env_reference( + &mut profile.provider.oauth_access_token, + &mut profile.provider.oauth_access_token_env, + ); + + if runtime_snapshot_redact_provider_secret_field( + profile.provider.api_key.as_mut(), + profile_id, + "api_key", + warnings, + ) { + profile.provider.api_key = None; + } + if runtime_snapshot_redact_provider_secret_field( + profile.provider.oauth_access_token.as_mut(), + profile_id, + "oauth_access_token", + warnings, + ) { + profile.provider.oauth_access_token = None; + } + + let header_keys_to_remove = profile + .provider + .headers + .iter() + .filter(|(header_name, header_value)| { + !runtime_snapshot_provider_header_is_safe_to_persist( + profile.provider.kind, + header_name, + header_value, + ) + }) + .map(|(header_name, _)| header_name.clone()) + .collect::>(); + for header_name in header_keys_to_remove { + profile.provider.headers.remove(&header_name); + warnings.push(format!( + "restore spec redacted inline provider header `{header_name}` for profile `{profile_id}`" + )); + } +} + +fn runtime_snapshot_redact_provider_secret_field( + raw: Option<&mut SecretRef>, + profile_id: &str, + field_name: &str, + warnings: &mut Vec, +) -> bool { + let Some(raw) = raw else { + return false; + }; + if raw.inline_literal_value().is_none() { + return false; + } + warnings.push(format!( + "restore spec redacted inline provider credential `{field_name}` for profile `{profile_id}`" + )); + true +} + +fn runtime_snapshot_provider_header_is_safe_to_persist( + provider_kind: mvp::config::ProviderKind, + header_name: &str, + header_value: &str, +) -> bool { + if header_value.trim().is_empty() || runtime_snapshot_is_env_reference_literal(header_value) { + return true; + } + + let normalized = header_name.trim().to_ascii_lowercase(); + matches!( + normalized.as_str(), + "accept" + | "accept-charset" + | "accept-encoding" + | "accept-language" + | "anthropic-version" + | "cache-control" + | "content-language" + | "content-type" + | "pragma" + | "user-agent" + | "anthropic-beta" + | "openai-beta" + ) || provider_kind + .default_headers() + .iter() + .any(|(default_name, _)| default_name.eq_ignore_ascii_case(&normalized)) +} + +fn runtime_snapshot_migrate_provider_env_reference( + inline_secret: &mut Option, + env_name: &mut Option, +) { + let explicit_env_name = inline_secret + .as_ref() + .and_then(SecretRef::explicit_env_name); + if let Some(explicit_env_name) = explicit_env_name { + *inline_secret = Some(SecretRef::Env { + env: explicit_env_name, + }); + *env_name = None; + return; + } + + if inline_secret.as_ref().is_some_and(SecretRef::is_configured) { + *env_name = None; + return; + } + + let configured_env_name = env_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + if let Some(configured_env_name) = configured_env_name { + *inline_secret = Some(SecretRef::Env { + env: configured_env_name, + }); + } + *env_name = None; +} + +fn runtime_snapshot_is_env_reference_literal(raw: &str) -> bool { + runtime_snapshot_parse_env_reference(raw).is_some() +} + +fn runtime_snapshot_parse_env_reference(raw: &str) -> Option<&str> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if let Some(inner) = trimmed + .strip_prefix("${") + .and_then(|value| value.strip_suffix('}')) + { + return runtime_snapshot_is_valid_env_name(inner).then_some(inner); + } + + if let Some(inner) = trimmed.strip_prefix('$') { + return runtime_snapshot_is_valid_env_name(inner).then_some(inner); + } + + if let Some(inner) = trimmed.strip_prefix("env:") { + return runtime_snapshot_is_valid_env_name(inner).then_some(inner); + } + + if let Some(inner) = trimmed + .strip_prefix('%') + .and_then(|value| value.strip_suffix('%')) + { + return runtime_snapshot_is_valid_env_name(inner).then_some(inner); + } + + None +} + +fn runtime_snapshot_is_valid_env_name(raw: &str) -> bool { + let mut chars = raw.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn build_runtime_snapshot_restore_managed_skills_spec( + external_skills: &RuntimeSnapshotExternalSkillsState, + warnings: &mut Vec, +) -> RuntimeSnapshotRestoreManagedSkillsSpec { + match external_skills.inventory_status { + RuntimeSnapshotInventoryStatus::Disabled => { + warnings.push( + "restore spec could not enumerate managed external skills because runtime inventory is disabled" + .to_owned(), + ); + return RuntimeSnapshotRestoreManagedSkillsSpec::default(); + } + RuntimeSnapshotInventoryStatus::Error => { + warnings.push( + "restore spec could not enumerate managed external skills because runtime inventory collection failed" + .to_owned(), + ); + return RuntimeSnapshotRestoreManagedSkillsSpec::default(); + } + RuntimeSnapshotInventoryStatus::Ok => {} + } + + let Some(skills) = external_skills + .inventory + .get("skills") + .and_then(Value::as_array) + else { + return RuntimeSnapshotRestoreManagedSkillsSpec::default(); + }; + + let mut managed_skills = skills + .iter() + .filter(|skill| skill.get("scope").and_then(Value::as_str) == Some("managed")) + .filter_map(|skill| { + let skill_id = skill.get("skill_id").and_then(Value::as_str)?; + let display_name = skill + .get("display_name") + .and_then(Value::as_str) + .unwrap_or_default(); + let summary = skill + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default(); + let source_kind = skill.get("source_kind").and_then(Value::as_str)?; + let source_path = skill.get("source_path").and_then(Value::as_str)?; + let sha256 = skill.get("sha256").and_then(Value::as_str)?; + Some(RuntimeSnapshotRestoreManagedSkillSpec { + skill_id: skill_id.to_owned(), + display_name: display_name.to_owned(), + summary: summary.to_owned(), + source_kind: source_kind.to_owned(), + source_path: source_path.to_owned(), + sha256: sha256.to_owned(), + }) + }) + .collect::>(); + managed_skills.sort_by(|left, right| left.skill_id.cmp(&right.skill_id)); + RuntimeSnapshotRestoreManagedSkillsSpec { + skills: managed_skills, + } +} + +#[cfg(test)] +mod runtime_snapshot_restore_spec_tests { + use super::*; + use serde_json::json; + + #[test] + fn runtime_snapshot_restore_managed_skills_keeps_entries_without_display_metadata() { + let mut warnings = Vec::new(); + let spec = build_runtime_snapshot_restore_managed_skills_spec( + &RuntimeSnapshotExternalSkillsState { + policy: mvp::tools::runtime_config::ExternalSkillsRuntimePolicy::default(), + override_active: false, + inventory_status: RuntimeSnapshotInventoryStatus::Ok, + inventory_error: None, + inventory: json!({ + "skills": [{ + "scope": "managed", + "skill_id": "demo-skill", + "source_kind": "directory", + "source_path": "/tmp/demo-skill", + "sha256": "deadbeef" + }] + }), + resolved_skill_count: 1, + shadowed_skill_count: 0, + }, + &mut warnings, + ); + + assert!(warnings.is_empty()); + assert_eq!(spec.skills.len(), 1); + assert_eq!(spec.skills[0].skill_id, "demo-skill"); + assert!(spec.skills[0].display_name.is_empty()); + assert!(spec.skills[0].summary.is_empty()); + } + + #[test] + fn runtime_snapshot_provider_header_safety_uses_explicit_safe_names_only() { + assert!(runtime_snapshot_provider_header_is_safe_to_persist( + mvp::config::ProviderKind::Anthropic, + "anthropic-version", + "2023-06-01", + )); + assert!(runtime_snapshot_provider_header_is_safe_to_persist( + mvp::config::ProviderKind::Deepseek, + "anthropic-version", + "2023-06-01", + )); + assert!(runtime_snapshot_provider_header_is_safe_to_persist( + mvp::config::ProviderKind::Anthropic, + "anthropic-beta", + "prompt-caching-2024-07-31", + )); + assert!(runtime_snapshot_provider_header_is_safe_to_persist( + mvp::config::ProviderKind::Openai, + "openai-beta", + "assistants=v2", + )); + assert!(runtime_snapshot_provider_header_is_safe_to_persist( + mvp::config::ProviderKind::Deepseek, + "x-goog-api-key", + "${GOOGLE_API_KEY}", + )); + assert!(!runtime_snapshot_provider_header_is_safe_to_persist( + mvp::config::ProviderKind::Deepseek, + "x-secret-beta", + "literal-secret", + )); + assert!(!runtime_snapshot_provider_header_is_safe_to_persist( + mvp::config::ProviderKind::Deepseek, + "x-secret-version", + "literal-secret", + )); + } + + #[test] + fn runtime_snapshot_restore_normalization_moves_provider_env_name_fields_into_secret_refs() { + let mut warnings = Vec::new(); + let mut profile = mvp::config::ProviderProfileConfig { + default_for_kind: true, + provider: mvp::config::ProviderConfig { + kind: mvp::config::ProviderKind::Openai, + model: "openai/gpt-5.1-codex".to_owned(), + api_key_env: Some("OPENAI_API_KEY".to_owned()), + oauth_access_token_env: Some("OPENAI_CODEX_OAUTH_TOKEN".to_owned()), + ..Default::default() + }, + }; + + normalize_runtime_snapshot_restore_provider_profile( + "openai-main", + &mut profile, + &mut warnings, + ); + + assert_eq!( + profile.provider.api_key, + Some(SecretRef::Env { + env: "OPENAI_API_KEY".to_owned(), + }) + ); + assert_eq!(profile.provider.api_key_env, None); + assert_eq!( + profile.provider.oauth_access_token, + Some(SecretRef::Env { + env: "OPENAI_CODEX_OAUTH_TOKEN".to_owned(), + }) + ); + assert_eq!(profile.provider.oauth_access_token_env, None); + assert!(warnings.is_empty()); + } + + #[test] + fn runtime_snapshot_restore_normalization_canonicalizes_matching_explicit_env_reference() { + let mut warnings = Vec::new(); + let mut profile = mvp::config::ProviderProfileConfig { + default_for_kind: true, + provider: mvp::config::ProviderConfig { + kind: mvp::config::ProviderKind::Openai, + model: "openai/gpt-5.1-codex".to_owned(), + api_key: Some(SecretRef::Inline("${INLINE_OPENAI_API_KEY}".to_owned())), + api_key_env: Some(" INLINE_OPENAI_API_KEY ".to_owned()), + oauth_access_token: Some(SecretRef::Inline( + "$INLINE_OPENAI_OAUTH_TOKEN".to_owned(), + )), + oauth_access_token_env: Some("INLINE_OPENAI_OAUTH_TOKEN".to_owned()), + ..Default::default() + }, + }; + + normalize_runtime_snapshot_restore_provider_profile( + "openai-main", + &mut profile, + &mut warnings, + ); + + assert_eq!( + profile.provider.api_key, + Some(SecretRef::Env { + env: "INLINE_OPENAI_API_KEY".to_owned(), + }) + ); + assert_eq!(profile.provider.api_key_env, None); + assert_eq!( + profile.provider.oauth_access_token, + Some(SecretRef::Env { + env: "INLINE_OPENAI_OAUTH_TOKEN".to_owned(), + }) + ); + assert_eq!(profile.provider.oauth_access_token_env, None); + assert!(warnings.is_empty()); + } + + #[test] + fn runtime_snapshot_restore_normalization_prefers_explicit_env_reference_over_legacy_env_field() + { + let mut warnings = Vec::new(); + let mut profile = mvp::config::ProviderProfileConfig { + default_for_kind: true, + provider: mvp::config::ProviderConfig { + kind: mvp::config::ProviderKind::Openai, + model: "openai/gpt-5.1-codex".to_owned(), + api_key: Some(SecretRef::Inline("${INLINE_OPENAI_API_KEY}".to_owned())), + api_key_env: Some("CONFIGURED_OPENAI_API_KEY".to_owned()), + oauth_access_token: Some(SecretRef::Inline( + "$INLINE_OPENAI_OAUTH_TOKEN".to_owned(), + )), + oauth_access_token_env: Some("CONFIGURED_OPENAI_OAUTH_TOKEN".to_owned()), + ..Default::default() + }, + }; + + normalize_runtime_snapshot_restore_provider_profile( + "openai-main", + &mut profile, + &mut warnings, + ); + + assert_eq!( + profile.provider.api_key, + Some(SecretRef::Env { + env: "INLINE_OPENAI_API_KEY".to_owned(), + }) + ); + assert_eq!(profile.provider.api_key_env, None); + assert_eq!( + profile.provider.oauth_access_token, + Some(SecretRef::Env { + env: "INLINE_OPENAI_OAUTH_TOKEN".to_owned(), + }) + ); + assert_eq!(profile.provider.oauth_access_token_env, None); + assert!(warnings.is_empty()); + } + + #[test] + fn runtime_snapshot_restore_normalization_treats_blank_inline_secret_as_absent() { + let mut warnings = Vec::new(); + let mut profile = mvp::config::ProviderProfileConfig { + default_for_kind: true, + provider: mvp::config::ProviderConfig { + kind: mvp::config::ProviderKind::Openai, + model: "openai/gpt-5.1-codex".to_owned(), + api_key: Some(SecretRef::Inline(" ".to_owned())), + api_key_env: Some("OPENAI_API_KEY".to_owned()), + oauth_access_token: Some(SecretRef::Inline(" ".to_owned())), + oauth_access_token_env: Some("OPENAI_CODEX_OAUTH_TOKEN".to_owned()), + ..Default::default() + }, + }; + + normalize_runtime_snapshot_restore_provider_profile( + "openai-main", + &mut profile, + &mut warnings, + ); + + assert_eq!( + profile.provider.api_key, + Some(SecretRef::Env { + env: "OPENAI_API_KEY".to_owned(), + }) + ); + assert_eq!(profile.provider.api_key_env, None); + assert_eq!( + profile.provider.oauth_access_token, + Some(SecretRef::Env { + env: "OPENAI_CODEX_OAUTH_TOKEN".to_owned(), + }) + ); + assert_eq!(profile.provider.oauth_access_token_env, None); + assert!(warnings.is_empty()); + } + + #[test] + fn runtime_snapshot_tool_runtime_json_reports_browser_execution_tiers() { + let mut runtime = mvp::tools::runtime_config::ToolRuntimeConfig::default(); + runtime.browser_companion.enabled = true; + runtime.browser_companion.ready = true; + runtime.browser_companion.command = Some("browser-companion".to_owned()); + + let json = runtime_snapshot_tool_runtime_json(&runtime); + + assert_eq!(json["browser"]["execution_tier"], json!("restricted")); + assert_eq!( + json["browser_companion"]["execution_tier"], + json!("balanced") + ); + } +} + +fn runtime_snapshot_artifact_metadata_now( + label: Option<&str>, + experiment_id: Option<&str>, + parent_snapshot_id: Option<&str>, +) -> CliResult { + let created_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| format!("format runtime snapshot artifact timestamp failed: {error}"))?; + Ok(RuntimeSnapshotArtifactMetadata { + created_at, + label: runtime_snapshot_optional_arg(label), + experiment_id: runtime_snapshot_optional_arg(experiment_id), + parent_snapshot_id: runtime_snapshot_optional_arg(parent_snapshot_id), + }) +} + +fn runtime_snapshot_optional_arg(raw: Option<&str>) -> Option { + raw.map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +pub(crate) fn persist_json_artifact( + output_path: &str, + payload: &Value, + artifact_label: &str, +) -> CliResult<()> { + let output_path = PathBuf::from(output_path); + let parent_path = output_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + fs::create_dir_all(&parent_path).map_err(|error| { + format!( + "create {artifact_label} directory {} failed: {error}", + parent_path.display() + ) + })?; + let encoded = serde_json::to_string_pretty(payload) + .map_err(|error| format!("serialize {artifact_label} failed: {error}"))?; + let file_name = output_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("artifact"); + let process_id = process::id(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("build {artifact_label} temp path failed: {error}"))? + .as_nanos(); + let temp_file_name = format!(".{file_name}.{process_id}.{timestamp}.tmp"); + let temp_path = parent_path.join(temp_file_name); + + let open_result = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path); + let mut temp_file = open_result.map_err(|error| { + format!( + "create {artifact_label} temp file {} failed: {error}", + temp_path.display() + ) + })?; + temp_file.write_all(encoded.as_bytes()).map_err(|error| { + format!( + "write {artifact_label} temp file {} failed: {error}", + temp_path.display() + ) + })?; + temp_file.sync_all().map_err(|error| { + format!( + "sync {artifact_label} temp file {} failed: {error}", + temp_path.display() + ) + })?; + drop(temp_file); + + let rename_result = fs::rename(&temp_path, &output_path); + if let Err(error) = rename_result { + let _ = fs::remove_file(&temp_path); + return Err(format!( + "replace {artifact_label} {} failed: {error}", + output_path.display() + )); + } + Ok(()) +} + +pub fn build_runtime_snapshot_artifact_json_payload( + snapshot: &RuntimeSnapshotCliState, + metadata: &RuntimeSnapshotArtifactMetadata, +) -> CliResult { + let base_payload = cli_json::build_runtime_snapshot_cli_json_payload(snapshot)?; + let lineage = runtime_snapshot_artifact_lineage(snapshot, metadata)?; + let document = RuntimeSnapshotArtifactDocument { + config: snapshot.config.clone(), + schema: RuntimeSnapshotArtifactSchema { + version: RUNTIME_SNAPSHOT_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "runtime_snapshot".to_owned(), + purpose: "experiment_reproducibility".to_owned(), + }, + lineage, + provider: base_payload.get("provider").cloned().unwrap_or(Value::Null), + context_engine: base_payload + .get("context_engine") + .cloned() + .unwrap_or(Value::Null), + memory_system: base_payload + .get("memory_system") + .cloned() + .unwrap_or(Value::Null), + acp: base_payload.get("acp").cloned().unwrap_or(Value::Null), + channels: base_payload.get("channels").cloned().unwrap_or(Value::Null), + tool_runtime: base_payload + .get("tool_runtime") + .cloned() + .unwrap_or(Value::Null), + tools: base_payload.get("tools").cloned().unwrap_or(Value::Null), + runtime_plugins: base_payload + .get("runtime_plugins") + .cloned() + .unwrap_or(Value::Null), + external_skills: base_payload + .get("external_skills") + .cloned() + .unwrap_or(Value::Null), + restore_spec: snapshot.restore_spec.clone(), + }; + serde_json::to_value(document) + .map_err(|error| format!("serialize runtime snapshot artifact payload failed: {error}")) +} + +fn runtime_snapshot_artifact_lineage( + snapshot: &RuntimeSnapshotCliState, + metadata: &RuntimeSnapshotArtifactMetadata, +) -> CliResult { + let serialized = serde_json::to_vec(&json!({ + "config": snapshot.config, + "created_at": metadata.created_at, + "label": metadata.label, + "experiment_id": metadata.experiment_id, + "parent_snapshot_id": metadata.parent_snapshot_id, + "capability_snapshot_sha256": snapshot.capability_snapshot_sha256, + "active_provider": snapshot.provider.active_profile_id, + })) + .map_err(|error| format!("serialize runtime snapshot lineage input failed: {error}"))?; + Ok(RuntimeSnapshotArtifactLineage { + snapshot_id: hex::encode(Sha256::digest(serialized)), + created_at: metadata.created_at.clone(), + label: metadata.label.clone(), + experiment_id: metadata.experiment_id.clone(), + parent_snapshot_id: metadata.parent_snapshot_id.clone(), + }) +} + +fn render_runtime_snapshot_artifact_text( + snapshot: &RuntimeSnapshotCliState, + artifact_payload: &Value, +) -> String { + let lineage = artifact_payload + .get("lineage") + .cloned() + .unwrap_or(Value::Null); + let schema_version = artifact_payload + .get("schema") + .and_then(|schema| schema.get("version")) + .and_then(Value::as_u64) + .unwrap_or(u64::from(RUNTIME_SNAPSHOT_ARTIFACT_JSON_SCHEMA_VERSION)); + + [ + format!("schema.version={schema_version}"), + format!("snapshot_id={}", json_string_field(&lineage, "snapshot_id")), + format!("created_at={}", json_string_field(&lineage, "created_at")), + format!("label={}", json_string_field(&lineage, "label")), + format!( + "experiment_id={}", + json_string_field(&lineage, "experiment_id") + ), + format!( + "parent_snapshot_id={}", + json_string_field(&lineage, "parent_snapshot_id") + ), + format!("restore_warnings={}", snapshot.restore_spec.warnings.len()), + render_runtime_snapshot_text(snapshot), + ] + .join("\n") +} +pub fn run_channels_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let inventory = mvp::channel::channel_inventory(&config); + let resolved_path_display = resolved_path.display().to_string(); + + if as_json { + let payload = build_channels_cli_json_payload(&resolved_path_display, &inventory); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize channel status output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!( + "{}", + render_channel_surfaces_text(&resolved_path_display, &inventory) + ); + Ok(()) +} + +pub const CHANNELS_CLI_JSON_SCHEMA_VERSION: u32 = 1; +pub const CHANNELS_CLI_JSON_LEGACY_VIEWS: &[&str] = &["channels", "catalog_only_channels"]; + +pub fn build_channels_cli_json_payload( + config_path: &str, + inventory: &mvp::channel::ChannelInventory, +) -> ChannelsCliJsonPayload { + gateway::read_models::build_channel_inventory_read_model(config_path, inventory) +} + +pub fn render_channel_surfaces_text( + config_path: &str, + inventory: &mvp::channel::ChannelInventory, +) -> String { + let mut lines = vec![format!("config={config_path}")]; + let mut catalog_only_surfaces = Vec::new(); + + for surface in &inventory.channel_surfaces { + if surface.catalog.implementation_status + == mvp::channel::ChannelCatalogImplementationStatus::Stub + { + catalog_only_surfaces.push(surface); + continue; + } + + push_channel_surface_header(&mut lines, surface); + lines.push(render_channel_onboarding_line(&surface.catalog.onboarding)); + push_channel_surface_plugin_bridge_contract(&mut lines, surface); + push_channel_surface_managed_plugin_bridge_discovery(&mut lines, surface); + for snapshot in &surface.configured_accounts { + let api_base_url = snapshot.api_base_url.as_deref().unwrap_or("-"); + lines.push(format!( + " account configured_account={} configured_account_label={} default_account={} default_source={} compiled={} enabled={} api_base_url={}", + snapshot.configured_account_id, + snapshot.configured_account_label, + snapshot.is_default_account, + snapshot.default_account_source.as_str(), + snapshot.compiled, + snapshot.enabled, + api_base_url + )); + for note in &snapshot.notes { + lines.push(format!(" note: {note}")); + } + for operation in &snapshot.operations { + let catalog_operation = surface.catalog.operation(operation.id); + let requirement_ids = catalog_operation + .map(|catalog_operation| { + render_channel_operation_requirement_ids(catalog_operation.requirements) + }) + .unwrap_or_else(|| "-".to_owned()); + lines.push(format!( + " op {} ({}) {}: {} target_kinds={} requirements={}", + operation.id, + operation.command, + operation.health.as_str(), + operation.detail, + render_channel_target_kind_ids( + catalog_operation + .map(|catalog_operation| catalog_operation.supported_target_kinds) + .unwrap_or(&[]) + ), + requirement_ids, + )); + if let Some(runtime) = &operation.runtime { + lines.push(format!( + " runtime account={} account_id={} running={} stale={} busy={} active_runs={} instance_count={} running_instances={} stale_instances={} last_run_activity_at={} last_heartbeat_at={} pid={}", + runtime + .account_label + .as_deref() + .unwrap_or("-"), + runtime + .account_id + .as_deref() + .unwrap_or("-"), + runtime.running, + runtime.stale, + runtime.busy, + runtime.active_runs, + runtime.instance_count, + runtime.running_instances, + runtime.stale_instances, + runtime + .last_run_activity_at + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_owned()), + runtime + .last_heartbeat_at + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_owned()), + runtime + .pid + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_owned()) + )); + } + for issue in &operation.issues { + lines.push(format!(" issue: {issue}")); + } + } + } + } + + if !catalog_only_surfaces.is_empty() { + lines.push("catalog-only channels:".to_owned()); + for surface in catalog_only_surfaces { + push_channel_surface_header(&mut lines, surface); + lines.push(render_channel_onboarding_line(&surface.catalog.onboarding)); + push_channel_surface_plugin_bridge_contract(&mut lines, surface); + push_channel_surface_managed_plugin_bridge_discovery(&mut lines, surface); + for operation in &surface.catalog.operations { + lines.push(format!( + " catalog op {} ({}) availability={} tracks_runtime={} target_kinds={} requirements={}", + operation.id, + operation.command, + operation.availability.as_str(), + operation.tracks_runtime, + render_channel_target_kind_ids(operation.supported_target_kinds), + render_channel_operation_requirement_ids(operation.requirements) + )); + } + } + } + lines.join("\n") +} + +pub fn render_channel_onboarding_line( + onboarding: &mvp::channel::ChannelOnboardingDescriptor, +) -> String { + format!( + " onboarding strategy={} status_command=\"{}\" repair_command={} setup_hint=\"{}\"", + onboarding.strategy.as_str(), + onboarding.status_command, + onboarding + .repair_command + .map(|command| format!("\"{command}\"")) + .unwrap_or_else(|| "-".to_owned()), + onboarding.setup_hint + ) +} + +pub fn render_channel_operation_requirement_ids( + requirements: &[mvp::channel::ChannelCatalogOperationRequirement], +) -> String { + if requirements.is_empty() { + return "-".to_owned(); + } + requirements + .iter() + .map(|requirement| requirement.id) + .collect::>() + .join(",") +} + +pub fn render_channel_target_kind_ids( + target_kinds: &[mvp::channel::ChannelCatalogTargetKind], +) -> String { + if target_kinds.is_empty() { + return "-".to_owned(); + } + target_kinds + .iter() + .map(|kind| kind.as_str()) + .collect::>() + .join(",") +} + +pub fn push_channel_surface_header( + lines: &mut Vec, + surface: &mvp::channel::ChannelSurface, +) { + let aliases = if surface.catalog.aliases.is_empty() { + "-".to_owned() + } else { + surface.catalog.aliases.join(",") + }; + let capabilities = if surface.catalog.capabilities.is_empty() { + "-".to_owned() + } else { + surface + .catalog + .capabilities + .iter() + .map(|capability| capability.as_str()) + .collect::>() + .join(",") + }; + let target_kinds = render_channel_target_kind_ids(&surface.catalog.supported_target_kinds); + lines.push(format!( + "{} [{}] implementation_status={} selection_order={} selection_label=\"{}\" capabilities={} aliases={} transport={} target_kinds={} configured_accounts={} default_configured_account={}", + surface.catalog.label, + surface.catalog.id, + surface.catalog.implementation_status.as_str(), + surface.catalog.selection_order, + surface.catalog.selection_label, + capabilities, + aliases, + surface.catalog.transport, + target_kinds, + surface.configured_accounts.len(), + surface + .default_configured_account_id + .as_deref() + .unwrap_or("-") + )); + lines.push(format!(" blurb: {}", surface.catalog.blurb)); +} + +pub fn run_list_context_engines_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let snapshot = mvp::conversation::collect_context_engine_runtime_snapshot(&config)?; + + if as_json { + let payload = json!({ + "config": resolved_path.display().to_string(), + "selected": context_engine_metadata_json( + &snapshot.selected_metadata, + Some(snapshot.selected.source.as_str()) + ), + "available": snapshot + .available + .iter() + .map(|metadata| context_engine_metadata_json(metadata, None)) + .collect::>(), + "compaction": { + "enabled": snapshot.compaction.enabled, + "min_messages": snapshot.compaction.min_messages, + "trigger_estimated_tokens": snapshot.compaction.trigger_estimated_tokens, + "fail_open": snapshot.compaction.fail_open, + }, + }); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize context-engine output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!("config={}", resolved_path.display()); + println!( + "selected={} source={} api_version={} capabilities={}", + snapshot.selected_metadata.id, + snapshot.selected.source.as_str(), + snapshot.selected_metadata.api_version, + format_capability_names(&snapshot.selected_metadata.capability_names()) + ); + println!( + "compaction=enabled:{} min_messages:{} trigger_estimated_tokens:{} fail_open:{}", + snapshot.compaction.enabled, + snapshot + .compaction + .min_messages + .map_or_else(|| "(none)".to_owned(), |value| value.to_string()), + snapshot + .compaction + .trigger_estimated_tokens + .map_or_else(|| "(none)".to_owned(), |value| value.to_string()), + snapshot.compaction.fail_open + ); + println!("available:"); + for metadata in snapshot.available { + println!( + "- {} api_version={} capabilities={}", + metadata.id, + metadata.api_version, + format_capability_names(&metadata.capability_names()) + ); + } + Ok(()) +} + +pub fn run_list_memory_systems_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let snapshot = mvp::memory::collect_memory_system_runtime_snapshot(&config)?; + + if as_json { + let payload = + build_memory_systems_cli_json_payload(&resolved_path.display().to_string(), &snapshot); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize memory-system output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!( + "{}", + render_memory_system_snapshot_text(&resolved_path.display().to_string(), &snapshot) + ); + Ok(()) +} + +pub fn run_list_acp_backends_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let snapshot = mvp::acp::collect_acp_runtime_snapshot(&config)?; + + if as_json { + let payload = json!({ + "config": resolved_path.display().to_string(), + "enabled": snapshot.control_plane.enabled, + "selected": acp_backend_metadata_json( + &snapshot.selected_metadata, + Some(snapshot.selected.source.as_str()) + ), + "available": snapshot + .available + .iter() + .map(|metadata| acp_backend_metadata_json(metadata, None)) + .collect::>(), + "control_plane": acp_control_plane_json(&snapshot.control_plane), + }); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize ACP backend output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!("config={}", resolved_path.display()); + println!( + "enabled={} selected={} source={} api_version={} capabilities={}", + snapshot.control_plane.enabled, + snapshot.selected_metadata.id, + snapshot.selected.source.as_str(), + snapshot.selected_metadata.api_version, + format_capability_names(&snapshot.selected_metadata.capability_names()) + ); + println!( + "control_plane=dispatch_enabled:{} conversation_routing:{} allowed_channels:{} allowed_account_ids:{} bootstrap_mcp_servers:{} working_directory:{} thread_routing:{} default_agent:{} allowed_agents:{} max_concurrent_sessions:{} session_idle_ttl_ms:{} startup_timeout_ms:{} turn_timeout_ms:{} queue_owner_ttl_ms:{} bindings_enabled:{} emit_runtime_events:{} allow_mcp_server_injection:{}", + snapshot.control_plane.dispatch_enabled, + snapshot.control_plane.conversation_routing.as_str(), + snapshot.control_plane.allowed_channels.join(","), + snapshot.control_plane.allowed_account_ids.join(","), + snapshot.control_plane.bootstrap_mcp_servers.join(","), + snapshot + .control_plane + .working_directory + .as_deref() + .unwrap_or(""), + snapshot.control_plane.thread_routing.as_str(), + snapshot.control_plane.default_agent, + snapshot.control_plane.allowed_agents.join(","), + snapshot.control_plane.max_concurrent_sessions, + snapshot.control_plane.session_idle_ttl_ms, + snapshot.control_plane.startup_timeout_ms, + snapshot.control_plane.turn_timeout_ms, + snapshot.control_plane.queue_owner_ttl_ms, + snapshot.control_plane.bindings_enabled, + snapshot.control_plane.emit_runtime_events, + snapshot.control_plane.allow_mcp_server_injection + ); + println!("available:"); + for metadata in snapshot.available { + println!( + "- {} api_version={} capabilities={} summary={}", + metadata.id, + metadata.api_version, + format_capability_names(&metadata.capability_names()), + metadata.summary + ); + } + Ok(()) +} + +pub fn run_list_acp_sessions_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { + #[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] + { + let _ = (config_path, as_json); + Err("ACP session persistence requires feature `memory-sqlite`".to_owned()) + } + + #[cfg(any(feature = "memory-sqlite", feature = "mvp"))] + { + let (resolved_path, config) = mvp::config::load(config_path)?; + let store = + mvp::acp::AcpSqliteSessionStore::new(Some(config.memory.resolved_sqlite_path())); + let sessions = mvp::acp::AcpSessionStore::list(&store)?; + + if as_json { + let payload = json!({ + "config": resolved_path.display().to_string(), + "sqlite_path": config.memory.resolved_sqlite_path().display().to_string(), + "sessions": sessions + .iter() + .map(acp_session_metadata_json) + .collect::>(), + }); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize ACP session output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!( + "config={} sqlite_path={}", + resolved_path.display(), + config.memory.resolved_sqlite_path().display() + ); + if sessions.is_empty() { + println!("sessions: (none)"); + return Ok(()); + } + println!("sessions:"); + for session in sessions { + println!( + "- session_key={} backend={} conversation_id={} binding_route_session_id={} activation_origin={} state={} mode={} runtime_session_name={} last_activity_ms={} last_error={}", + session.session_key, + session.backend_id, + session.conversation_id.as_deref().unwrap_or("(none)"), + session + .binding + .as_ref() + .map(|binding| binding.route_session_id.as_str()) + .unwrap_or("(none)"), + session + .activation_origin + .map(mvp::acp::AcpRoutingOrigin::as_str) + .unwrap_or("(none)"), + acp_session_state_label(session.state), + session.mode.map(acp_session_mode_label).unwrap_or("(none)"), + session.runtime_session_name, + session.last_activity_ms, + session.last_error.as_deref().unwrap_or("(none)") + ); + } + Ok(()) + } +} + +pub async fn run_acp_doctor_cli( + config_path: Option<&str>, + backend_id: Option<&str>, + as_json: bool, +) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let selection = mvp::acp::resolve_acp_backend_selection(&config); + let backend = backend_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(selection.id.as_str()); + let report = mvp::acp::AcpSessionManager::default() + .doctor(&config, Some(backend)) + .await?; + + if as_json { + let payload = acp_doctor_json( + resolved_path.display().to_string(), + selection.id.as_str(), + backend, + &report, + ); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize ACP doctor output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!("config={}", resolved_path.display()); + println!( + "selected_backend={} requested_backend={} healthy={}", + backend, backend, report.healthy + ); + if report.diagnostics.is_empty() { + println!("diagnostics: (none)"); + return Ok(()); + } + println!("diagnostics:"); + for (key, value) in report.diagnostics { + println!("- {}={}", key, value); + } + Ok(()) +} + +pub fn acp_doctor_json( + config_path: impl Into, + _default_backend: &str, + effective_backend: &str, + report: &mvp::acp::AcpDoctorReport, +) -> Value { + json!({ + "config": config_path.into(), + "selected_backend": effective_backend, + "requested_backend": effective_backend, + "healthy": report.healthy, + "diagnostics": report.diagnostics, + }) +} + +pub async fn run_acp_status_cli( + config_path: Option<&str>, + session_key: Option<&str>, + conversation_id: Option<&str>, + route_session_id: Option<&str>, + as_json: bool, +) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let resolved_session_key = + resolve_acp_status_session_key(&config, session_key, conversation_id, route_session_id)?; + let manager = mvp::acp::shared_acp_session_manager(&config)?; + let status = manager + .get_status(&config, resolved_session_key.as_str()) + .await?; + + if as_json { + let config_display = resolved_path.display().to_string(); + let payload = gateway::read_models::build_acp_status_read_model( + config_display.as_str(), + session_key, + conversation_id, + route_session_id, + resolved_session_key.as_str(), + &status, + ); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize ACP status output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!("config={}", resolved_path.display()); + if let Some(conversation_id) = conversation_id { + println!("requested_conversation_id={conversation_id}"); + } + if let Some(route_session_id) = route_session_id { + println!("requested_route_session_id={route_session_id}"); + } + if let Some(session_key) = session_key { + println!("requested_session={session_key}"); + } + println!("resolved_session_key={}", resolved_session_key); + println!( + "status=backend:{} state:{} mode:{} pending_turns:{} active_turn_id:{} conversation_id:{} binding_route_session_id:{} activation_origin:{} last_activity_ms:{} last_error:{}", + status.backend_id, + acp_session_state_label(status.state), + status.mode.map(acp_session_mode_label).unwrap_or("(none)"), + status.pending_turns, + status.active_turn_id.as_deref().unwrap_or("(none)"), + status.conversation_id.as_deref().unwrap_or("(none)"), + status + .binding + .as_ref() + .map(|binding| binding.route_session_id.as_str()) + .unwrap_or("(none)"), + status + .activation_origin + .map(mvp::acp::AcpRoutingOrigin::as_str) + .unwrap_or("(none)"), + status.last_activity_ms, + status.last_error.as_deref().unwrap_or("(none)") + ); + Ok(()) +} + +pub async fn run_acp_observability_cli(config_path: Option<&str>, as_json: bool) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let manager = mvp::acp::shared_acp_session_manager(&config)?; + let snapshot = manager.observability_snapshot(&config).await?; + + if as_json { + let config_display = resolved_path.display().to_string(); + let payload = gateway::read_models::build_acp_observability_read_model( + config_display.as_str(), + &snapshot, + ); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize ACP observability output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!("config={}", resolved_path.display()); + println!( + "runtime_cache=active_sessions:{} idle_ttl_ms:{} evicted_total:{} last_evicted_at_ms:{}", + snapshot.runtime_cache.active_sessions, + snapshot.runtime_cache.idle_ttl_ms, + snapshot.runtime_cache.evicted_total, + snapshot + .runtime_cache + .last_evicted_at_ms + .map(|value| value.to_string()) + .unwrap_or_else(|| "(none)".to_owned()) + ); + println!( + "sessions=bound:{} unbound:{} activation_origins:{} backends:{}", + snapshot.sessions.bound, + snapshot.sessions.unbound, + format_usize_rollup(&snapshot.sessions.activation_origin_counts), + format_usize_rollup(&snapshot.sessions.backend_counts) + ); + println!( + "actors=active:{} queue_depth:{} waiting:{}", + snapshot.actors.active, snapshot.actors.queue_depth, snapshot.actors.waiting + ); + println!( + "turns=active:{} queue_depth:{} completed:{} failed:{} average_latency_ms:{} max_latency_ms:{}", + snapshot.turns.active, + snapshot.turns.queue_depth, + snapshot.turns.completed, + snapshot.turns.failed, + snapshot.turns.average_latency_ms, + snapshot.turns.max_latency_ms + ); + if snapshot.errors_by_code.is_empty() { + println!("errors_by_code: (none)"); + } else { + println!("errors_by_code:"); + for (key, value) in snapshot.errors_by_code { + println!("- {}={}", key, value); + } + } + Ok(()) +} + +pub fn resolve_acp_status_session_key( + config: &mvp::config::LoongClawConfig, + session_key: Option<&str>, + conversation_id: Option<&str>, + route_session_id: Option<&str>, +) -> CliResult { + let session_key = session_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let conversation_id = conversation_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let route_session_id = route_session_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + + match (session_key, conversation_id, route_session_id) { + (Some(session_key), None, None) => Ok(session_key), + (None, Some(conversation_id), None) => { + #[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] + { + let _ = (config, conversation_id); + Err("ACP conversation-id lookup requires feature `memory-sqlite`".to_owned()) + } + + #[cfg(any(feature = "memory-sqlite", feature = "mvp"))] + { + let store = mvp::acp::AcpSqliteSessionStore::new(Some( + config.memory.resolved_sqlite_path(), + )); + let metadata = mvp::acp::AcpSessionStore::get_by_conversation_id( + &store, + conversation_id.as_str(), + )? + .ok_or_else(|| { + format!( + "ACP conversation `{}` is not registered in {}", + conversation_id, + config.memory.resolved_sqlite_path().display() + ) + })?; + Ok(metadata.session_key) + } + } + (None, None, Some(route_session_id)) => { + #[cfg(not(any(feature = "memory-sqlite", feature = "mvp")))] + { + let _ = (config, route_session_id); + Err("ACP route-session-id lookup requires feature `memory-sqlite`".to_owned()) + } + + #[cfg(any(feature = "memory-sqlite", feature = "mvp"))] + { + let store = mvp::acp::AcpSqliteSessionStore::new(Some( + config.memory.resolved_sqlite_path(), + )); + let metadata = mvp::acp::AcpSessionStore::get_by_binding_route_session_id( + &store, + route_session_id.as_str(), + )? + .ok_or_else(|| { + format!( + "ACP route session `{}` is not registered in {}", + route_session_id, + config.memory.resolved_sqlite_path().display() + ) + })?; + Ok(metadata.session_key) + } + } + (Some(_), Some(_), _) + | (Some(_), _, Some(_)) + | (_, Some(_), Some(_)) => Err( + "acp-status accepts exactly one of --session, --conversation-id, or --route-session-id" + .to_owned(), + ), + (None, None, None) => Err( + "acp-status requires --session , --conversation-id , or --route-session-id " + .to_owned(), + ), + } +} + +pub async fn run_chat_cli( + config_path: Option<&str>, + session: Option<&str>, + acp: bool, + acp_event_stream: bool, + acp_bootstrap_mcp_server: &[String], + acp_cwd: Option<&str>, +) -> CliResult<()> { + let options = build_cli_chat_options(acp, acp_event_stream, acp_bootstrap_mcp_server, acp_cwd); + mvp::chat::run_cli_chat(config_path, session, &options).await +} + +pub async fn run_ask_cli( + config_path: Option<&str>, + session: Option<&str>, + message: &str, + acp: bool, + acp_event_stream: bool, + acp_bootstrap_mcp_server: &[String], + acp_cwd: Option<&str>, +) -> CliResult<()> { + let options = build_cli_chat_options(acp, acp_event_stream, acp_bootstrap_mcp_server, acp_cwd); + mvp::chat::run_cli_ask(config_path, session, message, &options).await +} + +pub fn build_cli_chat_options( + acp: bool, + acp_event_stream: bool, + acp_bootstrap_mcp_server: &[String], + acp_cwd: Option<&str>, +) -> mvp::chat::CliChatOptions { + mvp::chat::CliChatOptions { + acp_requested: acp, + acp_event_stream, + acp_bootstrap_mcp_servers: acp_bootstrap_mcp_server.to_vec(), + acp_working_directory: acp_cwd + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(std::path::PathBuf::from), + } +} + +pub fn run_acp_event_summary_cli( + config_path: Option<&str>, + session: Option<&str>, + limit: usize, + as_json: bool, +) -> CliResult<()> { + if limit == 0 { + return Err("acp-event-summary limit must be >= 1".to_owned()); + } + + let (_, config) = mvp::config::load(config_path)?; + let session_id = session + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_owned(); + + #[cfg(feature = "memory-sqlite")] + { + let mem_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let turns = mvp::memory::window_direct(&session_id, limit, &mem_config) + .map_err(|error| format!("load ACP event summary failed: {error}"))?; + let summary = mvp::acp::summarize_turn_events( + turns + .iter() + .filter_map(|turn| (turn.role == "assistant").then_some(turn.content.as_str())), + ); + if as_json { + let payload = acp_event_summary_json(&session_id, limit, &summary); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize ACP event summary failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + print!("{}", format_acp_event_summary(&session_id, limit, &summary)); + Ok(()) + } + + #[cfg(not(feature = "memory-sqlite"))] + { + let _ = (config, session_id, as_json); + Err("acp-event-summary requires memory-sqlite feature".to_owned()) + } +} + +pub fn run_acp_dispatch_cli( + config_path: Option<&str>, + session: Option<&str>, + channel: Option<&str>, + conversation_id: Option<&str>, + account_id: Option<&str>, + thread_id: Option<&str>, + as_json: bool, +) -> CliResult<()> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let session_id = session + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_owned(); + let address = build_acp_dispatch_address( + session_id.as_str(), + channel, + conversation_id, + account_id, + thread_id, + )?; + let decision = mvp::acp::evaluate_acp_conversation_dispatch_for_address(&config, &address)?; + + if as_json { + let config_display = resolved_path.display().to_string(); + let payload = gateway::read_models::build_acp_dispatch_read_model( + config_display.as_str(), + &address, + session_id.as_str(), + &decision, + ); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize ACP dispatch output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + println!("config={}", resolved_path.display()); + println!( + "address=session:{} channel:{} account_id:{} conversation_id:{} thread_id:{}", + address.session_id, + address.channel_id.as_deref().unwrap_or("(none)"), + address.account_id.as_deref().unwrap_or("(none)"), + address.conversation_id.as_deref().unwrap_or("(none)"), + address.thread_id.as_deref().unwrap_or("(none)") + ); + println!( + "dispatch=route_via_acp:{} reason:{} automatic_routing_origin:{} route_session_id:{} prefixed_agent_id:{} channel_id:{} account_id:{} conversation_id:{} thread_id:{}", + decision.route_via_acp, + decision.reason.as_str(), + decision + .automatic_routing_origin + .map(mvp::acp::AcpRoutingOrigin::as_str) + .unwrap_or("(none)"), + decision.target.route_session_id, + decision + .target + .prefixed_agent_id + .as_deref() + .unwrap_or("(none)"), + decision.target.channel_id.as_deref().unwrap_or("(none)"), + decision.target.account_id.as_deref().unwrap_or("(none)"), + decision + .target + .conversation_id + .as_deref() + .unwrap_or("(none)"), + decision.target.thread_id.as_deref().unwrap_or("(none)") + ); + println!( + "channel_path={}", + if decision.target.channel_path.is_empty() { + "(none)".to_owned() + } else { + decision.target.channel_path.join(":") + } + ); + Ok(()) +} + +pub fn build_acp_dispatch_address( + session_id: &str, + channel: Option<&str>, + conversation_id: Option<&str>, + account_id: Option<&str>, + thread_id: Option<&str>, +) -> CliResult { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Err("acp-dispatch requires a non-empty --session value".to_owned()); + } + + let channel = channel.map(str::trim).filter(|value| !value.is_empty()); + let conversation_id = conversation_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let account_id = account_id.map(str::trim).filter(|value| !value.is_empty()); + let thread_id = thread_id.map(str::trim).filter(|value| !value.is_empty()); + + let channel = match channel { + Some(channel) => channel, + None => { + if conversation_id.is_some() || account_id.is_some() || thread_id.is_some() { + return Err( + "acp-dispatch requires --channel when using --conversation-id, --account-id, or --thread-id" + .to_owned(), + ); + } + return Ok(mvp::conversation::ConversationSessionAddress::from_session_id(session_id)); + } + }; + + let conversation_id = conversation_id.ok_or_else(|| { + "acp-dispatch requires --conversation-id when --channel is provided".to_owned() + })?; + let mut address = mvp::conversation::ConversationSessionAddress::from_session_id(session_id) + .with_channel_scope(channel, conversation_id); + if let Some(account_id) = account_id { + address = address.with_account_id(account_id); + } + if let Some(thread_id) = thread_id { + address = address.with_thread_id(thread_id); + } + Ok(address) +} + +pub fn run_safe_lane_summary_cli( + config_path: Option<&str>, + session: Option<&str>, + limit: usize, + as_json: bool, +) -> CliResult<()> { + if limit == 0 { + return Err("safe-lane-summary limit must be >= 1".to_owned()); + } + + let (_, config) = mvp::config::load(config_path)?; + let session_id = session + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_owned(); + + #[cfg(feature = "memory-sqlite")] + { + let mem_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let turns = mvp::memory::window_direct(&session_id, limit, &mem_config) + .map_err(|error| format!("load safe-lane summary failed: {error}"))?; + let summary = mvp::conversation::summarize_safe_lane_events( + turns + .iter() + .filter_map(|turn| (turn.role == "assistant").then_some(turn.content.as_str())), + ); + if as_json { + let payload = json!({ + "session": session_id, + "limit": limit, + "summary": summary, + }); + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize safe-lane summary failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + let final_status = match summary.final_status { + Some(mvp::conversation::SafeLaneFinalStatus::Succeeded) => "succeeded", + Some(mvp::conversation::SafeLaneFinalStatus::Failed) => "failed", + None => "unknown", + }; + println!("safe_lane_summary session={} limit={}", session_id, limit); + println!( + "events lane_selected={} round_started={} round_completed_succeeded={} round_completed_failed={} verify_failed={} verify_policy_adjusted={} replan_triggered={} final_status={} governor_engaged={} governor_force_no_replan={}", + summary.lane_selected_events, + summary.round_started_events, + summary.round_completed_succeeded_events, + summary.round_completed_failed_events, + summary.verify_failed_events, + summary.verify_policy_adjusted_events, + summary.replan_triggered_events, + summary.final_status_events, + summary.session_governor_engaged_events, + summary.session_governor_force_no_replan_events + ); + println!( + "terminal status={} failure_code={} route_decision={} route_reason={}", + final_status, + summary.final_failure_code.as_deref().unwrap_or("-"), + summary.final_route_decision.as_deref().unwrap_or("-"), + summary.final_route_reason.as_deref().unwrap_or("-") + ); + let route_reasons_rollup = if summary.route_reason_counts.is_empty() { + "-".to_owned() + } else { + summary + .route_reason_counts + .iter() + .map(|(key, value)| format!("{key}:{value}")) + .collect::>() + .join(",") + }; + println!( + "governor trigger_failed_threshold={} trigger_backpressure_threshold={} trigger_trend_threshold={} trigger_recovery_threshold={}", + summary.session_governor_failed_threshold_triggered_events, + summary.session_governor_backpressure_threshold_triggered_events, + summary.session_governor_trend_threshold_triggered_events, + summary.session_governor_recovery_threshold_triggered_events + ); + println!( + "governor_latest snapshots={} trend_samples={} trend_min_samples={} trend_failure_ewma={} trend_backpressure_ewma={} recovery_success_streak={} recovery_streak_threshold={}", + summary.session_governor_metrics_snapshots_seen, + summary + .session_governor_latest_trend_samples + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_owned()), + summary + .session_governor_latest_trend_min_samples + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_owned()), + format_milli_ratio(summary.session_governor_latest_trend_failure_ewma_milli), + format_milli_ratio(summary.session_governor_latest_trend_backpressure_ewma_milli), + summary + .session_governor_latest_recovery_success_streak + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_owned()), + summary + .session_governor_latest_recovery_success_streak_threshold + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_owned()) + ); + println!("rollup route_reasons={route_reasons_rollup}"); + Ok(()) + } + + #[cfg(not(feature = "memory-sqlite"))] + { + let _ = (config, session_id, as_json); + Err("safe-lane-summary requires memory-sqlite feature".to_owned()) + } +} + +#[cfg(feature = "memory-sqlite")] +pub fn format_milli_ratio(value: Option) -> String { + value + .map(|raw| format!("{:.3}", (raw as f64) / 1000.0)) + .unwrap_or_else(|| "-".to_owned()) +} + +pub async fn with_graceful_shutdown(serve_future: F) -> CliResult<()> +where + F: std::future::Future>, +{ + tokio::select! { + result = serve_future => result, + result = wait_for_shutdown_reason() => result.map(|_| ()), + } +} + +#[cfg(unix)] +pub async fn wait_for_shutdown_reason() -> CliResult { + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(|error| format!("failed to register SIGTERM handler: {error}"))?; + + tokio::select! { + result = tokio::signal::ctrl_c() => { + result.map_err(|error| format!("failed to register Ctrl-C handler: {error}"))?; + eprintln!("\nReceived Ctrl-C, shutting down gracefully..."); + Ok("ctrl-c received".to_owned()) + } + _ = sigterm.recv() => { + eprintln!("\nReceived SIGTERM, shutting down gracefully..."); + Ok("sigterm received".to_owned()) + } + } +} + +#[cfg(not(unix))] +pub async fn wait_for_shutdown_reason() -> CliResult { + tokio::signal::ctrl_c() + .await + .map_err(|error| format!("failed to register Ctrl-C handler: {error}"))?; + eprintln!("\nReceived Ctrl-C, shutting down gracefully..."); + Ok("ctrl-c received".to_owned()) +} + +pub async fn wait_for_shutdown_signal() -> CliResult<()> { + wait_for_shutdown_reason().await.map(|_| ()) +} + +pub const TELEGRAM_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::TELEGRAM_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_telegram_send_cli_impl, +}; + +pub const FEISHU_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::FEISHU_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_feishu_send_cli_impl, +}; + +pub const MATRIX_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::MATRIX_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_matrix_send_cli_impl, +}; + +pub const WECOM_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::WECOM_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_wecom_send_cli_impl, +}; + +pub const DISCORD_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::DISCORD_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_discord_send_cli_impl, +}; + +pub const DINGTALK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::DINGTALK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_dingtalk_send_cli_impl, +}; + +pub const SLACK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::SLACK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_slack_send_cli_impl, +}; + +pub const LINE_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::LINE_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_line_send_cli_impl, +}; + +pub const WHATSAPP_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::WHATSAPP_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_whatsapp_send_cli_impl, +}; + +pub const EMAIL_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::EMAIL_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_email_send_cli_impl, +}; + +pub const WEBHOOK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::WEBHOOK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_webhook_send_cli_impl, +}; + +pub const GOOGLE_CHAT_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::GOOGLE_CHAT_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_google_chat_send_cli_impl, +}; + +pub const TEAMS_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::TEAMS_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_teams_send_cli_impl, +}; + +pub const SIGNAL_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::SIGNAL_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_signal_send_cli_impl, +}; + +pub const TWITCH_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::TWITCH_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_twitch_send_cli_impl, +}; + +pub const MATTERMOST_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::MATTERMOST_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_mattermost_send_cli_impl, +}; + +pub const NEXTCLOUD_TALK_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::NEXTCLOUD_TALK_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_nextcloud_talk_send_cli_impl, +}; + +pub const SYNOLOGY_CHAT_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::SYNOLOGY_CHAT_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_synology_chat_send_cli_impl, +}; + +pub const IRC_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::IRC_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_irc_send_cli_impl, +}; + +pub const IMESSAGE_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::IMESSAGE_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_imessage_send_cli_impl, +}; + +pub const NOSTR_SEND_CLI_SPEC: ChannelSendCliSpec = ChannelSendCliSpec { + family: mvp::channel::NOSTR_CATALOG_COMMAND_FAMILY_DESCRIPTOR, + run: run_nostr_send_cli_impl, +}; + +pub const TELEGRAM_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { + family: mvp::channel::TELEGRAM_COMMAND_FAMILY_DESCRIPTOR, + run: run_telegram_serve_cli_impl, +}; + +pub const FEISHU_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { + family: mvp::channel::FEISHU_COMMAND_FAMILY_DESCRIPTOR, + run: run_feishu_serve_cli_impl, +}; + +pub const MATRIX_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { + family: mvp::channel::MATRIX_COMMAND_FAMILY_DESCRIPTOR, + run: run_matrix_serve_cli_impl, +}; + +pub const WECOM_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { + family: mvp::channel::WECOM_COMMAND_FAMILY_DESCRIPTOR, + run: run_wecom_serve_cli_impl, +}; + +pub const WHATSAPP_SERVE_CLI_SPEC: ChannelServeCliSpec = ChannelServeCliSpec { + family: mvp::channel::WHATSAPP_COMMAND_FAMILY_DESCRIPTOR, + run: run_whatsapp_serve_cli_impl, +}; + +pub async fn run_channel_send_cli( + spec: ChannelSendCliSpec, + args: ChannelSendCliArgs<'_>, +) -> CliResult<()> { + let _ = spec.family; + (spec.run)(args).await +} + +pub async fn run_channel_serve_cli( + spec: ChannelServeCliSpec, + args: ChannelServeCliArgs<'_>, +) -> CliResult<()> { + let _ = spec.family; + (spec.run)(args).await +} + +fn require_channel_send_target<'a>(command: &str, target: Option<&'a str>) -> CliResult<&'a str> { + let target = target.map(str::trim).filter(|value| !value.is_empty()); + let Some(target) = target else { + return Err(format!("{command} requires --target")); + }; + + Ok(target) +} + +pub fn run_telegram_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_telegram_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_feishu_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let target = args.target.unwrap_or_default(); + mvp::channel::run_feishu_send( + args.config_path, + args.account, + &mvp::channel::FeishuChannelSendRequest { + receive_id: target.to_owned(), + receive_id_type: Some(args.target_kind.as_str().to_owned()), + text: Some(args.text.to_owned()), + post_json: None, + image_key: None, + file_key: None, + image_path: None, + file_path: None, + file_type: None, + card: args.as_card, + uuid: None, + }, + ) + .await + }) +} + +pub fn run_matrix_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_matrix_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_wecom_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_wecom_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_discord_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_discord_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_dingtalk_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + mvp::channel::run_dingtalk_send( + args.config_path, + args.account, + args.target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_slack_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_slack_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_line_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_line_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_whatsapp_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_whatsapp_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_email_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = require_channel_send_target("email-send", args.target)?; + mvp::channel::run_email_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_webhook_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + mvp::channel::run_webhook_send( + args.config_path, + args.account, + args.target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_google_chat_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + mvp::channel::run_google_chat_send( + args.config_path, + args.account, + args.target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_teams_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + mvp::channel::run_teams_send( + args.config_path, + args.account, + args.target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_mattermost_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = require_channel_send_target("mattermost-send", args.target)?; + mvp::channel::run_mattermost_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_nextcloud_talk_send_cli_impl( + args: ChannelSendCliArgs<'_>, +) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = require_channel_send_target("nextcloud-talk-send", args.target)?; + mvp::channel::run_nextcloud_talk_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_synology_chat_send_cli_impl( + args: ChannelSendCliArgs<'_>, +) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + mvp::channel::run_synology_chat_send( + args.config_path, + args.account, + args.target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_irc_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = require_channel_send_target("irc-send", args.target)?; + mvp::channel::run_irc_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_imessage_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = require_channel_send_target("imessage-send", args.target)?; + mvp::channel::run_imessage_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_nostr_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + mvp::channel::run_nostr_send( + args.config_path, + args.account, + args.target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_signal_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = args.target.unwrap_or_default(); + mvp::channel::run_signal_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_twitch_send_cli_impl(args: ChannelSendCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.as_card; + let target = require_channel_send_target("twitch-send", args.target)?; + mvp::channel::run_twitch_send( + args.config_path, + args.account, + target, + args.target_kind, + args.text, + ) + .await + }) +} + +pub fn run_telegram_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = (args.bind_override, args.path_override); + with_graceful_shutdown(mvp::channel::run_telegram_channel( + args.config_path, + args.once, + args.account, + )) + .await + }) +} + +pub fn default_channel_send_target_kind( + spec: ChannelSendCliSpec, +) -> mvp::channel::ChannelOutboundTargetKind { + spec.family.default_send_target_kind +} + +pub fn parse_channel_send_target_kind( + spec: ChannelSendCliSpec, + raw: &str, +) -> Result { + let target_kind = raw.parse::()?; + let channel_id = spec.family.channel_id; + let operation = spec.family.send; + if !operation.supports_target_kind(target_kind) { + let supported = operation + .supported_target_kinds + .iter() + .map(|kind| format!("`{}`", kind.as_str())) + .collect::>() + .join(" or "); + return Err(format!( + "{channel_id} --target-kind does not support `{}`; use {}", + target_kind.as_str(), + supported + )); + } + Ok(target_kind) +} + +pub fn default_telegram_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(TELEGRAM_SEND_CLI_SPEC) +} + +pub fn parse_telegram_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(TELEGRAM_SEND_CLI_SPEC, raw) +} + +pub fn default_matrix_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(MATRIX_SEND_CLI_SPEC) +} + +pub fn parse_matrix_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(MATRIX_SEND_CLI_SPEC, raw) +} + +pub fn default_wecom_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(WECOM_SEND_CLI_SPEC) +} + +pub fn parse_wecom_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(WECOM_SEND_CLI_SPEC, raw) +} + +pub fn default_feishu_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(FEISHU_SEND_CLI_SPEC) +} + +pub fn parse_feishu_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(FEISHU_SEND_CLI_SPEC, raw) +} + +pub fn default_discord_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(DISCORD_SEND_CLI_SPEC) +} + +pub fn parse_discord_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(DISCORD_SEND_CLI_SPEC, raw) +} + +pub fn default_dingtalk_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(DINGTALK_SEND_CLI_SPEC) +} + +pub fn parse_dingtalk_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(DINGTALK_SEND_CLI_SPEC, raw) +} + +pub fn default_slack_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(SLACK_SEND_CLI_SPEC) +} + +pub fn parse_slack_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(SLACK_SEND_CLI_SPEC, raw) +} + +pub fn default_line_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(LINE_SEND_CLI_SPEC) +} + +pub fn parse_line_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(LINE_SEND_CLI_SPEC, raw) +} + +pub fn default_whatsapp_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(WHATSAPP_SEND_CLI_SPEC) +} + +pub fn parse_whatsapp_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(WHATSAPP_SEND_CLI_SPEC, raw) +} + +pub fn default_email_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(EMAIL_SEND_CLI_SPEC) +} + +pub fn parse_email_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(EMAIL_SEND_CLI_SPEC, raw) +} + +pub fn default_webhook_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(WEBHOOK_SEND_CLI_SPEC) +} + +pub fn parse_webhook_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(WEBHOOK_SEND_CLI_SPEC, raw) +} + +pub fn default_google_chat_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(GOOGLE_CHAT_SEND_CLI_SPEC) +} + +pub fn parse_google_chat_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(GOOGLE_CHAT_SEND_CLI_SPEC, raw) +} + +pub fn default_teams_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(TEAMS_SEND_CLI_SPEC) +} + +pub fn parse_teams_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(TEAMS_SEND_CLI_SPEC, raw) +} + +pub fn default_signal_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(SIGNAL_SEND_CLI_SPEC) +} + +pub fn parse_signal_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(SIGNAL_SEND_CLI_SPEC, raw) +} + +pub fn default_mattermost_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(MATTERMOST_SEND_CLI_SPEC) +} + +pub fn parse_mattermost_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(MATTERMOST_SEND_CLI_SPEC, raw) +} + +pub fn default_nextcloud_talk_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(NEXTCLOUD_TALK_SEND_CLI_SPEC) +} + +pub fn parse_nextcloud_talk_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(NEXTCLOUD_TALK_SEND_CLI_SPEC, raw) +} + +pub fn default_synology_chat_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(SYNOLOGY_CHAT_SEND_CLI_SPEC) +} + +pub fn parse_synology_chat_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(SYNOLOGY_CHAT_SEND_CLI_SPEC, raw) +} + +pub fn default_irc_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(IRC_SEND_CLI_SPEC) +} + +pub fn parse_irc_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(IRC_SEND_CLI_SPEC, raw) +} + +pub fn default_imessage_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(IMESSAGE_SEND_CLI_SPEC) +} + +pub fn parse_imessage_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(IMESSAGE_SEND_CLI_SPEC, raw) +} + +pub fn default_nostr_send_target_kind() -> mvp::channel::ChannelOutboundTargetKind { + default_channel_send_target_kind(NOSTR_SEND_CLI_SPEC) +} + +pub fn parse_nostr_send_target_kind( + raw: &str, +) -> Result { + parse_channel_send_target_kind(NOSTR_SEND_CLI_SPEC, raw) +} + +pub fn run_feishu_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + with_graceful_shutdown(mvp::channel::run_feishu_channel( + args.config_path, + args.account, + args.bind_override, + args.path_override, + )) + .await + }) +} + +pub fn run_matrix_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = (args.bind_override, args.path_override); + with_graceful_shutdown(mvp::channel::run_matrix_channel( + args.config_path, + args.once, + args.account, + )) + .await + }) +} + +pub fn run_wecom_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + // WeCom AIBot uses a long connection only. `args.once`, + // `args.bind_override`, and `args.path_override` are intentionally + // discarded because single-run mode and HTTP bind/path overrides do not + // apply to this transport. + let _ = (args.once, args.bind_override, args.path_override); + with_graceful_shutdown(mvp::channel::run_wecom_channel( + args.config_path, + args.account, + )) + .await + }) +} + +pub fn run_whatsapp_serve_cli_impl(args: ChannelServeCliArgs<'_>) -> ChannelCliCommandFuture<'_> { + Box::pin(async move { + let _ = args.once; + with_graceful_shutdown(mvp::channel::run_whatsapp_channel( + args.config_path, + args.account, + args.bind_override, + args.path_override, + )) + .await + }) +} + +pub async fn run_multi_channel_serve_cli( + config_path: Option<&str>, + session: &str, + channel_accounts: Vec, +) -> CliResult<()> { + gateway::service::run_multi_channel_serve_gateway_compat_cli( + config_path, + session, + channel_accounts, + ) + .await +} + +pub(crate) fn render_string_list<'a>(values: impl IntoIterator) -> String { + let rendered = values + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>(); + if rendered.is_empty() { + "-".to_owned() + } else { + rendered.join(",") + } +} + +fn json_string_field<'a>(value: &'a Value, key: &str) -> &'a str { + value.get(key).and_then(Value::as_str).unwrap_or("-") +} + +pub fn context_engine_metadata_json( + metadata: &mvp::conversation::ContextEngineMetadata, + source: Option<&str>, +) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert("id".to_owned(), json!(metadata.id)); + payload.insert("api_version".to_owned(), json!(metadata.api_version)); + payload.insert( + "capabilities".to_owned(), + json!(metadata.capability_names()), + ); + if let Some(source) = source { + payload.insert("source".to_owned(), json!(source)); + } + Value::Object(payload) +} + +pub fn memory_system_metadata_json( + metadata: &mvp::memory::MemorySystemMetadata, + source: Option<&str>, +) -> Value { + let supported_stage_families = metadata + .supported_stage_families + .iter() + .copied() + .map(mvp::memory::MemoryStageFamily::as_str) + .collect::>(); + let supported_pre_assembly_stage_families = metadata + .supported_pre_assembly_stage_families + .iter() + .copied() + .map(mvp::memory::MemoryStageFamily::as_str) + .collect::>(); + let supported_recall_modes = metadata + .supported_recall_modes + .iter() + .copied() + .map(mvp::memory::MemoryRecallMode::as_str) + .collect::>(); + let mut payload = serde_json::Map::new(); + payload.insert("id".to_owned(), json!(metadata.id)); + payload.insert("api_version".to_owned(), json!(metadata.api_version)); + payload.insert( + "capabilities".to_owned(), + json!(metadata.capability_names()), + ); + payload.insert( + "runtime_fallback_kind".to_owned(), + json!(metadata.runtime_fallback_kind.as_str()), + ); + payload.insert( + "supported_stage_families".to_owned(), + json!(supported_stage_families), + ); + payload.insert( + "supported_pre_assembly_stage_families".to_owned(), + json!(supported_pre_assembly_stage_families), + ); + payload.insert( + "supported_recall_modes".to_owned(), + json!(supported_recall_modes), + ); + payload.insert("summary".to_owned(), json!(metadata.summary)); + if let Some(source) = source { + payload.insert("source".to_owned(), json!(source)); + } + Value::Object(payload) +} + +fn format_memory_stage_family_names(families: &[mvp::memory::MemoryStageFamily]) -> String { + let names = families + .iter() + .copied() + .map(mvp::memory::MemoryStageFamily::as_str) + .collect::>(); + render_string_list(names) +} + +fn format_memory_recall_mode_names(recall_modes: &[mvp::memory::MemoryRecallMode]) -> String { + let names = recall_modes + .iter() + .copied() + .map(mvp::memory::MemoryRecallMode::as_str) + .collect::>(); + render_string_list(names) +} + +fn format_memory_core_operation_names(operations: &[mvp::memory::MemoryCoreOperation]) -> String { + let names = operations + .iter() + .copied() + .map(mvp::memory::MemoryCoreOperation::as_str) + .collect::>(); + render_string_list(names) +} + +pub fn memory_system_policy_json(policy: &mvp::memory::MemorySystemPolicySnapshot) -> Value { + json!({ + "backend": policy.backend.as_str(), + "profile": policy.profile.as_str(), + "mode": policy.mode.as_str(), + "ingest_mode": policy.ingest_mode.as_str(), + "fail_open": policy.fail_open, + "strict_mode_requested": policy.strict_mode_requested, + "strict_mode_active": policy.strict_mode_active, + "effective_fail_open": policy.effective_fail_open, + }) +} + +pub fn build_memory_systems_cli_json_payload( + config_path: &str, + snapshot: &mvp::memory::MemorySystemRuntimeSnapshot, +) -> Value { + json!({ + "config": config_path, + "selected": memory_system_metadata_json( + &snapshot.selected_metadata, + Some(snapshot.selected.source.as_str()) + ), + "available": snapshot + .available + .iter() + .map(|metadata| memory_system_metadata_json(metadata, None)) + .collect::>(), + "core_operations": snapshot + .core_operations + .iter() + .copied() + .map(mvp::memory::MemoryCoreOperation::as_str) + .collect::>(), + "policy": memory_system_policy_json(&snapshot.policy), + }) +} + +pub fn render_memory_system_snapshot_text( + config_path: &str, + snapshot: &mvp::memory::MemorySystemRuntimeSnapshot, +) -> String { + let selected_capabilities = snapshot.selected_metadata.capability_names(); + let selected_stage_families = + format_memory_stage_family_names(&snapshot.selected_metadata.supported_stage_families); + let selected_pre_assembly_stages = format_memory_stage_family_names( + &snapshot + .selected_metadata + .supported_pre_assembly_stage_families, + ); + let selected_recall_modes = + format_memory_recall_mode_names(&snapshot.selected_metadata.supported_recall_modes); + let core_operations = format_memory_core_operation_names(&snapshot.core_operations); + let mut lines = vec![ + format!("config={config_path}"), + format!( + "selected={} source={} api_version={} capabilities={} runtime_fallback_kind={} stages={} pre_assembly_stages={} recall_modes={} core_operations={} summary={}", + snapshot.selected_metadata.id, + snapshot.selected.source.as_str(), + snapshot.selected_metadata.api_version, + format_capability_names(&selected_capabilities), + snapshot.selected_metadata.runtime_fallback_kind.as_str(), + selected_stage_families, + selected_pre_assembly_stages, + selected_recall_modes, + core_operations, + snapshot.selected_metadata.summary + ), + format!( + "policy=backend:{} profile:{} mode:{} ingest_mode:{} fail_open:{} strict_mode_requested:{} strict_mode_active:{} effective_fail_open:{}", + snapshot.policy.backend.as_str(), + snapshot.policy.profile.as_str(), + snapshot.policy.mode.as_str(), + snapshot.policy.ingest_mode.as_str(), + snapshot.policy.fail_open, + snapshot.policy.strict_mode_requested, + snapshot.policy.strict_mode_active, + snapshot.policy.effective_fail_open, + ), + "available:".to_owned(), + ]; + + for metadata in &snapshot.available { + let capabilities = metadata.capability_names(); + let stage_families = format_memory_stage_family_names(&metadata.supported_stage_families); + let pre_assembly_stages = + format_memory_stage_family_names(&metadata.supported_pre_assembly_stage_families); + let recall_modes = format_memory_recall_mode_names(&metadata.supported_recall_modes); + lines.push(format!( + "- {} api_version={} capabilities={} runtime_fallback_kind={} stages={} pre_assembly_stages={} recall_modes={} summary={}", + metadata.id, + metadata.api_version, + format_capability_names(&capabilities), + metadata.runtime_fallback_kind.as_str(), + stage_families, + pre_assembly_stages, + recall_modes, + metadata.summary + )); + } + + lines.join("\n") +} + +pub fn acp_backend_metadata_json( + metadata: &mvp::acp::AcpBackendMetadata, + source: Option<&str>, +) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert("id".to_owned(), json!(metadata.id)); + payload.insert("api_version".to_owned(), json!(metadata.api_version)); + payload.insert( + "capabilities".to_owned(), + json!(metadata.capability_names()), + ); + payload.insert("summary".to_owned(), json!(metadata.summary)); + if let Some(source) = source { + payload.insert("source".to_owned(), json!(source)); + } + Value::Object(payload) +} + +pub fn acp_control_plane_json(snapshot: &mvp::acp::AcpControlPlaneSnapshot) -> Value { + json!({ + "enabled": snapshot.enabled, + "dispatch_enabled": snapshot.dispatch_enabled, + "conversation_routing": snapshot.conversation_routing.as_str(), + "allowed_channels": snapshot.allowed_channels, + "allowed_account_ids": snapshot.allowed_account_ids, + "bootstrap_mcp_servers": snapshot.bootstrap_mcp_servers, + "working_directory": snapshot.working_directory, + "thread_routing": snapshot.thread_routing.as_str(), + "default_agent": snapshot.default_agent, + "allowed_agents": snapshot.allowed_agents, + "max_concurrent_sessions": snapshot.max_concurrent_sessions, + "session_idle_ttl_ms": snapshot.session_idle_ttl_ms, + "startup_timeout_ms": snapshot.startup_timeout_ms, + "turn_timeout_ms": snapshot.turn_timeout_ms, + "queue_owner_ttl_ms": snapshot.queue_owner_ttl_ms, + "bindings_enabled": snapshot.bindings_enabled, + "emit_runtime_events": snapshot.emit_runtime_events, + "allow_mcp_server_injection": snapshot.allow_mcp_server_injection, + }) +} + +pub fn acp_session_metadata_json(metadata: &mvp::acp::AcpSessionMetadata) -> Value { + json!({ + "session_key": metadata.session_key, + "conversation_id": metadata.conversation_id, + "binding": metadata.binding.as_ref().map(acp_binding_scope_json), + "activation_origin": metadata.activation_origin.map(mvp::acp::AcpRoutingOrigin::as_str), + "provenance": acp_session_activation_provenance_json(metadata.activation_origin), + "backend_id": metadata.backend_id, + "runtime_session_name": metadata.runtime_session_name, + "working_directory": metadata + .working_directory + .as_ref() + .map(|path| path.display().to_string()), + "backend_session_id": metadata.backend_session_id, + "agent_session_id": metadata.agent_session_id, + "mode": metadata.mode.map(acp_session_mode_label), + "state": acp_session_state_label(metadata.state), + "last_activity_ms": metadata.last_activity_ms, + "last_error": metadata.last_error, + }) +} + +pub fn acp_session_status_json(status: &mvp::acp::AcpSessionStatus) -> Value { + json!({ + "session_key": status.session_key, + "backend_id": status.backend_id, + "conversation_id": status.conversation_id, + "binding": status.binding.as_ref().map(acp_binding_scope_json), + "activation_origin": status.activation_origin.map(mvp::acp::AcpRoutingOrigin::as_str), + "provenance": acp_session_activation_provenance_json(status.activation_origin), + "state": acp_session_state_label(status.state), + "mode": status.mode.map(acp_session_mode_label), + "pending_turns": status.pending_turns, + "active_turn_id": status.active_turn_id, + "last_activity_ms": status.last_activity_ms, + "last_error": status.last_error, + }) +} + +pub fn acp_binding_scope_json(binding: &mvp::acp::AcpSessionBindingScope) -> Value { + json!({ + "route_session_id": binding.route_session_id, + "channel_id": binding.channel_id, + "account_id": binding.account_id, + "conversation_id": binding.conversation_id, + "thread_id": binding.thread_id, + }) +} + +pub fn acp_session_activation_provenance_json(origin: Option) -> Value { + json!({ + "surface": "session_activation", + "activation_origin": origin.map(mvp::acp::AcpRoutingOrigin::as_str), + }) +} + +pub fn acp_dispatch_prediction_provenance_json( + decision: &mvp::acp::AcpConversationDispatchDecision, +) -> Value { + json!({ + "surface": "dispatch_prediction", + "automatic_routing_origin": decision + .automatic_routing_origin + .map(mvp::acp::AcpRoutingOrigin::as_str), + }) +} + +pub fn acp_turn_provenance_json(summary: &mvp::acp::AcpTurnEventSummary) -> Value { + json!({ + "surface": "turn_execution", + "last_routing_intent": summary.last_routing_intent, + "last_routing_origin": summary.last_routing_origin, + "routing_intent_counts": summary.routing_intent_counts, + "routing_origin_counts": summary.routing_origin_counts, + }) +} + +pub fn acp_dispatch_decision_json( + session: &str, + decision: &mvp::acp::AcpConversationDispatchDecision, +) -> Value { + json!({ + "session": session, + "decision": { + "route_via_acp": decision.route_via_acp, + "reason": decision.reason.as_str(), + "automatic_routing_origin": decision + .automatic_routing_origin + .map(mvp::acp::AcpRoutingOrigin::as_str), + "provenance": acp_dispatch_prediction_provenance_json(decision), + "target": { + "original_session_id": decision.target.original_session_id, + "route_session_id": decision.target.route_session_id, + "prefixed_agent_id": decision.target.prefixed_agent_id, + "channel_id": decision.target.channel_id, + "account_id": decision.target.account_id, + "conversation_id": decision.target.conversation_id, + "thread_id": decision.target.thread_id, + "channel_path": decision.target.channel_path, + } + } + }) +} + +pub fn acp_manager_observability_json( + snapshot: &mvp::acp::AcpManagerObservabilitySnapshot, +) -> Value { + json!({ + "runtime_cache": { + "active_sessions": snapshot.runtime_cache.active_sessions, + "idle_ttl_ms": snapshot.runtime_cache.idle_ttl_ms, + "evicted_total": snapshot.runtime_cache.evicted_total, + "last_evicted_at_ms": snapshot.runtime_cache.last_evicted_at_ms, + }, + "sessions": { + "bound": snapshot.sessions.bound, + "unbound": snapshot.sessions.unbound, + "activation_origin_counts": snapshot.sessions.activation_origin_counts, + "provenance": { + "surface": "session_activation_aggregate", + "activation_origin_counts": snapshot.sessions.activation_origin_counts, + }, + "backend_counts": snapshot.sessions.backend_counts, + }, + "actors": { + "active": snapshot.actors.active, + "queue_depth": snapshot.actors.queue_depth, + "waiting": snapshot.actors.waiting, + }, + "turns": { + "active": snapshot.turns.active, + "queue_depth": snapshot.turns.queue_depth, + "completed": snapshot.turns.completed, + "failed": snapshot.turns.failed, + "average_latency_ms": snapshot.turns.average_latency_ms, + "max_latency_ms": snapshot.turns.max_latency_ms, + }, + "errors_by_code": snapshot.errors_by_code, + }) +} + +pub fn acp_event_summary_json( + session: &str, + limit: usize, + summary: &mvp::acp::AcpTurnEventSummary, +) -> Value { + json!({ + "session": session, + "limit": limit, + "provenance": acp_turn_provenance_json(summary), + "summary": summary, + }) +} + +pub fn format_acp_event_summary( + session: &str, + limit: usize, + summary: &mvp::acp::AcpTurnEventSummary, +) -> String { + format!( + concat!( + "acp_event_summary session={} limit={}\n", + "records turn_event_records={} final_records={}\n", + "events done={} error={} text={} usage_update={}\n", + "turns succeeded={} cancelled={} failed={}\n", + "latest backend_id={} agent_id={} routing_intent={} routing_origin={} session_key={} conversation_id={} binding_route_session_id={} channel_id={} account_id={} channel_conversation_id={} channel_thread_id={} trace_id={} source_message_id={} ack_cursor={} state={} stop_reason={} error={}\n", + "rollup event_types={} stop_reasons={} routing_intents={} routing_origins={}\n" + ), + session, + limit, + summary.turn_event_records, + summary.final_records, + summary.done_events, + summary.error_events, + summary.text_events, + summary.usage_update_events, + summary.turns_succeeded, + summary.turns_cancelled, + summary.turns_failed, + summary.last_backend_id.as_deref().unwrap_or("-"), + summary.last_agent_id.as_deref().unwrap_or("-"), + summary.last_routing_intent.as_deref().unwrap_or("-"), + summary.last_routing_origin.as_deref().unwrap_or("-"), + summary.last_session_key.as_deref().unwrap_or("-"), + summary.last_conversation_id.as_deref().unwrap_or("-"), + summary + .last_binding_route_session_id + .as_deref() + .unwrap_or("-"), + summary.last_channel_id.as_deref().unwrap_or("-"), + summary.last_account_id.as_deref().unwrap_or("-"), + summary + .last_channel_conversation_id + .as_deref() + .unwrap_or("-"), + summary.last_channel_thread_id.as_deref().unwrap_or("-"), + summary.last_trace_id.as_deref().unwrap_or("-"), + summary.last_source_message_id.as_deref().unwrap_or("-"), + summary.last_ack_cursor.as_deref().unwrap_or("-"), + summary.last_turn_state.as_deref().unwrap_or("-"), + summary.last_stop_reason.as_deref().unwrap_or("-"), + summary.last_error.as_deref().unwrap_or("-"), + format_u32_rollup(&summary.event_type_counts), + format_u32_rollup(&summary.stop_reason_counts), + format_u32_rollup(&summary.routing_intent_counts), + format_u32_rollup(&summary.routing_origin_counts) + ) +} + +pub fn acp_session_mode_label(mode: mvp::acp::AcpSessionMode) -> &'static str { + match mode { + mvp::acp::AcpSessionMode::Interactive => "interactive", + mvp::acp::AcpSessionMode::Background => "background", + mvp::acp::AcpSessionMode::Review => "review", + } +} + +pub fn acp_session_state_label(state: mvp::acp::AcpSessionState) -> &'static str { + match state { + mvp::acp::AcpSessionState::Initializing => "initializing", + mvp::acp::AcpSessionState::Ready => "ready", + mvp::acp::AcpSessionState::Busy => "busy", + mvp::acp::AcpSessionState::Cancelling => "cancelling", + mvp::acp::AcpSessionState::Error => "error", + mvp::acp::AcpSessionState::Closed => "closed", + } +} + +pub fn format_capability_names(names: &[&str]) -> String { + if names.is_empty() { + return "(none)".to_owned(); + } + names.join(",") +} + +pub fn format_u32_rollup(values: &BTreeMap) -> String { + if values.is_empty() { + return "-".to_owned(); + } + values + .iter() + .map(|(key, value)| format!("{key}:{value}")) + .collect::>() + .join(",") +} + +pub fn format_usize_rollup(values: &BTreeMap) -> String { + if values.is_empty() { + return "-".to_owned(); + } + values + .iter() + .map(|(key, value)| format!("{key}:{value}")) + .collect::>() + .join(",") +} + +pub fn read_spec_file(path: &str) -> CliResult { + read_spec_file_with_bridge_support_resolution(path, None).map(|resolved| resolved.spec) +} + +pub fn read_spec_file_with_bridge_support_selection( + path: &str, + bridge_support_selection_override: Option<&BridgeSupportSelectionInput>, +) -> CliResult { + read_spec_file_with_bridge_support_resolution(path, bridge_support_selection_override) + .map(|resolved| resolved.spec) +} + +pub fn read_spec_file_with_bridge_support_resolution( + path: &str, + bridge_support_selection_override: Option<&BridgeSupportSelectionInput>, +) -> CliResult { + let mut input = read_spec_file_input(path)?; + let spec_has_bridge_support_config = + input.spec.bridge_support.is_some() || input.bridge_support_selection.is_some(); + + if let Some(selection) = bridge_support_selection_override { + if spec_has_bridge_support_config { + return Err(format!( + "spec file {path} accepts either file-local bridge support configuration or CLI bridge support selection overrides, not both" + )); + } + let override_selection = resolve_process_relative_bridge_support_selection(selection)?; + input.bridge_support_selection = Some(override_selection); + } + + resolve_spec_file_input(path, input) +} + +fn resolve_process_relative_bridge_support_selection( + selection: &BridgeSupportSelectionInput, +) -> CliResult { + let path = selection + .path + .as_deref() + .map(resolve_process_relative_path) + .transpose()?; + let delta_artifact = selection + .delta_artifact + .as_deref() + .map(resolve_process_relative_path) + .transpose()?; + + Ok(BridgeSupportSelectionInput { + path, + bundled_profile: selection.bundled_profile.clone(), + delta_artifact, + expected_sha256: selection.expected_sha256.clone(), + expected_delta_sha256: selection.expected_delta_sha256.clone(), + }) +} + +fn read_spec_file_input(path: &str) -> CliResult { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read spec file {path}: {error}"))?; + serde_json::from_str(&raw).map_err(|error| format!("failed to parse spec file {path}: {error}")) +} + +fn resolve_spec_file_input( + path: &str, + mut input: RunnerSpecFileInput, +) -> CliResult { + if let Some(selection) = input.bridge_support_selection.take() { + if input.spec.bridge_support.is_some() { + return Err(format!( + "spec file {path} accepts either inline `bridge_support` or `bridge_support_selection`, not both" + )); + } + + let policy_path = selection + .path + .as_deref() + .map(|value| resolve_spec_relative_path(path, value)); + let delta_artifact_path = selection + .delta_artifact + .as_deref() + .map(|value| resolve_spec_relative_path(path, value)); + let resolved = resolve_bridge_support_selection( + policy_path.as_deref(), + selection.bundled_profile.as_deref(), + delta_artifact_path.as_deref(), + selection.expected_sha256.as_deref(), + selection.expected_delta_sha256.as_deref(), + ) + .map_err(|error| { + format!("failed to resolve bridge support selection in {path}: {error}") + })?; + let bridge_support_source = resolved + .as_ref() + .map(|selection| selection.policy.source.clone()); + let bridge_support_delta_source = resolved + .as_ref() + .and_then(|selection| selection.delta_source.clone()); + let bridge_support_delta_sha256 = resolved.as_ref().and_then(|selection| { + selection + .delta_artifact + .as_ref() + .map(|artifact| artifact.sha256.clone()) + }); + input.spec.bridge_support = resolved.map(|selection| selection.policy.profile); + return Ok(ResolvedRunnerSpecFile { + spec: input.spec, + bridge_support_source, + bridge_support_delta_source, + bridge_support_delta_sha256, + }); + } + + let bridge_support_source = input + .spec + .bridge_support + .as_ref() + .map(|_| format!("inline:{path}")); + + Ok(ResolvedRunnerSpecFile { + spec: input.spec, + bridge_support_source, + bridge_support_delta_source: None, + bridge_support_delta_sha256: None, + }) +} + +fn resolve_process_relative_path(value: &str) -> CliResult { + let candidate = Path::new(value); + if candidate.is_absolute() { + return Ok(value.to_owned()); + } + + let current_dir = std::env::current_dir() + .map_err(|error| format!("resolve current directory failed: {error}"))?; + let resolved = current_dir.join(candidate); + + Ok(resolved.display().to_string()) +} + +fn resolve_spec_relative_path(spec_path: &str, value: &str) -> String { + let candidate = Path::new(value); + if candidate.is_absolute() { + return value.to_owned(); + } + + Path::new(spec_path) + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(candidate) + .display() + .to_string() +} + +pub fn write_json_file(path: &str, value: &T) -> CliResult<()> { + let serialized = serde_json::to_string_pretty(value) + .map_err(|error| format!("serialize JSON value for output file failed: {error}"))?; + if let Some(parent) = Path::new(path).parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent) + .map_err(|error| format!("create output directory failed: {error}"))?; + } + fs::write(path, serialized) + .map_err(|error| format!("write JSON output file failed: {error}"))?; + Ok(()) +} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 3b87334f2..17fb76aac 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1107,6 +1107,7 @@ async fn main() { } => run_multi_channel_serve_cli(config.as_deref(), &session, channel_account).await, Commands::Gateway { command } => gateway::service::run_gateway_cli(command).await, Commands::Feishu { command } => feishu_cli::run_feishu_command(command).await, + Commands::Web { command } => web_cli::run_web_command(command).await, Commands::Completions { shell } => { completions_cli::run_completions_cli(completions_cli::CompletionsCommandOptions { shell, diff --git a/crates/daemon/src/web/abilities.rs b/crates/daemon/src/web/abilities.rs new file mode 100644 index 000000000..553ee14fe --- /dev/null +++ b/crates/daemon/src/web/abilities.rs @@ -0,0 +1,404 @@ +use serde_json::Value; + +use super::*; + +pub(super) async fn abilities_personalization( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let payload = build_personalization_payload(&snapshot.config); + + Ok(Json(ApiEnvelope { + ok: true, + data: payload, + })) +} + +pub(super) async fn abilities_personalization_save( + State(state): State>, + Json(request): Json, +) -> Result>, WebApiError> { + let config_path = resolve_web_config_path(state.as_ref()); + let mut config = if config_path.is_file() { + let (_, loaded) = mvp::config::load(state.config_path.as_deref()).map_err(|error| { + WebApiError::bad_request(format!("local config could not be loaded: {error}")) + })?; + loaded + } else { + mvp::config::LoongClawConfig::default() + }; + + let existing_personalization = config.memory.trimmed_personalization(); + let default_personalization = mvp::config::PersonalizationConfig::default(); + let prompt_state = + parse_personalization_prompt_state(request.prompt_state.as_deref().unwrap_or("pending"))?; + let updated_at_epoch_seconds = u64::try_from(OffsetDateTime::now_utc().unix_timestamp()).ok(); + + let personalization = mvp::config::PersonalizationConfig { + preferred_name: normalize_optional_text(request.preferred_name.as_deref()), + response_density: parse_response_density(request.response_density.as_deref())?, + initiative_level: parse_initiative_level(request.initiative_level.as_deref())?, + standing_boundaries: normalize_optional_text(request.standing_boundaries.as_deref()), + timezone: normalize_optional_text(request.timezone.as_deref()), + locale: normalize_optional_text(request.locale.as_deref()), + prompt_state, + schema_version: existing_personalization + .as_ref() + .map(|value| value.schema_version) + .unwrap_or(default_personalization.schema_version), + updated_at_epoch_seconds, + } + .normalized(); + + config.memory.personalization = personalization; + + let path_string = config_path.display().to_string(); + mvp::config::write(Some(path_string.as_str()), &config, true).map_err(WebApiError::internal)?; + + let payload = build_personalization_payload(&config); + let state_label = payload.prompt_state; + record_debug_operation( + &state, + "abilities_personalization", + format!( + "{} personalization updated", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()) + ), + vec![ + format!( + "preferred_name={}", + payload.preferred_name.as_deref().unwrap_or("empty") + ), + format!( + "response_density={}", + payload.response_density.unwrap_or("unset") + ), + format!( + "initiative_level={}", + payload.initiative_level.unwrap_or("unset") + ), + format!("prompt_state={state_label}"), + ], + ); + + Ok(Json(ApiEnvelope { + ok: true, + data: payload, + })) +} + +pub(super) async fn abilities_channels( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let runtime_snapshot = collect_runtime_snapshot(snapshot.resolved_path.as_path())?; + let inventory = &runtime_snapshot.channels.inventory; + let enabled_service_channel_ids = &runtime_snapshot.channels.enabled_service_channel_ids; + + let surfaces = inventory + .channel_surfaces + .iter() + .map(|surface| { + let channel_id = surface.surface.catalog.id.to_owned(); + let service_enabled = enabled_service_channel_ids.contains(&channel_id); + let ready_send_account_count = surface + .surface + .configured_accounts + .iter() + .filter(|account| { + channel_account_operation_is_ready( + account, + mvp::channel::CHANNEL_OPERATION_SEND_ID, + ) + }) + .count(); + let ready_serve_account_count = surface + .surface + .configured_accounts + .iter() + .filter(|account| { + channel_account_operation_is_ready( + account, + mvp::channel::CHANNEL_OPERATION_SERVE_ID, + ) + }) + .count(); + + AbilitiesChannelSurfacePayload { + id: channel_id, + label: surface.surface.catalog.label.to_owned(), + source: surface.surface.catalog.implementation_status.as_str(), + configured_account_count: surface.surface.configured_accounts.len(), + enabled_account_count: surface + .surface + .configured_accounts + .iter() + .filter(|account| account.enabled) + .count(), + misconfigured_account_count: surface + .surface + .configured_accounts + .iter() + .filter(|account| channel_account_is_misconfigured(account)) + .count(), + ready_send_account_count, + ready_serve_account_count, + default_configured_account_id: surface + .surface + .default_configured_account_id + .clone(), + service_enabled, + service_ready: service_enabled && ready_serve_account_count > 0, + } + }) + .collect::>(); + + Ok(Json(ApiEnvelope { + ok: true, + data: AbilitiesChannelsPayload { + catalog_channel_count: inventory.channel_catalog.len(), + configured_channel_count: inventory + .channel_surfaces + .iter() + .filter(|surface| !surface.surface.configured_accounts.is_empty()) + .count(), + configured_account_count: inventory.channels.len(), + enabled_account_count: inventory + .channels + .iter() + .filter(|account| account.enabled) + .count(), + misconfigured_account_count: inventory + .channels + .iter() + .filter(|account| channel_account_is_misconfigured(account)) + .count(), + runtime_backed_channel_count: inventory + .channel_catalog + .iter() + .filter(|channel| { + channel.implementation_status + == mvp::channel::ChannelCatalogImplementationStatus::RuntimeBacked + }) + .count(), + enabled_service_channel_count: enabled_service_channel_ids.len(), + ready_service_channel_count: surfaces + .iter() + .filter(|surface| surface.service_ready) + .count(), + surfaces, + }, + })) +} + +pub(super) async fn abilities_skills( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let runtime_snapshot = collect_runtime_snapshot(snapshot.resolved_path.as_path())?; + + let browser_companion = json_object_field(&runtime_snapshot.tool_runtime, "browser_companion"); + let external_skills = json_object_field(&runtime_snapshot.external_skills, "policy"); + + Ok(Json(ApiEnvelope { + ok: true, + data: AbilitiesSkillsPayload { + visible_runtime_tool_count: runtime_snapshot.tools.visible_tool_count, + visible_runtime_tools: runtime_snapshot.tools.visible_tool_names.clone(), + browser_companion: AbilitiesBrowserCompanionPayload { + enabled: json_bool_field(browser_companion, "enabled"), + ready: json_bool_field(browser_companion, "ready"), + command_configured: json_string_option_field(browser_companion, "command") + .is_some(), + expected_version: json_string_option_field(browser_companion, "expected_version"), + execution_tier: json_string_field(browser_companion, "execution_tier") + .unwrap_or("unknown") + .to_owned(), + timeout_seconds: json_u64_field(browser_companion, "timeout_seconds").unwrap_or(0), + }, + external_skills: AbilitiesExternalSkillsPayload { + enabled: json_bool_field(external_skills, "enabled"), + override_active: json_bool_field( + &runtime_snapshot.external_skills, + "override_active", + ), + inventory_status: json_string_field( + &runtime_snapshot.external_skills, + "inventory_status", + ) + .unwrap_or("unknown") + .to_owned(), + inventory_error: json_string_option_field( + &runtime_snapshot.external_skills, + "inventory_error", + ), + require_download_approval: json_bool_field( + external_skills, + "require_download_approval", + ), + auto_expose_installed: json_bool_field(external_skills, "auto_expose_installed"), + install_root: json_string_option_field(external_skills, "install_root"), + allowed_domain_count: json_array_len(external_skills, "allowed_domains"), + blocked_domain_count: json_array_len(external_skills, "blocked_domains"), + resolved_skill_count: json_usize_field( + &runtime_snapshot.external_skills, + "resolved_skill_count", + ) + .unwrap_or(0), + shadowed_skill_count: json_usize_field( + &runtime_snapshot.external_skills, + "shadowed_skill_count", + ) + .unwrap_or(0), + }, + }, + })) +} + +fn collect_runtime_snapshot( + resolved_path: &FsPath, +) -> Result { + let path_string = resolved_path.display().to_string(); + let snapshot = crate::collect_runtime_snapshot_cli_state(Some(path_string.as_str())) + .map_err(WebApiError::internal)?; + Ok(crate::gateway::read_models::build_runtime_snapshot_read_model(&snapshot)) +} + +fn build_personalization_payload( + config: &mvp::config::LoongClawConfig, +) -> AbilitiesPersonalizationPayload { + match config.memory.trimmed_personalization() { + Some(personalization) => AbilitiesPersonalizationPayload { + configured: true, + has_operator_preferences: personalization.has_operator_preferences(), + suppressed: personalization.suppresses_suggestions(), + prompt_state: match personalization.prompt_state { + mvp::config::PersonalizationPromptState::Pending => "pending", + mvp::config::PersonalizationPromptState::Deferred => "deferred", + mvp::config::PersonalizationPromptState::Suppressed => "suppressed", + mvp::config::PersonalizationPromptState::Configured => "configured", + }, + updated_at: personalization + .updated_at_epoch_seconds + .map(|value| format_timestamp(value as i64)), + preferred_name: personalization.preferred_name, + response_density: personalization + .response_density + .map(mvp::config::ResponseDensity::as_str), + initiative_level: personalization + .initiative_level + .map(mvp::config::InitiativeLevel::as_str), + standing_boundaries: personalization.standing_boundaries, + locale: personalization.locale, + timezone: personalization.timezone, + }, + None => AbilitiesPersonalizationPayload { + configured: false, + has_operator_preferences: false, + suppressed: false, + prompt_state: "pending", + updated_at: None, + preferred_name: None, + response_density: None, + initiative_level: None, + standing_boundaries: None, + locale: None, + timezone: None, + }, + } +} + +fn normalize_optional_text(raw: Option<&str>) -> Option { + raw.map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn parse_response_density( + raw: Option<&str>, +) -> Result, WebApiError> { + match raw.map(str::trim).filter(|value| !value.is_empty()) { + Some("concise") => Ok(Some(mvp::config::ResponseDensity::Concise)), + Some("balanced") => Ok(Some(mvp::config::ResponseDensity::Balanced)), + Some("thorough") => Ok(Some(mvp::config::ResponseDensity::Thorough)), + Some(other) => Err(WebApiError::bad_request(format!( + "unknown response density `{other}`" + ))), + None => Ok(None), + } +} + +fn parse_initiative_level( + raw: Option<&str>, +) -> Result, WebApiError> { + match raw.map(str::trim).filter(|value| !value.is_empty()) { + Some("ask_before_acting") => Ok(Some(mvp::config::InitiativeLevel::AskBeforeActing)), + Some("balanced") => Ok(Some(mvp::config::InitiativeLevel::Balanced)), + Some("high_initiative") => Ok(Some(mvp::config::InitiativeLevel::HighInitiative)), + Some(other) => Err(WebApiError::bad_request(format!( + "unknown initiative level `{other}`" + ))), + None => Ok(None), + } +} + +fn parse_personalization_prompt_state( + raw: &str, +) -> Result { + match raw.trim() { + "pending" => Ok(mvp::config::PersonalizationPromptState::Pending), + "deferred" => Ok(mvp::config::PersonalizationPromptState::Deferred), + "suppressed" => Ok(mvp::config::PersonalizationPromptState::Suppressed), + "configured" => Ok(mvp::config::PersonalizationPromptState::Configured), + other => Err(WebApiError::bad_request(format!( + "unknown prompt state `{other}`" + ))), + } +} + +fn json_object_field<'a>(value: &'a Value, key: &str) -> &'a Value { + value.get(key).unwrap_or(&Value::Null) +} + +fn json_bool_field(value: &Value, key: &str) -> bool { + value.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn json_string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn json_string_option_field(value: &Value, key: &str) -> Option { + json_string_field(value, key).map(ToOwned::to_owned) +} + +fn json_u64_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_u64) +} + +fn json_usize_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_u64) + .map(|raw| raw as usize) +} + +fn json_array_len(value: &Value, key: &str) -> usize { + value.get(key).and_then(Value::as_array).map_or(0, Vec::len) +} + +fn channel_account_is_misconfigured(account: &mvp::channel::ChannelStatusSnapshot) -> bool { + account + .operations + .iter() + .any(|operation| operation.health == mvp::channel::ChannelOperationHealth::Misconfigured) +} + +fn channel_account_operation_is_ready( + account: &mvp::channel::ChannelStatusSnapshot, + operation_id: &str, +) -> bool { + account + .operation(operation_id) + .is_some_and(|operation| operation.health == mvp::channel::ChannelOperationHealth::Ready) +} diff --git a/crates/daemon/src/web/auth.rs b/crates/daemon/src/web/auth.rs new file mode 100644 index 000000000..2e5e0b551 --- /dev/null +++ b/crates/daemon/src/web/auth.rs @@ -0,0 +1,156 @@ +use super::*; + +pub(super) async fn require_local_token( + State(state): State>, + request: Request, + next: Next, +) -> Result { + if request.method() == Method::OPTIONS { + return Ok(next.run(request).await); + } + + let token = extract_request_token(request.headers()); + if request_is_authenticated(state.as_ref(), token.as_deref()) { + return Ok(next.run(request).await); + } + + if state.web_install_mode == "same_origin_static" { + return Err(WebApiError::unauthorized( + "Local Web session required. Open the same-origin Web surface again to refresh the session.", + )); + } + + Err(WebApiError::unauthorized(format!( + "Local API token required. Read it from `{}` or set `{WEB_API_TOKEN_ENV}`.", + state.local_token_path.display() + ))) +} + +pub(super) async fn require_same_origin_write_origin( + State(state): State>, + request: Request, + next: Next, +) -> Result { + if request.method() == Method::OPTIONS || request.method() == Method::GET { + return Ok(next.run(request).await); + } + + if state.web_install_mode != "same_origin_static" { + return Ok(next.run(request).await); + } + + if !request_matches_exact_origin(state.as_ref(), request.headers()) { + return Err(WebApiError::forbidden( + "same-origin Web writes require the daemon's exact local origin", + )); + } + + Ok(next.run(request).await) +} + +pub(super) fn extract_request_token(headers: &HeaderMap) -> Option { + if let Some(raw) = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(raw.to_owned()); + } + + headers + .get("x-loongclaw-token") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + headers + .get(COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(extract_any_web_cookie_token) + }) +} + +fn extract_any_web_cookie_token(raw_cookie: &str) -> Option { + raw_cookie + .split(';') + .map(str::trim) + .filter_map(|segment| segment.split_once('=')) + .find_map(|(name, value)| { + matches!(name.trim(), WEB_API_PAIRING_COOKIE | WEB_API_SESSION_COOKIE) + .then(|| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +pub(super) fn request_is_authenticated(state: &WebApiState, token: Option<&str>) -> bool { + token == Some(state.local_token.as_str()) +} + +pub(super) fn extract_allowed_local_origin(headers: &HeaderMap) -> Option { + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| is_allowed_local_origin(value)) + .map(ToOwned::to_owned) +} + +fn request_matches_exact_origin(state: &WebApiState, headers: &HeaderMap) -> bool { + let Some(expected_origin) = state.exact_origin.as_deref() else { + return false; + }; + + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + == Some(expected_origin) +} + +fn is_allowed_local_origin(origin: &str) -> bool { + let Ok(url) = reqwest::Url::parse(origin) else { + return false; + }; + + matches!(url.scheme(), "http" | "https") + && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) +} + +pub(super) fn build_pairing_cookie(token: &str) -> Result { + HeaderValue::from_str(&format!( + "{WEB_API_PAIRING_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000" + )) + .map_err(|error| WebApiError::internal(format!("build pairing cookie failed: {error}"))) +} + +pub(super) fn build_clear_pairing_cookie() -> Result { + HeaderValue::from_str(&format!( + "{WEB_API_PAIRING_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + )) + .map_err(|error| WebApiError::internal(format!("build pairing cookie clear failed: {error}"))) +} + +pub(super) fn build_same_origin_session_cookie(token: &str) -> Result { + HeaderValue::from_str(&format!( + "{WEB_API_SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000" + )) + .map_err(|error| { + WebApiError::internal(format!("build same-origin session cookie failed: {error}")) + }) +} + +pub(super) fn build_clear_same_origin_session_cookie() -> Result { + HeaderValue::from_str(&format!( + "{WEB_API_SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + )) + .map_err(|error| { + WebApiError::internal(format!( + "build same-origin session cookie clear failed: {error}" + )) + }) +} diff --git a/crates/daemon/src/web/chat.rs b/crates/daemon/src/web/chat.rs new file mode 100644 index 000000000..98bd8951b --- /dev/null +++ b/crates/daemon/src/web/chat.rs @@ -0,0 +1,158 @@ +use super::*; + +pub(super) async fn chat_sessions( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let items = snapshot + .sessions + .iter() + .map(|session| ChatSessionItemPayload { + id: session.id.clone(), + title: session.title.clone(), + updated_at: format_timestamp(session.latest_turn_ts), + }) + .collect(); + + Ok(Json(ApiEnvelope { + ok: true, + data: ChatSessionsPayload { items }, + })) +} + +pub(super) async fn create_chat_session( + Json(payload): Json, +) -> Json> { + let session_id = payload + .title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(session_id_from_title) + .unwrap_or_else(generate_session_id); + + Json(ApiEnvelope { + ok: true, + data: CreateChatSessionPayload { session_id }, + }) +} + +pub(super) async fn chat_history( + State(state): State>, + Path(id): Path, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let history = load_visible_session_messages(&snapshot.memory_config, &id, 128, 256)?; + + if history.is_empty() { + return Err(WebApiError::not_found(format!( + "session `{id}` was not found in sqlite memory" + ))); + } + + let messages = history + .into_iter() + .enumerate() + .map(|(index, turn)| ChatMessagePayload { + id: format!("{id}:{index}"), + role: turn.role, + content: turn.content, + created_at: format_timestamp(turn.ts), + }) + .collect(); + + Ok(Json(ApiEnvelope { + ok: true, + data: ChatHistoryPayload { + session_id: id, + messages, + }, + })) +} + +pub(super) async fn delete_chat_session( + State(state): State>, + Path(id): Path, +) -> Result { + let snapshot = load_web_snapshot(state.as_ref())?; + mvp::memory::clear_session_direct(&id, &snapshot.memory_config) + .map_err(WebApiError::internal)?; + Ok(StatusCode::NO_CONTENT) +} + +pub(super) async fn chat_turn( + State(state): State>, + Path(id): Path, + Json(payload): Json, +) -> Result>, WebApiError> { + let input = payload.input.trim(); + if input.is_empty() { + return Err(WebApiError { + status: StatusCode::BAD_REQUEST, + code: "invalid_request", + message: "chat turn input must not be empty".to_owned(), + }); + } + + let turn_id = generate_turn_id(); + let (sender, receiver) = mpsc::unbounded_channel(); + let now = OffsetDateTime::now_utc().unix_timestamp(); + + { + let mut streams = state.turn_streams.lock().await; + // GC: Clear out unconsumed streams older than 60 seconds to prevent memory leaks + streams.retain(|_, (ts, _)| now - *ts < 60); + streams.insert(turn_id.clone(), (now, receiver)); + } + + let state_for_turn = state.clone(); + let session_id = id.clone(); + let turn_id_for_task = turn_id.clone(); + let input_owned = input.to_owned(); + tokio::spawn(async move { + let _ = run_chat_turn_stream( + state_for_turn, + session_id, + turn_id_for_task, + input_owned, + sender, + ) + .await; + }); + + Ok(Json(ApiEnvelope { + ok: true, + data: ChatTurnPayload { + session_id: id, + turn_id, + status: "accepted", + }, + })) +} + +pub(super) async fn chat_turn_stream( + State(state): State>, + Path((_session_id, turn_id)): Path<(String, String)>, +) -> Result { + let receiver = state + .turn_streams + .lock() + .await + .remove(&turn_id) + .map(|(_, rx)| rx) + .ok_or_else(|| WebApiError::not_found(format!("turn `{turn_id}` was not found")))?; + + let body_stream = stream::unfold(receiver, |mut receiver| async move { + receiver + .recv() + .await + .map(|line| (Ok::(format!("{line}\n")), receiver)) + }); + + let mut response = Response::new(Body::from_stream(body_stream)); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/x-ndjson"), + ); + Ok(response) +} diff --git a/crates/daemon/src/web/dashboard.rs b/crates/daemon/src/web/dashboard.rs new file mode 100644 index 000000000..4ba3ef41e --- /dev/null +++ b/crates/daemon/src/web/dashboard.rs @@ -0,0 +1,203 @@ +use super::*; + +pub(super) async fn dashboard_summary( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + Ok(Json(ApiEnvelope { + ok: true, + data: DashboardSummaryPayload { + runtime_status: "ready", + active_provider: snapshot.config.active_provider_id().map(str::to_owned), + active_model: snapshot.config.provider.model.clone(), + memory_backend: "sqlite", + session_count: snapshot.sessions.len(), + web_install_mode: state.web_install_mode, + }, + })) +} + +pub(super) async fn dashboard_providers( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + Ok(Json(ApiEnvelope { + ok: true, + data: DashboardProvidersPayload { + active_provider: snapshot.config.active_provider_id().map(str::to_owned), + items: build_provider_items(&snapshot.config), + }, + })) +} + +pub(super) async fn provider_catalog() +-> Result>, WebApiError> { + Ok(Json(ApiEnvelope { + ok: true, + data: ProviderCatalogPayload { + items: build_provider_catalog_items(), + }, + })) +} + +pub(super) async fn dashboard_runtime( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + Ok(Json(ApiEnvelope { + ok: true, + data: DashboardRuntimePayload { + status: "ready", + source: "local_daemon", + config_path: snapshot.resolved_path.display().to_string(), + memory_backend: snapshot.config.memory.resolved_backend().as_str(), + memory_mode: snapshot.config.memory.resolved_mode().as_str(), + ingest_mode: snapshot.config.memory.ingest_mode.as_str(), + web_install_mode: state.web_install_mode, + active_provider: snapshot.config.active_provider_id().map(str::to_owned), + active_model: snapshot.config.provider.model.clone(), + acp_enabled: snapshot.config.acp.enabled, + strict_memory: !snapshot.config.memory.effective_fail_open(), + }, + })) +} + +pub(super) async fn dashboard_connectivity( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let endpoint = snapshot.config.provider.endpoint(); + let parsed = reqwest::Url::parse(&endpoint).map_err(|error| { + WebApiError::internal(format!( + "parse provider endpoint for connectivity failed: {error}" + )) + })?; + let host = parsed + .host_str() + .ok_or_else(|| WebApiError::internal("provider endpoint host was missing"))? + .to_owned(); + let port = parsed.port_or_known_default().unwrap_or(443); + let dns_addresses = resolve_provider_host_addresses(host.as_str(), port).await; + let fake_ip_detected = dns_addresses + .iter() + .any(|address| is_fake_ip_address(address)); + let proxy_env_detected = has_proxy_environment(); + let (probe_status, probe_status_code) = probe_provider_endpoint(endpoint.as_str()).await; + let degraded = fake_ip_detected || probe_status != "reachable"; + let recommendation = if fake_ip_detected { + Some("direct_host_and_fake_ip_filter") + } else if probe_status != "reachable" { + Some("check_network_route") + } else { + None + }; + + Ok(Json(ApiEnvelope { + ok: true, + data: DashboardConnectivityPayload { + status: if degraded { "degraded" } else { "healthy" }, + endpoint, + host, + dns_addresses, + probe_status, + probe_status_code, + fake_ip_detected, + proxy_env_detected, + recommendation, + }, + })) +} + +pub(super) async fn dashboard_config( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let active_provider = build_provider_items(&snapshot.config) + .into_iter() + .find(|item| item.enabled); + + Ok(Json(ApiEnvelope { + ok: true, + data: DashboardConfigPayload { + active_provider: snapshot.config.active_provider_id().map(str::to_owned), + last_provider: snapshot.config.last_provider.clone(), + model: snapshot.config.provider.model.clone(), + provider_base_url: snapshot.config.provider.resolved_base_url(), + provider_endpoint_explicit: snapshot.config.provider.endpoint_explicit + && snapshot + .config + .provider + .endpoint + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()), + endpoint: snapshot.config.provider.endpoint(), + api_key_configured: active_provider + .as_ref() + .map(|item| item.api_key_configured) + .unwrap_or(false), + api_key_masked: active_provider.and_then(|item| item.api_key_masked), + personality: prompt_personality_id(snapshot.config.cli.resolved_personality()) + .to_owned(), + prompt_mode: if snapshot.config.cli.uses_native_prompt_pack() { + "native_prompt_pack" + } else { + "inline_prompt" + }, + prompt_addendum_configured: snapshot + .config + .cli + .system_prompt_addendum + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()), + prompt_addendum: snapshot + .config + .cli + .system_prompt_addendum + .clone() + .unwrap_or_default(), + memory_profile: snapshot + .config + .memory + .resolved_profile() + .as_str() + .to_owned(), + memory_system: snapshot.config.memory.resolved_system().as_str(), + sqlite_path: snapshot + .config + .memory + .resolved_sqlite_path() + .display() + .to_string(), + file_root: snapshot + .config + .tools + .resolved_file_root() + .display() + .to_string(), + sliding_window: snapshot.config.memory.sliding_window, + summary_max_chars: snapshot.config.memory.summary_max_chars, + }, + })) +} + +pub(super) async fn dashboard_tools( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let tool_runtime = mvp::tools::runtime_config::ToolRuntimeConfig::from_loongclaw_config( + &snapshot.config, + None, + ); + Ok(Json(ApiEnvelope { + ok: true, + data: DashboardToolsPayload { + approval_mode: approval_mode_label(snapshot.config.tools.approval.mode).to_owned(), + shell_default_mode: snapshot.config.tools.shell_default_mode.clone(), + shell_allow_count: snapshot.config.tools.shell_allow.len(), + shell_deny_count: snapshot.config.tools.shell_deny.len(), + items: build_tool_items(&snapshot.config, &tool_runtime), + }, + })) +} diff --git a/crates/daemon/src/web/debug_console.rs b/crates/daemon/src/web/debug_console.rs new file mode 100644 index 000000000..e1e929101 --- /dev/null +++ b/crates/daemon/src/web/debug_console.rs @@ -0,0 +1,433 @@ +use super::*; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DashboardDebugConsolePayload { + generated_at: String, + command: String, + blocks: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardDebugConsoleBlockPayload { + id: String, + kind: &'static str, + started_at: String, + header: String, + lines: Vec, +} + +pub(super) async fn dashboard_debug_console( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + let tool_runtime = mvp::tools::runtime_config::ToolRuntimeConfig::from_loongclaw_config( + &snapshot.config, + None, + ); + let debug_state = snapshot_debug_state(state.as_ref()); + Ok(Json(ApiEnvelope { + ok: true, + data: DashboardDebugConsolePayload { + generated_at: format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + command: "$ loongclaw web debug --readonly".to_owned(), + blocks: build_debug_console_blocks(&snapshot, &tool_runtime, &debug_state), + }, + })) +} + +fn build_debug_console_blocks( + snapshot: &WebSnapshot, + runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, + debug_state: &DebugConsoleRuntimeState, +) -> Vec { + let now = format_timestamp(OffsetDateTime::now_utc().unix_timestamp()); + let active_provider = snapshot.config.active_provider_id().unwrap_or("none"); + let active_model = snapshot.config.provider.model.as_str(); + let enabled_tool_count = build_tool_items(&snapshot.config, runtime) + .into_iter() + .filter(|item| item.enabled) + .count(); + + let mut blocks = vec![ + build_turn_summary_block( + &now, + snapshot, + debug_state, + active_provider, + active_model, + enabled_tool_count, + ), + build_recent_tool_activity_block(&now, debug_state), + build_last_failure_block(&now, debug_state), + ]; + + if let Some(log_block) = build_log_output_block() { + blocks.push(log_block); + } + + blocks.push(build_raw_events_block(&now, debug_state)); + + blocks +} + +fn build_turn_summary_block( + now: &str, + snapshot: &WebSnapshot, + debug_state: &DebugConsoleRuntimeState, + active_provider: &str, + active_model: &str, + enabled_tool_count: usize, +) -> DashboardDebugConsoleBlockPayload { + let latest_turn = debug_state + .recent_blocks + .iter() + .rev() + .find(|block| block.kind == "turn"); + + let status = latest_turn.map(|turn| turn.status).unwrap_or("idle"); + let turn_id = latest_turn + .map(|turn| turn.id.trim_start_matches("turn:")) + .unwrap_or("none"); + let session_id = latest_turn + .and_then(|turn| turn.session_id.as_deref()) + .unwrap_or("none"); + let mut lines = vec![ + format!("[turn] status={status} session={session_id} turn={turn_id}"), + format!("[provider] ready kind={active_provider} model={active_model}"), + format!( + "[tools] active={} recent={} enabled={}", + debug_state.active_tool_starts.len(), + debug_state.recent_tool_activity.len(), + enabled_tool_count + ), + format!( + "[memory] profile={} window={} summary_max_chars={}", + snapshot.config.memory.resolved_profile().as_str(), + snapshot.config.memory.sliding_window, + snapshot.config.memory.summary_max_chars + ), + ]; + + if let Some(turn) = latest_turn { + let first_token = turn + .first_delta_at_ms + .map(|at| format_duration_ms(at.saturating_sub(turn.started_at_ms))) + .unwrap_or_else(|| "n/a".to_owned()); + let total = turn + .finished_at_ms + .map(|at| format_duration_ms(at.saturating_sub(turn.started_at_ms))) + .unwrap_or_else(|| "in_progress".to_owned()); + lines.push(format!( + "[latency] first_token={first_token} total={total} tool_calls={}", + turn.tool_calls + )); + } + + lines.push(format!( + "[hint] {}", + latest_turn + .map(turn_summary_hint) + .unwrap_or("idle and waiting for the next turn") + )); + + DashboardDebugConsoleBlockPayload { + id: "turn-summary".to_owned(), + kind: "summary", + started_at: now.to_owned(), + header: format!("{now} TURN SUMMARY"), + lines, + } +} + +fn build_recent_tool_activity_block( + now: &str, + debug_state: &DebugConsoleRuntimeState, +) -> DashboardDebugConsoleBlockPayload { + let mut lines = debug_state + .recent_tool_activity + .iter() + .rev() + .take(5) + .rev() + .map(|activity| { + let duration = activity + .duration_ms + .map(|value| format!(" duration={}", format_duration_ms(value))) + .unwrap_or_default(); + format!( + "[tool] {} status={}{} detail={}", + activity.label, activity.outcome, duration, activity.detail + ) + }) + .collect::>(); + if lines.is_empty() { + lines.push("[tool] none recent_tool_activity=empty".to_owned()); + } + DashboardDebugConsoleBlockPayload { + id: "recent-tool-activity".to_owned(), + kind: "tools", + started_at: now.to_owned(), + header: format!("{now} RECENT TOOL ACTIVITY"), + lines, + } +} + +fn build_last_failure_block( + now: &str, + debug_state: &DebugConsoleRuntimeState, +) -> DashboardDebugConsoleBlockPayload { + let lines = if let Some(failure) = debug_state.last_failure.as_ref() { + vec![ + format!("[error] category={} at={}", failure.category, failure.at), + format!("[detail] {}", failure.detail), + format!("[hint] {}", failure.hint), + ] + } else { + vec![ + "[error] none recent_failure=none".to_owned(), + "[hint] no recent failure was recorded".to_owned(), + ] + }; + DashboardDebugConsoleBlockPayload { + id: "last-failure".to_owned(), + kind: "error", + started_at: now.to_owned(), + header: format!("{now} LAST FAILURE"), + lines, + } +} + +fn build_raw_events_block( + now: &str, + debug_state: &DebugConsoleRuntimeState, +) -> DashboardDebugConsoleBlockPayload { + let mut raw_lines = debug_state + .recent_blocks + .iter() + .flat_map(|block| block.lines.iter().cloned()) + .collect::>(); + if raw_lines.len() > 18 { + raw_lines.drain(0..(raw_lines.len() - 18)); + } + let lines = if raw_lines.is_empty() { + vec!["[event] none raw_event_buffer=empty".to_owned()] + } else { + raw_lines + .into_iter() + .map(|line| format!("[event] {line}")) + .collect() + }; + DashboardDebugConsoleBlockPayload { + id: "raw-events".to_owned(), + kind: "events", + started_at: now.to_owned(), + header: format!("{now} RAW EVENTS"), + lines, + } +} + +fn format_duration_ms(duration_ms: i64) -> String { + if duration_ms < 1_000 { + format!("{duration_ms}ms") + } else { + format!("{:.2}s", duration_ms as f64 / 1_000.0) + } +} + +fn turn_summary_hint(turn: &DebugConsoleBlock) -> &'static str { + match turn.status { + "running" if turn.tool_calls > 0 && turn.first_delta_at_ms.is_none() => { + "waiting_for_tool_result" + } + "running" if turn.first_delta_at_ms.is_some() => "streaming_response", + "running" => "thinking", + "completed" => "turn_completed", + "failed" => "review_last_failure", + _ => "idle and waiting for the next turn", + } +} + +fn snapshot_debug_state(state: &WebApiState) -> DebugConsoleRuntimeState { + let Ok(debug) = state.debug_state.lock() else { + return DebugConsoleRuntimeState::default(); + }; + debug.clone() +} + +fn build_log_output_block() -> Option { + let mut lines = Vec::new(); + append_log_tail( + &mut lines, + "web-api", + default_web_log_root().join("web-api.log"), + 10, + ); + append_log_tail( + &mut lines, + "web-api:err", + default_web_log_root().join("web-api.err.log"), + 8, + ); + append_log_tail( + &mut lines, + "web-dev", + default_web_log_root().join("web-dev.log"), + 8, + ); + append_log_tail( + &mut lines, + "web-dev:err", + default_web_log_root().join("web-dev.err.log"), + 8, + ); + let lines = normalize_process_output_lines(lines); + + (!lines.is_empty()).then(|| DashboardDebugConsoleBlockPayload { + id: "process-output".to_owned(), + kind: "logs", + started_at: format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + header: format!( + "{} process output", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()) + ), + lines, + }) +} + +fn normalize_process_output_lines(lines: Vec) -> Vec { + let mut filtered = Vec::with_capacity(lines.len()); + let mut suppressed_optional_repo_note_warnings = 0usize; + + for line in lines { + if is_optional_repo_note_probe_warning(line.as_str()) { + suppressed_optional_repo_note_warnings += 1; + continue; + } + filtered.push(line); + } + + if suppressed_optional_repo_note_warnings > 0 { + filtered.insert( + 0, + format!( + "[web-api:noise] suppressed={} optional repo note lookup warnings", + suppressed_optional_repo_note_warnings + ), + ); + } + + filtered +} + +fn is_optional_repo_note_probe_warning(line: &str) -> bool { + let normalized = line.to_ascii_lowercase(); + normalized.contains("[web-api:err]") + && normalized.contains("requested_tool_name=file.read") + && normalized.contains("canonical_tool_name=file.read") + && normalized.contains("os error 2") + && ["tools.md", "soul.md", "identity.md", "user.md"] + .iter() + .any(|needle| normalized.contains(needle)) +} + +fn default_web_log_root() -> PathBuf { + mvp::config::default_loongclaw_home().join("logs") +} + +const LOG_TAIL_READ_BYTES: u64 = 128 * 1024; + +fn append_log_tail(lines: &mut Vec, label: &str, path: PathBuf, max_lines: usize) { + match read_log_tail_lines(path.as_path(), max_lines) { + Ok(entries) if entries.is_empty() => {} + Ok(entries) => { + lines.extend( + entries + .into_iter() + .map(|entry| format!("[{label}] {entry}")), + ); + } + Err(message) => lines.push(format!("[{label}] unavailable {message}")), + } +} + +fn read_log_tail_lines(path: &std::path::Path, max_lines: usize) -> Result, String> { + if !path.exists() { + return Ok(vec!["(missing)".to_owned()]); + } + + let file_size = fs::metadata(path).map_err(|error| error.to_string())?.len(); + let read_start = file_size.saturating_sub(LOG_TAIL_READ_BYTES); + let mut file = std::fs::File::open(path).map_err(|error| error.to_string())?; + std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(read_start)) + .map_err(|error| error.to_string())?; + + let mut bytes = Vec::with_capacity((file_size - read_start) as usize); + std::io::Read::read_to_end(&mut file, &mut bytes).map_err(|error| error.to_string())?; + + let normalized = String::from_utf8_lossy(&bytes).replace('\r', ""); + let tail = if read_start > 0 { + normalized + .find('\n') + .map(|index| &normalized[index + 1..]) + .unwrap_or(normalized.as_str()) + } else { + normalized.as_str() + }; + + let lines = tail + .lines() + .rev() + .take(max_lines) + .map(strip_ansi_escape_codes) + .collect::>(); + Ok(lines.into_iter().rev().collect()) +} + +fn strip_ansi_escape_codes(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let chars: Vec = input.chars().collect(); + let mut index = 0usize; + + while index < chars.len() { + if chars.get(index).copied() == Some('\u{1b}') { + index += 1; + if chars.get(index).copied() == Some('[') { + index += 1; + while index < chars.len() { + let ch = chars.get(index).copied().unwrap_or_default(); + index += 1; + if ('@'..='~').contains(&ch) { + break; + } + } + continue; + } + continue; + } + + if let Some(&ch) = chars.get(index) { + output.push(ch); + } + index += 1; + } + + output +} + +pub(super) fn record_debug_operation( + state: &Arc, + kind: &'static str, + title: String, + lines: Vec, +) { + let Ok(mut debug) = state.debug_state.lock() else { + return; + }; + let at = format_timestamp(OffsetDateTime::now_utc().unix_timestamp()); + let mut block = + DebugConsoleBlock::operation(format!("{kind}:{at}:{}", random::()), kind, title); + block.lines = lines; + push_debug_block(&mut debug.recent_blocks, block); +} diff --git a/crates/daemon/src/web/install.rs b/crates/daemon/src/web/install.rs new file mode 100644 index 000000000..914f93b35 --- /dev/null +++ b/crates/daemon/src/web/install.rs @@ -0,0 +1,215 @@ +use super::*; + +pub(super) fn default_web_install_dir() -> PathBuf { + mvp::config::default_loongclaw_home().join("web") +} + +pub(super) fn web_install_dist_dir(install_dir: &FsPath) -> PathBuf { + install_dir.join("dist") +} + +fn web_install_manifest_path(install_dir: &FsPath) -> PathBuf { + install_dir.join("install.json") +} + +fn copy_dir_all(src: &FsPath, dst: &FsPath) -> CliResult<()> { + for entry in + fs::read_dir(src).map_err(|error| format!("failed to read `{}`: {error}", src.display()))? + { + let entry = entry.map_err(|error| format!("failed to read directory entry: {error}"))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + fs::create_dir_all(&dst_path) + .map_err(|error| format!("failed to create `{}`: {error}", dst_path.display()))?; + copy_dir_all(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path).map_err(|error| { + format!( + "failed to copy `{}` to `{}`: {error}", + src_path.display(), + dst_path.display() + ) + })?; + } + } + Ok(()) +} + +pub(super) fn run_web_install(source: &str) -> CliResult<()> { + let source_path = PathBuf::from(source); + if !source_path.exists() { + return Err(format!( + "source path `{}` does not exist", + source_path.display() + )); + } + if !source_path.is_dir() { + return Err(format!( + "source path `{}` is not a directory", + source_path.display() + )); + } + if !source_path.join("index.html").is_file() { + return Err(format!( + "source path `{}` is missing `index.html` — run `npm run build` first", + source_path.display() + )); + } + + let install_dir = default_web_install_dir(); + let dist_dir = web_install_dist_dir(&install_dir); + let staging_dir = install_dir.join(format!("dist.staging-{}", random::())); + let backup_dir = install_dir.join(format!("dist.backup-{}", random::())); + + fs::create_dir_all(&install_dir).map_err(|error| { + format!( + "failed to create install root `{}`: {error}", + install_dir.display() + ) + })?; + + if staging_dir.exists() { + fs::remove_dir_all(&staging_dir).map_err(|error| { + format!( + "failed to clear staging install `{}`: {error}", + staging_dir.display() + ) + })?; + } + fs::create_dir_all(&staging_dir).map_err(|error| { + format!( + "failed to create staging install directory `{}`: {error}", + staging_dir.display() + ) + })?; + + if let Err(error) = copy_dir_all(&source_path, &staging_dir) { + let _ = fs::remove_dir_all(&staging_dir); + return Err(error); + } + + let promote_result: CliResult<()> = (|| { + if dist_dir.exists() { + if backup_dir.exists() { + fs::remove_dir_all(&backup_dir).map_err(|error| { + format!( + "failed to clear previous backup install `{}`: {error}", + backup_dir.display() + ) + })?; + } + fs::rename(&dist_dir, &backup_dir).map_err(|error| { + format!( + "failed to stage existing install `{}`: {error}", + dist_dir.display() + ) + })?; + } + + if let Err(error) = fs::rename(&staging_dir, &dist_dir) { + if backup_dir.exists() && !dist_dir.exists() { + let _ = fs::rename(&backup_dir, &dist_dir); + } + return Err(format!( + "failed to promote staged install `{}`: {error}", + staging_dir.display() + )); + } + + if backup_dir.exists() { + fs::remove_dir_all(&backup_dir).map_err(|error| { + format!( + "failed to remove previous install backup `{}`: {error}", + backup_dir.display() + ) + })?; + } + Ok(()) + })(); + + if let Err(error) = promote_result { + let _ = fs::remove_dir_all(&staging_dir); + return Err(error); + } + + let canonical_source = source_path + .canonicalize() + .unwrap_or_else(|_| source_path.clone()); + let manifest = WebInstallManifest { + installed_at: OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_default(), + source_path: canonical_source.display().to_string(), + install_dir: install_dir.display().to_string(), + }; + let manifest_json = serde_json::to_string_pretty(&manifest) + .map_err(|error| format!("failed to serialize install manifest: {error}"))?; + fs::write(web_install_manifest_path(&install_dir), manifest_json) + .map_err(|error| format!("failed to write install manifest: {error}"))?; + + println!("Web Console installed to: {}", dist_dir.display()); + println!("Run `loongclaw web serve` to start the same-origin Web Console."); + Ok(()) +} + +pub(super) fn run_web_status() -> CliResult<()> { + let install_dir = default_web_install_dir(); + let manifest_path = web_install_manifest_path(&install_dir); + let dist_dir = web_install_dist_dir(&install_dir); + + if !manifest_path.exists() { + println!("Web Console: not installed"); + println!("Run `loongclaw web install --source ` to install."); + return Ok(()); + } + + let manifest_raw = fs::read_to_string(&manifest_path) + .map_err(|error| format!("failed to read install manifest: {error}"))?; + let manifest: WebInstallManifest = serde_json::from_str(&manifest_raw) + .map_err(|error| format!("failed to parse install manifest: {error}"))?; + + let assets_ok = dist_dir.join("index.html").is_file(); + println!("Web Console: installed"); + println!("Install dir: {}", manifest.install_dir); + println!("Installed at: {}", manifest.installed_at); + println!("Source: {}", manifest.source_path); + println!( + "Assets: {}", + if assets_ok { + "ok" + } else { + "missing (dist/index.html not found — re-run `web install`)" + } + ); + Ok(()) +} + +pub(super) fn run_web_remove(force: bool) -> CliResult<()> { + let install_dir = default_web_install_dir(); + let manifest_path = web_install_manifest_path(&install_dir); + let dist_dir = web_install_dist_dir(&install_dir); + + if !manifest_path.exists() && !dist_dir.exists() { + println!("Web Console: not installed, nothing to remove."); + return Ok(()); + } + + if !force { + println!("This will remove: {}", install_dir.display()); + println!("Re-run with --force to confirm removal."); + return Ok(()); + } + + if dist_dir.exists() { + fs::remove_dir_all(&dist_dir) + .map_err(|error| format!("failed to remove `{}`: {error}", dist_dir.display()))?; + } + if manifest_path.exists() { + fs::remove_file(&manifest_path) + .map_err(|error| format!("failed to remove `{}`: {error}", manifest_path.display()))?; + } + + println!("Web Console removed from: {}", install_dir.display()); + Ok(()) +} diff --git a/crates/daemon/src/web/mod.rs b/crates/daemon/src/web/mod.rs new file mode 100644 index 000000000..afe3cc4f2 --- /dev/null +++ b/crates/daemon/src/web/mod.rs @@ -0,0 +1,1772 @@ +use std::{ + collections::{HashMap, HashSet}, + convert::Infallible, + env, fs, + net::SocketAddr, + path::{Path as FsPath, PathBuf}, + sync::{ + Arc, Mutex as StdMutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +use ::time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use axum::{ + Json, Router, + body::Body, + extract::{Path, Request, State}, + http::{ + HeaderMap, HeaderValue, Method, StatusCode, Uri, + header::{ + ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, + ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION, CONTENT_TYPE, + COOKIE, ORIGIN, SET_COOKIE, VARY, + }, + }, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, +}; +use clap::Subcommand; +use futures_util::stream; +use rand::random; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::{ + sync::{Mutex, mpsc}, + time::{self, Duration}, +}; + +use crate::{CliResult, mvp, with_graceful_shutdown}; + +mod abilities; +mod auth; +mod chat; +mod dashboard; +mod debug_console; +mod install; +mod onboarding; +mod serve; + +use abilities::{ + abilities_channels, abilities_personalization, abilities_personalization_save, abilities_skills, +}; +use auth::{ + build_clear_pairing_cookie, build_clear_same_origin_session_cookie, build_pairing_cookie, + build_same_origin_session_cookie, extract_allowed_local_origin, extract_request_token, + request_is_authenticated, require_local_token, require_same_origin_write_origin, +}; +use chat::{ + chat_history, chat_sessions, chat_turn, chat_turn_stream, create_chat_session, + delete_chat_session, +}; +use dashboard::{ + dashboard_config, dashboard_connectivity, dashboard_providers, dashboard_runtime, + dashboard_summary, dashboard_tools, provider_catalog, +}; +use debug_console::{dashboard_debug_console, record_debug_operation}; +use install::{ + default_web_install_dir, run_web_install, run_web_remove, run_web_status, web_install_dist_dir, +}; +use serve::run_web_serve; + +#[derive(Subcommand, Debug)] +pub enum WebCommand { + /// Serve the local Web Console API surface + Serve { + #[arg(long)] + config: Option, + #[arg(long, default_value = "127.0.0.1:4317")] + bind: String, + /// Path to the built frontend assets. If omitted, uses installed assets + /// from `web install` when available, otherwise runs in API-only mode. + #[arg(long)] + static_root: Option, + }, + /// Install the Web Console UI assets to ~/.loongclaw/web + Install { + /// Path to the built frontend assets directory (e.g. web/dist) + #[arg(long)] + source: String, + }, + /// Show Web Console installation status + Status, + /// Remove the installed Web Console UI assets + Remove { + /// Skip the confirmation prompt + #[arg(long)] + force: bool, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WebInstallManifest { + installed_at: String, + source_path: String, + install_dir: String, +} + +const WEB_API_TOKEN_ENV: &str = "LOONGCLAW_WEB_TOKEN"; +const WEB_API_TOKEN_FILE: &str = "web-api-token"; +const WEB_API_PAIRING_COOKIE: &str = "loongclaw-web-pair"; +const WEB_API_SESSION_COOKIE: &str = "loongclaw-web-session"; + +#[derive(Debug)] +struct WebApiState { + config_path: Option, + local_token: String, + local_token_path: PathBuf, + web_install_mode: &'static str, + exact_origin: Option, + static_root: Option, + turn_streams: Mutex)>>, + debug_state: StdMutex, +} + +struct WebTurnEventSink { + state: Arc, + turn_id: String, + sender: mpsc::UnboundedSender, + emitted_text: Arc, +} + +#[derive(Debug, Default, Clone)] +struct DebugConsoleRuntimeState { + recent_blocks: Vec, + recent_tool_activity: Vec, + active_tool_starts: HashMap, + last_failure: Option, +} + +#[derive(Debug, Clone)] +struct DebugConsoleBlock { + id: String, + kind: &'static str, + lines: Vec, + tool_calls: usize, + delta_chunks: usize, + delta_chars: usize, + started_at_ms: i64, + first_delta_at_ms: Option, + finished_at_ms: Option, + status: &'static str, + session_id: Option, + failure_code: Option, + failure_message: Option, +} + +#[derive(Debug, Clone)] +struct DebugToolActivity { + id: String, + label: String, + outcome: &'static str, + detail: String, + duration_ms: Option, +} + +#[derive(Debug, Clone)] +struct DebugConsoleFailure { + at: String, + category: &'static str, + detail: String, + hint: &'static str, +} + +impl DebugConsoleBlock { + fn operation(id: String, kind: &'static str, _header: String) -> Self { + let started_at_ms = current_timestamp_ms(); + Self { + id, + kind, + lines: Vec::new(), + tool_calls: 0, + delta_chunks: 0, + delta_chars: 0, + started_at_ms, + first_delta_at_ms: None, + finished_at_ms: None, + status: "info", + session_id: None, + failure_code: None, + failure_message: None, + } + } +} + +fn current_timestamp_ms() -> i64 { + (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000) as i64 +} + +fn upsert_debug_tool_activity( + activities: &mut Vec, + activity: DebugToolActivity, +) { + if let Some(index) = activities + .iter() + .position(|existing| existing.id == activity.id) + { + activities.remove(index); + } + activities.push(activity); + if activities.len() > 8 { + let overflow = activities.len() - 8; + activities.drain(0..overflow); + } +} + +fn classify_failure(code: &str, message: &str) -> (&'static str, &'static str) { + let normalized = format!("{code} {message}").to_lowercase(); + if normalized.contains("policy") || normalized.contains("denied") { + ("policy", "review policy or approval settings") + } else if normalized.contains("timeout") + || normalized.contains("transport") + || normalized.contains("dns") + || normalized.contains("network") + { + ("network", "check route, proxy, or endpoint reachability") + } else if normalized.contains("runtime") + || normalized.contains("unavailable") + || normalized.contains("not ready") + { + ( + "runtime", + "check runtime readiness and companion availability", + ) + } else if normalized.contains("provider") || normalized.contains("credential") { + ("provider", "check provider endpoint and credentials") + } else { + ("turn", "inspect recent tool activity and raw events") + } +} + +impl mvp::acp::AcpTurnEventSink for WebTurnEventSink { + fn on_event(&self, event: &Value) -> CliResult<()> { + let Some(delta) = extract_stream_text_delta(event) else { + return Ok(()); + }; + if delta.is_empty() { + return Ok(()); + } + self.emitted_text.store(true, Ordering::Relaxed); + send_stream_event( + &self.sender, + json!({ + "type": "message.delta", + "turnId": self.turn_id, + "role": "assistant", + "delta": delta, + }), + ) + .map_err(|error| error.message)?; + record_message_delta(&self.state, &self.turn_id, delta.as_str()); + Ok(()) + } +} + +impl mvp::conversation::ConversationTurnObserver for WebTurnEventSink { + fn on_phase(&self, event: mvp::conversation::ConversationTurnPhaseEvent) { + let lane = match event.lane { + Some(mvp::conversation::ExecutionLane::Fast) => Some("fast"), + Some(mvp::conversation::ExecutionLane::Safe) => Some("safe"), + None => None, + }; + let _ = send_stream_event( + &self.sender, + json!({ + "type": "turn.phase", + "turnId": self.turn_id, + "phase": event.phase.as_str(), + "providerRound": event.provider_round, + "lane": lane, + "toolCallCount": event.tool_call_count, + "messageCount": event.message_count, + "estimatedTokens": event.estimated_tokens, + }), + ); + } + + fn on_tool(&self, event: mvp::conversation::ConversationTurnToolEvent) { + match event.state { + mvp::conversation::ConversationTurnToolState::Running => { + let _ = send_stream_event( + &self.sender, + json!({ + "type": "tool.started", + "turnId": self.turn_id, + "toolId": event.tool_call_id, + "label": event.tool_name, + }), + ); + record_tool_started( + &self.state, + "", + &self.turn_id, + event.tool_call_id.as_str(), + event.tool_name.as_str(), + ); + } + mvp::conversation::ConversationTurnToolState::Completed + | mvp::conversation::ConversationTurnToolState::NeedsApproval + | mvp::conversation::ConversationTurnToolState::Denied + | mvp::conversation::ConversationTurnToolState::Failed + | mvp::conversation::ConversationTurnToolState::Interrupted => { + let outcome = if matches!( + event.state, + mvp::conversation::ConversationTurnToolState::Completed + ) { + "ok" + } else { + "error" + }; + let detail = event + .detail + .as_deref() + .or(event.request_summary.as_deref()) + .map(str::to_owned); + let _ = send_stream_event( + &self.sender, + json!({ + "type": "tool.finished", + "turnId": self.turn_id, + "toolId": event.tool_call_id, + "label": event.tool_name, + "outcome": outcome, + "detail": detail, + }), + ); + record_tool_finished( + &self.state, + "", + &self.turn_id, + event.tool_call_id.as_str(), + event.tool_name.as_str(), + outcome, + ); + } + } + } +} + +#[derive(Debug, Serialize)] +struct ApiEnvelope { + ok: bool, + data: T, +} + +#[derive(Debug, Serialize)] +struct ApiErrorEnvelope { + ok: bool, + error: ApiErrorPayload, +} + +#[derive(Debug, Serialize)] +struct ApiErrorPayload { + code: &'static str, + message: String, +} + +#[derive(Debug, Serialize)] +struct HealthPayload { + status: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MetaPayload { + app_version: String, + api_version: &'static str, + web_install_mode: &'static str, + supported_locales: [&'static str; 2], + default_locale: &'static str, + auth: MetaAuthPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MetaAuthPayload { + required: bool, + scheme: &'static str, + header: &'static str, + token_path: String, + token_env: &'static str, + mode: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardSummaryPayload { + runtime_status: &'static str, + active_provider: Option, + active_model: String, + memory_backend: &'static str, + session_count: usize, + web_install_mode: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardProvidersPayload { + active_provider: Option, + items: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProviderCatalogPayload { + items: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardRuntimePayload { + status: &'static str, + source: &'static str, + config_path: String, + memory_backend: &'static str, + memory_mode: &'static str, + ingest_mode: &'static str, + web_install_mode: &'static str, + active_provider: Option, + active_model: String, + acp_enabled: bool, + strict_memory: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardConnectivityPayload { + status: &'static str, + endpoint: String, + host: String, + dns_addresses: Vec, + probe_status: &'static str, + probe_status_code: Option, + fake_ip_detected: bool, + proxy_env_detected: bool, + recommendation: Option<&'static str>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardConfigPayload { + active_provider: Option, + last_provider: Option, + model: String, + provider_base_url: String, + provider_endpoint_explicit: bool, + endpoint: String, + api_key_configured: bool, + api_key_masked: Option, + personality: String, + prompt_mode: &'static str, + prompt_addendum_configured: bool, + prompt_addendum: String, + memory_profile: String, + memory_system: &'static str, + sqlite_path: String, + file_root: String, + sliding_window: usize, + summary_max_chars: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardToolsPayload { + approval_mode: String, + shell_default_mode: String, + shell_allow_count: usize, + shell_deny_count: usize, + items: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AbilitiesPersonalizationPayload { + configured: bool, + has_operator_preferences: bool, + suppressed: bool, + prompt_state: &'static str, + updated_at: Option, + preferred_name: Option, + response_density: Option<&'static str>, + initiative_level: Option<&'static str>, + standing_boundaries: Option, + locale: Option, + timezone: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AbilitiesPersonalizationWriteRequest { + preferred_name: Option, + response_density: Option, + initiative_level: Option, + standing_boundaries: Option, + locale: Option, + timezone: Option, + prompt_state: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AbilitiesChannelsPayload { + catalog_channel_count: usize, + configured_channel_count: usize, + configured_account_count: usize, + enabled_account_count: usize, + misconfigured_account_count: usize, + runtime_backed_channel_count: usize, + enabled_service_channel_count: usize, + ready_service_channel_count: usize, + surfaces: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AbilitiesChannelSurfacePayload { + id: String, + label: String, + source: &'static str, + configured_account_count: usize, + enabled_account_count: usize, + misconfigured_account_count: usize, + ready_send_account_count: usize, + ready_serve_account_count: usize, + default_configured_account_id: Option, + service_enabled: bool, + service_ready: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AbilitiesSkillsPayload { + visible_runtime_tool_count: usize, + visible_runtime_tools: Vec, + browser_companion: AbilitiesBrowserCompanionPayload, + external_skills: AbilitiesExternalSkillsPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AbilitiesBrowserCompanionPayload { + enabled: bool, + ready: bool, + command_configured: bool, + expected_version: Option, + execution_tier: String, + timeout_seconds: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AbilitiesExternalSkillsPayload { + enabled: bool, + override_active: bool, + inventory_status: String, + inventory_error: Option, + require_download_approval: bool, + auto_expose_installed: bool, + install_root: Option, + allowed_domain_count: usize, + blocked_domain_count: usize, + resolved_skill_count: usize, + shadowed_skill_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardToolItemPayload { + id: &'static str, + enabled: bool, + source: &'static str, + capability_state: &'static str, + detail: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProviderItemPayload { + id: String, + label: String, + enabled: bool, + model: String, + endpoint: String, + api_key_configured: bool, + api_key_masked: Option, + default_for_kind: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProviderCatalogItemPayload { + kind: String, + display_name: String, + default_base_url: String, + default_chat_path: String, + default_models_path: Option, + auth_scheme: String, + feature_family: String, + is_coding_variant: bool, + aliases: Vec, + configuration_hint: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatSessionsPayload { + items: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatSessionItemPayload { + id: String, + title: String, + updated_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatHistoryPayload { + session_id: String, + messages: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateChatSessionRequest { + title: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateChatSessionPayload { + session_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChatTurnRequest { + input: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatTurnPayload { + session_id: String, + turn_id: String, + status: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ChatMessagePayload { + id: String, + role: String, + content: String, + created_at: String, +} + +#[derive(Debug)] +struct WebApiError { + status: StatusCode, + code: &'static str, + message: String, +} + +impl WebApiError { + fn internal(message: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "internal_error", + message: message.into(), + } + } + + fn not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + code: "not_found", + message: message.into(), + } + } + + fn unauthorized(message: impl Into) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + code: "unauthorized", + message: message.into(), + } + } + + fn forbidden(message: impl Into) -> Self { + Self { + status: StatusCode::FORBIDDEN, + code: "forbidden", + message: message.into(), + } + } + + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "bad_request", + message: message.into(), + } + } +} + +impl IntoResponse for WebApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(ApiErrorEnvelope { + ok: false, + error: ApiErrorPayload { + code: self.code, + message: self.message, + }, + }), + ) + .into_response() + } +} + +pub async fn run_web_command(command: WebCommand) -> CliResult<()> { + match command { + WebCommand::Serve { + config, + bind, + static_root, + } => run_web_serve(config.as_deref(), &bind, static_root.as_deref()).await, + WebCommand::Install { source } => run_web_install(&source), + WebCommand::Status => run_web_status(), + WebCommand::Remove { force } => run_web_remove(force), + } +} + +struct WebSnapshot { + resolved_path: PathBuf, + config: mvp::config::LoongClawConfig, + memory_config: mvp::memory::runtime_config::MemoryRuntimeConfig, + sessions: Vec, +} + +struct WebSessionSummary { + id: String, + title: String, + latest_turn_ts: i64, +} + +fn load_web_snapshot(state: &WebApiState) -> Result { + let (resolved_path, config) = + mvp::config::load(state.config_path.as_deref()).map_err(WebApiError::internal)?; + let memory_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let sessions = list_sessions(&memory_config)?; + + Ok(WebSnapshot { + resolved_path, + config, + memory_config, + sessions, + }) +} + +fn list_sessions( + memory_config: &mvp::memory::runtime_config::MemoryRuntimeConfig, +) -> Result, WebApiError> { + let sessions = mvp::memory::list_recent_sessions_direct(24, memory_config) + .map_err(WebApiError::internal)?; + + sessions + .into_iter() + .map(|session| { + let title = load_session_messages(memory_config, &session.session_id) + .ok() + .and_then(|messages| derive_session_title(&messages)) + .unwrap_or_else(|| session.session_id.clone()); + + Ok(WebSessionSummary { + id: session.session_id, + title, + latest_turn_ts: session.latest_turn_ts, + }) + }) + .collect() +} + +fn load_session_messages( + memory_config: &mvp::memory::runtime_config::MemoryRuntimeConfig, + session_id: &str, +) -> Result, WebApiError> { + mvp::memory::window_direct(session_id, 64, memory_config).map_err(WebApiError::internal) +} + +fn load_visible_session_messages( + memory_config: &mvp::memory::runtime_config::MemoryRuntimeConfig, + session_id: &str, + visible_limit: usize, + raw_limit: usize, +) -> Result, WebApiError> { + let mut turns = mvp::memory::window_direct(session_id, raw_limit, memory_config) + .map_err(WebApiError::internal)?; + turns.retain(|turn| { + !(turn.role.eq_ignore_ascii_case("assistant") + && is_internal_assistant_record(&turn.content)) + }); + + if turns.len() > visible_limit { + let start = turns.len() - visible_limit; + Ok(turns.split_off(start)) + } else { + Ok(turns) + } +} + +fn build_tool_items( + config: &mvp::config::LoongClawConfig, + runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, +) -> Vec { + vec![ + DashboardToolItemPayload { + id: "bash_exec", + enabled: true, + source: "native", + capability_state: if config.tools.shell_default_mode == "deny" + && config.tools.shell_allow.is_empty() + { + "policy_limited" + } else { + "executable" + }, + detail: format!( + "{} default, {} allow / {} deny", + config.tools.shell_default_mode, + config.tools.shell_allow.len(), + config.tools.shell_deny.len() + ), + }, + DashboardToolItemPayload { + id: "sessions", + enabled: config.tools.sessions.enabled, + source: "native", + capability_state: if config.tools.sessions.enabled { + "executable" + } else { + "discoverable" + }, + detail: format!( + "{} visibility, list {} / history {} / search / status / events / wait", + match config.tools.sessions.visibility { + mvp::config::SessionVisibility::SelfOnly => "self", + mvp::config::SessionVisibility::Children => "children", + }, + config.tools.sessions.list_limit, + config.tools.sessions.history_limit + ), + }, + DashboardToolItemPayload { + id: "messages", + enabled: config.tools.messages.enabled, + source: "native", + capability_state: if config.tools.messages.enabled { + "executable" + } else { + "discoverable" + }, + detail: "message tool surface".to_owned(), + }, + DashboardToolItemPayload { + id: "delegate", + enabled: config.tools.delegate.enabled, + source: "native", + capability_state: if config.tools.delegate.enabled { + "executable" + } else { + "discoverable" + }, + detail: format!( + "depth {}, active children {}", + config.tools.delegate.max_depth, config.tools.delegate.max_active_children + ), + }, + DashboardToolItemPayload { + id: "browser", + enabled: config.tools.browser.enabled, + source: "native", + capability_state: if config.tools.browser.enabled { + "executable" + } else { + "discoverable" + }, + detail: format!( + "{} sessions, {} links, {} chars", + config.tools.browser.max_sessions, + config.tools.browser.max_links, + config.tools.browser.max_text_chars + ), + }, + DashboardToolItemPayload { + id: "browser_companion", + enabled: config.tools.browser_companion.enabled, + source: "companion", + capability_state: if !config.tools.browser_companion.enabled { + "discoverable" + } else if runtime.browser_companion.is_runtime_ready() { + "executable" + } else { + "runtime_unavailable" + }, + // Prefer runtime-ready signals here so the dashboard reflects whether + // the companion can actually be used right now, not just how it is configured. + detail: format!( + "{}, {}, {}s timeout", + if runtime.browser_companion.is_runtime_ready() { + "ready" + } else { + "not ready" + }, + if runtime.browser_companion.command.is_some() { + "command configured" + } else { + "no command" + }, + runtime.browser_companion.timeout_seconds + ), + }, + DashboardToolItemPayload { + id: "web_fetch", + enabled: config.tools.web.enabled, + source: "native", + capability_state: if config.tools.web.enabled { + "executable" + } else { + "discoverable" + }, + detail: format!( + "{}s timeout, {} bytes, {} redirects", + config.tools.web.timeout_seconds, + config.tools.web.max_bytes, + config.tools.web.max_redirects + ), + }, + DashboardToolItemPayload { + id: "web_search", + enabled: config.tools.web_search.enabled, + source: "provider", + capability_state: if config.tools.web_search.enabled { + "executable" + } else { + "discoverable" + }, + detail: format!( + "{} provider, {}s timeout, {} results", + runtime.web_search.default_provider, + runtime.web_search.timeout_seconds, + runtime.web_search.max_results + ), + }, + DashboardToolItemPayload { + id: "file_tools", + enabled: true, + source: "local", + capability_state: "executable", + detail: format!( + "read / write / edit within {}", + config.tools.resolved_file_root().display() + ), + }, + DashboardToolItemPayload { + id: "external_skills", + enabled: config.external_skills.enabled, + source: "catalog", + capability_state: if !config.external_skills.enabled { + "discoverable" + } else if config.external_skills.auto_expose_installed { + "executable" + } else { + "discoverable" + }, + detail: if config.external_skills.auto_expose_installed { + "auto expose installed".to_owned() + } else { + "manual expose".to_owned() + }, + }, + ] +} + +fn truncate_debug_value(value: &str, max_chars: usize) -> String { + let mut output = String::new(); + for (index, ch) in value.chars().enumerate() { + if index >= max_chars { + output.push_str("..."); + break; + } + output.push(ch); + } + output +} + +fn approval_mode_label(mode: mvp::config::GovernedToolApprovalMode) -> &'static str { + match mode { + mvp::config::GovernedToolApprovalMode::Disabled => "disabled", + mvp::config::GovernedToolApprovalMode::MediumBalanced => "medium_balanced", + mvp::config::GovernedToolApprovalMode::Strict => "strict", + } +} + +async fn resolve_provider_host_addresses(host: &str, port: u16) -> Vec { + let mut values = HashSet::new(); + if let Ok(addresses) = tokio::net::lookup_host((host, port)).await { + for address in addresses { + values.insert(address.ip().to_string()); + } + } + + let mut addresses = values.into_iter().collect::>(); + addresses.sort(); + addresses +} + +fn is_fake_ip_address(address: &str) -> bool { + let Ok(parsed) = address.parse::() else { + return false; + }; + + match parsed { + std::net::IpAddr::V4(ipv4) => { + let octets = ipv4.octets(); + octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) + } + std::net::IpAddr::V6(_) => false, + } +} + +fn has_proxy_environment() -> bool { + [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] + .into_iter() + .any(|key| { + env::var(key) + .ok() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + }) +} + +async fn probe_provider_endpoint(endpoint: &str) -> (&'static str, Option) { + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + else { + return ("transport_failure", None); + }; + + match client.head(endpoint).send().await { + Ok(response) => ("reachable", Some(response.status().as_u16())), + Err(_) => ("transport_failure", None), + } +} + +fn build_provider_items(config: &mvp::config::LoongClawConfig) -> Vec { + if config.providers.is_empty() { + return vec![provider_item_from_parts( + config.provider.kind.profile().id.to_owned(), + &config.provider, + true, + true, + )]; + } + + config + .providers + .iter() + .map(|(profile_id, profile)| { + provider_item_from_parts( + profile_id.clone(), + &profile.provider, + Some(profile_id.as_str()) == config.active_provider_id(), + profile.default_for_kind, + ) + }) + .collect() +} + +fn build_provider_catalog_items() -> Vec { + mvp::config::provider_catalog_entries() + .into_iter() + .map(|entry| ProviderCatalogItemPayload { + kind: entry.kind, + display_name: entry.display_name, + default_base_url: entry.default_base_url, + default_chat_path: entry.default_chat_path, + default_models_path: entry.default_models_path, + auth_scheme: entry.auth_scheme, + feature_family: entry.feature_family, + is_coding_variant: entry.is_coding_variant, + aliases: entry.aliases, + configuration_hint: entry.configuration_hint, + }) + .collect() +} + +fn prompt_personality_id(personality: mvp::prompt::PromptPersonality) -> &'static str { + crate::onboard_cli::prompt_personality_id(personality) +} + +fn provider_item_from_parts( + id: String, + provider: &mvp::config::ProviderConfig, + enabled: bool, + default_for_kind: bool, +) -> ProviderItemPayload { + let default_profile_id = provider.inferred_profile_id(); + let label = if id == default_profile_id { + provider.kind.display_name().to_owned() + } else { + format!("{} ({id})", provider.kind.display_name()) + }; + let api_key_value = provider + .api_key + .as_ref() + .and_then(|secret| secret.inline_value()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let api_key_env = provider + .api_key_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + ProviderItemPayload { + label, + id, + enabled, + model: provider.model.clone(), + endpoint: provider.endpoint(), + api_key_configured: api_key_value.is_some() || api_key_env.is_some(), + api_key_masked: api_key_value + .map(mask_secret) + .or_else(|| api_key_env.map(|_| "(env reference)".to_owned())), + default_for_kind, + } +} + +fn derive_session_title(turns: &[mvp::memory::ConversationTurn]) -> Option { + turns + .iter() + .find(|turn| turn.role.eq_ignore_ascii_case("user")) + .or_else(|| turns.first()) + .map(|turn| truncate_title(turn.content.as_str(), 56)) +} + +fn truncate_title(input: &str, max_chars: usize) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return "Untitled session".to_owned(); + } + + let mut output = String::new(); + for (index, ch) in trimmed.chars().enumerate() { + if index >= max_chars { + output.push('…'); + break; + } + output.push(ch); + } + output +} + +fn mask_secret(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return "****".to_owned(); + } + + if trimmed.starts_with('$') || trimmed.starts_with("env:") || trimmed.starts_with('%') { + return "(env reference)".to_owned(); + } + + let suffix: String = trimmed + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect(); + format!("****{suffix}") +} + +fn resolve_local_web_token() -> Result<(String, PathBuf), WebApiError> { + let loongclaw_home = mvp::config::default_loongclaw_home(); + fs::create_dir_all(&loongclaw_home) + .map_err(|error| WebApiError::internal(format!("create loongclaw home failed: {error}")))?; + + let token_path = loongclaw_home.join(WEB_API_TOKEN_FILE); + if let Ok(raw_env_token) = env::var(WEB_API_TOKEN_ENV) { + let token = raw_env_token.trim(); + if !token.is_empty() { + return Ok((token.to_owned(), token_path)); + } + } + + if let Ok(existing) = fs::read_to_string(&token_path) { + let token = existing.trim(); + if !token.is_empty() { + return Ok((token.to_owned(), token_path)); + } + } + + let token = format!( + "{:016x}{:016x}{:016x}{:016x}", + random::(), + random::(), + random::(), + random::() + ); + fs::write(&token_path, format!("{token}\n")).map_err(|error| { + WebApiError::internal(format!("write local web api token failed: {error}")) + })?; + Ok((token, token_path)) +} + +// ── Web install helpers ────────────────────────────────────────────────────── + +// ==== Config / static root helpers ==== + +fn resolve_web_config_path(state: &WebApiState) -> PathBuf { + state + .config_path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(mvp::config::default_config_path) +} + +fn format_timestamp(unix_seconds: i64) -> String { + OffsetDateTime::from_unix_timestamp(unix_seconds) + .ok() + .and_then(|timestamp| timestamp.format(&Rfc3339).ok()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned()) +} + +fn is_internal_assistant_record(content: &str) -> bool { + content.contains("\"_loongclaw_internal\":true") + && (content.contains("\"type\":\"conversation_event\"") + || content.contains("\"type\":\"tool_decision\"") + || content.contains("\"type\":\"tool_outcome\"")) +} + +fn extract_stream_text_delta(event: &Value) -> Option { + // Provider/runtime event shapes are not fully uniform yet, so accept the + // common text-bearing variants we already see from streaming-capable paths. + let kind = event.get("type").and_then(Value::as_str); + if kind == Some("text") { + return event + .get("content") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + } + if kind == Some("agent_message_chunk") { + return extract_nested_text(event); + } + if event.get("sessionUpdate").and_then(Value::as_str) == Some("agent_message_chunk") { + return extract_nested_text(event); + } + let payload = event + .get("params") + .and_then(|params| params.get("update"))?; + if payload.get("sessionUpdate").and_then(Value::as_str) == Some("agent_message_chunk") { + return extract_nested_text(payload); + } + None +} + +fn extract_nested_text(value: &Value) -> Option { + value + .get("content") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + value + .get("message") + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .or_else(|| { + value + .get("delta") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) +} + +async fn run_chat_turn_stream( + state: Arc, + session_id: String, + turn_id: String, + input: String, + sender: mpsc::UnboundedSender, +) -> Result<(), WebApiError> { + let stream_result: Result<(), WebApiError> = async { + let snapshot = load_web_snapshot(state.as_ref())?; + + send_stream_event( + &sender, + json!({ + "type": "turn.started", + "turnId": turn_id, + "sessionId": session_id, + "createdAt": format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + }), + )?; + record_turn_started(&state, &session_id, &turn_id); + + mvp::runtime_env::initialize_runtime_environment( + &snapshot.config, + Some(&snapshot.resolved_path), + ); + let sqlite_path = snapshot.config.memory.resolved_sqlite_path(); + mvp::memory::ensure_memory_db_ready(Some(sqlite_path), &snapshot.memory_config) + .map_err(WebApiError::internal)?; + let kernel_ctx = mvp::context::bootstrap_kernel_context_with_config( + "web-api", + mvp::context::DEFAULT_TOKEN_TTL_S, + &snapshot.config, + ) + .map_err(WebApiError::internal)?; + let turn_config = snapshot + .config + .reload_provider_runtime_state_from_path(snapshot.resolved_path.as_path()) + .map_err(WebApiError::internal)?; + let address = mvp::conversation::ConversationSessionAddress::from_session_id(&session_id); + let coordinator = mvp::conversation::ConversationTurnCoordinator::new(); + let emitted_text = Arc::new(AtomicBool::new(false)); + let event_sink = Arc::new(WebTurnEventSink { + state: state.clone(), + turn_id: turn_id.clone(), + sender: sender.clone(), + emitted_text: emitted_text.clone(), + }); + let observer_handle: mvp::conversation::ConversationTurnObserverHandle = + event_sink.clone(); + let acp_options = + mvp::acp::AcpConversationTurnOptions::automatic().with_event_sink(Some(event_sink.as_ref())); + + let turn_future = coordinator.handle_production_turn_with_address_and_acp_options_and_observer( + &turn_config, + &address, + &input, + mvp::conversation::ProviderErrorMode::InlineMessage, + &acp_options, + mvp::conversation::ConversationRuntimeBinding::kernel(&kernel_ctx), + Some(observer_handle), + ); + tokio::pin!(turn_future); + + let assistant_text: String = loop { + tokio::select! { + result = &mut turn_future => { + break result.map_err(WebApiError::internal)?; + } + } + }; + + let final_message = + latest_assistant_message(&snapshot.memory_config, &session_id, &assistant_text); + if !emitted_text.load(Ordering::Relaxed) { + // Older buffered providers still produce only the final assistant text. + // Preserve the previous chunked fallback so the Web stream stays compatible. + for delta in chunk_text(final_message.content.as_str(), 48) { + send_stream_event( + &sender, + json!({ + "type": "message.delta", + "turnId": turn_id, + "role": "assistant", + "delta": delta, + }), + )?; + record_message_delta(&state, &turn_id, delta.as_str()); + time::sleep(Duration::from_millis(18)).await; + } + } + + send_stream_event( + &sender, + json!({ + "type": "turn.completed", + "turnId": turn_id, + "message": final_message, + }), + )?; + record_turn_completed(&state, &turn_id); + + Ok(()) + } + .await; + + if let Err(error) = stream_result { + let _ = send_stream_event( + &sender, + json!({ + "type": "turn.failed", + "turnId": turn_id, + "code": error.code, + "message": error.message, + }), + ); + record_turn_failed( + &state, + &session_id, + &turn_id, + error.code, + error.message.as_str(), + ); + } + + Ok(()) +} + +fn generate_session_id() -> String { + let now = OffsetDateTime::now_utc().unix_timestamp(); + format!("web-{now}-{:08x}", random::()) +} + +fn generate_turn_id() -> String { + let now = OffsetDateTime::now_utc().unix_timestamp(); + format!("turn-{now}-{:08x}", random::()) +} + +fn send_stream_event( + sender: &mpsc::UnboundedSender, + payload: Value, +) -> Result<(), WebApiError> { + let line = serde_json::to_string(&payload).map_err(|error| { + WebApiError::internal(format!("serialize stream event failed: {error}")) + })?; + sender + .send(line) + .map_err(|_error| WebApiError::internal("web turn stream receiver dropped")) +} + +fn record_turn_started(state: &Arc, session_id: &str, turn_id: &str) { + let Ok(mut debug) = state.debug_state.lock() else { + return; + }; + let started_at = format_timestamp(OffsetDateTime::now_utc().unix_timestamp()); + let mut block = DebugConsoleBlock::operation( + format!("turn:{turn_id}"), + "turn", + format!("{started_at} dialogue {turn_id}"), + ); + block.status = "running"; + block.session_id = Some(session_id.to_owned()); + block.lines.push(format!( + "{started_at} turn.started session={session_id} turn={turn_id}" + )); + push_debug_block(&mut debug.recent_blocks, block); +} + +fn record_message_delta(state: &Arc, turn_id: &str, delta: &str) { + let Ok(mut debug) = state.debug_state.lock() else { + return; + }; + let Some(last_turn) = + find_debug_block_mut(&mut debug.recent_blocks, &format!("turn:{turn_id}")) + else { + return; + }; + + last_turn.delta_chunks += 1; + last_turn.delta_chars += delta.chars().count(); + if last_turn.delta_chunks == 1 { + last_turn.first_delta_at_ms = Some(current_timestamp_ms()); + last_turn.lines.push(format!( + "{} message.delta first_chunk chars={}", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + delta.chars().count() + )); + } +} + +fn record_tool_started( + state: &Arc, + session_id: &str, + turn_id: &str, + tool_id: &str, + label: &str, +) { + let Ok(mut debug) = state.debug_state.lock() else { + return; + }; + let tool_key = format!("{turn_id}:{tool_id}"); + debug + .active_tool_starts + .insert(tool_key.clone(), current_timestamp_ms()); + if let Some(last_turn) = + find_debug_block_mut(&mut debug.recent_blocks, &format!("turn:{turn_id}")) + { + last_turn.tool_calls += 1; + last_turn.lines.push(format!( + "{} tool.started {} ({})", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + label, + tool_id + )); + } + upsert_debug_tool_activity( + &mut debug.recent_tool_activity, + DebugToolActivity { + id: tool_key, + label: label.to_owned(), + outcome: "running", + detail: "started".to_owned(), + duration_ms: None, + }, + ); + let _ = session_id; +} + +fn record_tool_finished( + state: &Arc, + session_id: &str, + turn_id: &str, + tool_id: &str, + label: &str, + outcome: &str, +) { + let Ok(mut debug) = state.debug_state.lock() else { + return; + }; + let at = format_timestamp(OffsetDateTime::now_utc().unix_timestamp()); + let duration_ms = debug + .active_tool_starts + .remove(&format!("{turn_id}:{tool_id}")) + .map(|started_at| current_timestamp_ms().saturating_sub(started_at)); + if let Some(last_turn) = + find_debug_block_mut(&mut debug.recent_blocks, &format!("turn:{turn_id}")) + { + last_turn.lines.push(format!( + "{at} tool.finished {label} ({tool_id}) outcome={outcome}" + )); + } + upsert_debug_tool_activity( + &mut debug.recent_tool_activity, + DebugToolActivity { + id: format!("{turn_id}:{tool_id}"), + label: label.to_owned(), + outcome: if outcome == "ok" { "ok" } else { "error" }, + detail: if outcome == "ok" { + "completed".to_owned() + } else { + format!("failed outcome={outcome}") + }, + duration_ms, + }, + ); + let _ = session_id; +} + +fn record_turn_completed(state: &Arc, turn_id: &str) { + let Ok(mut debug) = state.debug_state.lock() else { + return; + }; + let Some(last_turn) = + find_debug_block_mut(&mut debug.recent_blocks, &format!("turn:{turn_id}")) + else { + return; + }; + last_turn.status = "completed"; + last_turn.finished_at_ms = Some(current_timestamp_ms()); + last_turn.lines.push(format!( + "{} turn.completed delta_chunks={} delta_chars={} tool_calls={}", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + last_turn.delta_chunks, + last_turn.delta_chars, + last_turn.tool_calls + )); + if last_turn.tool_calls == 0 { + last_turn.lines.push(format!( + "{} tool.none no real tool invocation was recorded for this turn", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()) + )); + } + debug + .active_tool_starts + .retain(|key, _| !key.starts_with(&format!("{turn_id}:"))); +} + +fn record_turn_failed( + state: &Arc, + session_id: &str, + turn_id: &str, + code: &str, + message: &str, +) { + let Ok(mut debug) = state.debug_state.lock() else { + return; + }; + let at = format_timestamp(OffsetDateTime::now_utc().unix_timestamp()); + let (category, hint) = classify_failure(code, message); + if let Some(last_turn) = + find_debug_block_mut(&mut debug.recent_blocks, &format!("turn:{turn_id}")) + { + last_turn.status = "failed"; + last_turn.finished_at_ms = Some(current_timestamp_ms()); + last_turn.failure_code = Some(code.to_owned()); + last_turn.failure_message = Some(message.to_owned()); + last_turn.lines.push(format!( + "{} turn.failed code={} tool_calls={} message={}", + at, + code, + last_turn.tool_calls, + truncate_debug_value(message, 180) + )); + if last_turn.tool_calls == 0 { + last_turn.lines.push(format!( + "{} tool.none no real tool invocation was recorded for this turn", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()) + )); + } + } + let interrupted_keys = debug + .active_tool_starts + .iter() + .filter_map(|(key, started_at)| { + key.starts_with(&format!("{turn_id}:")) + .then_some((key.clone(), *started_at)) + }) + .collect::>(); + for (tool_key, started_at) in interrupted_keys { + debug.active_tool_starts.remove(&tool_key); + upsert_debug_tool_activity( + &mut debug.recent_tool_activity, + DebugToolActivity { + id: tool_key, + label: "tool".to_owned(), + outcome: "error", + detail: "interrupted".to_owned(), + duration_ms: Some(current_timestamp_ms().saturating_sub(started_at)), + }, + ); + } + debug.last_failure = Some(DebugConsoleFailure { + at, + category, + detail: truncate_debug_value(message, 180), + hint, + }); + let _ = session_id; +} + +fn push_debug_block(blocks: &mut Vec, block: DebugConsoleBlock) { + blocks.push(block); + trim_debug_blocks(blocks); +} + +fn trim_debug_blocks(blocks: &mut Vec) { + if blocks.len() > 24 { + let overflow = blocks.len() - 24; + blocks.drain(0..overflow); + } +} + +fn find_debug_block_mut<'a>( + blocks: &'a mut [DebugConsoleBlock], + id: &str, +) -> Option<&'a mut DebugConsoleBlock> { + blocks.iter_mut().find(|block| block.id == id) +} + +fn latest_assistant_message( + memory_config: &mvp::memory::runtime_config::MemoryRuntimeConfig, + session_id: &str, + fallback_content: &str, +) -> ChatMessagePayload { + let visible_history = load_session_messages(memory_config, session_id) + .ok() + .unwrap_or_default() + .into_iter() + .filter(|turn| { + turn.role.eq_ignore_ascii_case("assistant") + && !is_internal_assistant_record(&turn.content) + }) + .collect::>(); + + visible_history + .last() + .map(|turn| ChatMessagePayload { + id: format!("{session_id}:{}", turn.ts), + role: "assistant".to_owned(), + content: turn.content.clone(), + created_at: format_timestamp(turn.ts), + }) + .unwrap_or_else(|| { + let created_at = OffsetDateTime::now_utc().unix_timestamp(); + ChatMessagePayload { + id: format!("{session_id}:{created_at}"), + role: "assistant".to_owned(), + content: fallback_content.to_owned(), + created_at: format_timestamp(created_at), + } + }) +} + +fn chunk_text(content: &str, chunk_size: usize) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_len = 0usize; + + for ch in content.chars() { + current.push(ch); + current_len += 1; + if current_len >= chunk_size { + chunks.push(std::mem::take(&mut current)); + current_len = 0; + } + } + + if !current.is_empty() { + chunks.push(current); + } + + if chunks.is_empty() { + chunks.push(String::new()); + } + + chunks +} + +fn session_id_from_title(title: &str) -> String { + let slug = title + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + let normalized = slug + .split('-') + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("-"); + + if normalized.is_empty() { + generate_session_id() + } else { + format!("{normalized}-{:08x}", random::()) + } +} diff --git a/crates/daemon/src/web/onboarding.rs b/crates/daemon/src/web/onboarding.rs new file mode 100644 index 000000000..09d426eef --- /dev/null +++ b/crates/daemon/src/web/onboarding.rs @@ -0,0 +1,915 @@ +use super::*; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OnboardStatusPayload { + runtime_online: bool, + token_required: bool, + token_paired: bool, + config_exists: bool, + config_loadable: bool, + provider_configured: bool, + provider_reachable: bool, + active_provider: Option, + active_model: String, + provider_base_url: String, + provider_endpoint: String, + provider_endpoint_explicit: bool, + api_key_configured: bool, + personality: String, + memory_profile: String, + sliding_window: usize, + prompt_addendum: String, + config_path: String, + blocking_stage: &'static str, + next_action: &'static str, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OnboardProviderWriteRequest { + kind: String, + model: String, + base_url_or_endpoint: String, + api_key: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OnboardPreferencesWriteRequest { + personality: String, + memory_profile: String, + sliding_window: Option, + prompt_addendum: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OnboardValidationPayload { + passed: bool, + endpoint_status: &'static str, + endpoint_status_code: Option, + credential_status: &'static str, + credential_status_code: Option, + status: OnboardStatusPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OnboardPairingPayload { + paired: bool, + mode: &'static str, + status: OnboardStatusPayload, +} + +#[derive(Debug, Clone, Copy)] +struct ProviderValidationResult { + endpoint_status: &'static str, + endpoint_status_code: Option, + credential_status: &'static str, + credential_status_code: Option, +} + +impl ProviderValidationResult { + fn passed(self) -> bool { + self.endpoint_status == "reachable" + && matches!(self.credential_status, "validated" | "request_rejected") + } +} + +pub(super) async fn onboard_status( + State(state): State>, + headers: HeaderMap, +) -> Json> { + let token_paired = + request_is_authenticated(state.as_ref(), extract_request_token(&headers).as_deref()); + let payload = build_onboard_status_payload(state.as_ref(), token_paired).await; + + Json(ApiEnvelope { + ok: true, + data: payload, + }) +} + +pub(super) async fn onboard_provider( + State(state): State>, + Json(request): Json, +) -> Result>, WebApiError> { + let config_path = resolve_web_config_path(state.as_ref()); + let mut config = load_or_default_web_config(state.as_ref())?; + apply_provider_request_to_config(&mut config, &request)?; + let path_string = config_path.display().to_string(); + mvp::config::write(Some(path_string.as_str()), &config, true).map_err(WebApiError::internal)?; + + record_debug_operation( + &state, + "provider_apply", + format!( + "{} provider config saved", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()) + ), + vec![ + format!("provider.kind={}", request.kind.trim()), + format!("provider.model={}", request.model.trim()), + format!("provider.route={}", request.base_url_or_endpoint.trim()), + ], + ); + + let payload = build_onboard_status_payload(state.as_ref(), true).await; + Ok(Json(ApiEnvelope { + ok: true, + data: payload, + })) +} + +pub(super) async fn onboard_provider_apply( + State(state): State>, + Json(request): Json, +) -> Result>, WebApiError> { + let config_path = resolve_web_config_path(state.as_ref()); + let current_config = load_or_default_web_config(state.as_ref())?; + let mut candidate_config = current_config.clone(); + apply_provider_request_to_config(&mut candidate_config, &request)?; + + let validation = validate_provider_config(&candidate_config.provider).await; + if validation.passed() { + let path_string = config_path.display().to_string(); + mvp::config::write(Some(path_string.as_str()), &candidate_config, true) + .map_err(WebApiError::internal)?; + } + + let mut status = build_onboard_status_payload(state.as_ref(), true).await; + if validation.passed() { + status.provider_reachable = true; + status.blocking_stage = "ready"; + status.next_action = "open_chat"; + } + + record_debug_operation( + &state, + "provider_apply", + format!( + "{} provider apply {}", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + if validation.passed() { + "passed" + } else { + "failed" + } + ), + vec![ + format!("provider.kind={}", request.kind.trim()), + format!("provider.model={}", request.model.trim()), + format!("provider.route={}", request.base_url_or_endpoint.trim()), + format!("endpoint_status={}", validation.endpoint_status), + format!("credential_status={}", validation.credential_status), + ], + ); + + Ok(Json(ApiEnvelope { + ok: true, + data: OnboardValidationPayload { + passed: validation.passed(), + endpoint_status: validation.endpoint_status, + endpoint_status_code: validation.endpoint_status_code, + credential_status: validation.credential_status, + credential_status_code: validation.credential_status_code, + status, + }, + })) +} + +fn route_matches_existing_provider_route( + route: &str, + existing_provider: &mvp::config::ProviderConfig, +) -> bool { + let normalized = route.trim(); + if normalized.is_empty() { + return true; + } + + normalized == existing_provider.endpoint() + || normalized == existing_provider.resolved_base_url() + || normalized == existing_provider.base_url.trim() + || existing_provider + .endpoint + .as_deref() + .map(str::trim) + .is_some_and(|value| normalized == value) +} + +fn load_or_default_web_config( + state: &WebApiState, +) -> Result { + let config_path = resolve_web_config_path(state); + if config_path.is_file() { + let (_, loaded) = mvp::config::load(state.config_path.as_deref()).map_err(|error| { + WebApiError::bad_request(format!("local config could not be loaded: {error}")) + })?; + Ok(loaded) + } else { + Ok(mvp::config::LoongClawConfig::default()) + } +} + +fn apply_provider_request_to_config( + config: &mut mvp::config::LoongClawConfig, + request: &OnboardProviderWriteRequest, +) -> Result<(), WebApiError> { + let kind = mvp::config::parse_provider_kind_id(request.kind.as_str()).ok_or_else(|| { + WebApiError::bad_request(format!("unknown provider kind `{}`", request.kind.trim())) + })?; + let model = request.model.trim(); + if model.is_empty() { + return Err(WebApiError::bad_request("model is required")); + } + + let existing_provider = config.provider.clone(); + let kind_changed = existing_provider.kind != kind; + let mut provider = existing_provider.clone(); + provider.set_kind(kind); + provider.model = model.to_owned(); + + let route = request.base_url_or_endpoint.trim(); + let should_reset_route_to_kind_default = route.is_empty() + || (kind_changed && route_matches_existing_provider_route(route, &existing_provider)); + + if should_reset_route_to_kind_default { + provider.set_base_url(kind.profile().base_url.to_owned()); + provider.set_chat_completions_path(kind.profile().chat_completions_path.to_owned()); + provider.set_endpoint(None); + provider.set_models_endpoint(None); + } else if looks_like_provider_endpoint(route) { + provider.set_base_url(kind.profile().base_url.to_owned()); + provider.set_chat_completions_path(kind.profile().chat_completions_path.to_owned()); + provider.set_endpoint(Some(route.to_owned())); + provider.set_models_endpoint(None); + } else { + provider.set_base_url(route.to_owned()); + provider.set_chat_completions_path(kind.profile().chat_completions_path.to_owned()); + provider.set_endpoint(None); + provider.set_models_endpoint(None); + } + + if let Some(hint) = provider.kind_route_mismatch_hint() { + return Err(WebApiError::bad_request(hint)); + } + + if let Some(api_key) = request + .api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + provider.api_key = Some(loongclaw_contracts::SecretRef::Inline(api_key.to_owned())); + } else if kind_changed { + provider.api_key = None; + provider.set_api_key_env(kind.default_api_key_env().map(str::to_owned)); + provider.oauth_access_token = None; + provider + .set_oauth_access_token_env(kind.default_oauth_access_token_env().map(str::to_owned)); + } + + let profile_id = provider.inferred_profile_id(); + config.set_active_provider_profile( + profile_id, + mvp::config::ProviderProfileConfig::from_provider(provider), + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::StatusCode; + + #[test] + fn apply_provider_request_rejects_volcengine_coding_route_on_standard_kind() { + let mut config = mvp::config::LoongClawConfig::default(); + let request = OnboardProviderWriteRequest { + kind: "volcengine".to_owned(), + model: "ark-code-latest".to_owned(), + base_url_or_endpoint: "https://ark.cn-beijing.volces.com/api/coding/v3".to_owned(), + api_key: None, + }; + + let error = apply_provider_request_to_config(&mut config, &request) + .expect_err("volcengine should reject coding-plan routes"); + + assert_eq!(error.status, StatusCode::BAD_REQUEST); + assert!( + error + .message + .contains("switch to `kind = \"volcengine_coding\"`"), + "expected a clear kind-switch hint, got: {}", + error.message + ); + } + + #[test] + fn apply_provider_request_accepts_volcengine_coding_route_on_coding_kind() { + let mut config = mvp::config::LoongClawConfig::default(); + let request = OnboardProviderWriteRequest { + kind: "volcengine_coding".to_owned(), + model: "ark-code-latest".to_owned(), + base_url_or_endpoint: "https://ark.cn-beijing.volces.com/api/coding/v3".to_owned(), + api_key: None, + }; + + apply_provider_request_to_config(&mut config, &request) + .expect("volcengine_coding should accept the coding-plan route"); + + assert_eq!( + config.provider.endpoint(), + "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions" + ); + } +} + +pub(super) async fn onboard_preferences( + State(state): State>, + Json(request): Json, +) -> Result>, WebApiError> { + let personality = crate::onboard_cli::parse_prompt_personality(request.personality.as_str()) + .ok_or_else(|| { + WebApiError::bad_request(format!( + "unknown personality `{}`", + request.personality.trim() + )) + })?; + let memory_profile = crate::onboard_cli::parse_memory_profile(request.memory_profile.as_str()) + .ok_or_else(|| { + WebApiError::bad_request(format!( + "unknown memory profile `{}`", + request.memory_profile.trim() + )) + })?; + + let config_path = resolve_web_config_path(state.as_ref()); + let config_exists = config_path.is_file(); + let mut config = if config_exists { + let (_, loaded) = mvp::config::load(state.config_path.as_deref()).map_err(|error| { + WebApiError::bad_request(format!("local config could not be loaded: {error}")) + })?; + loaded + } else { + mvp::config::LoongClawConfig::default() + }; + + config.cli.personality = Some(personality); + config.memory.profile = memory_profile; + if let Some(sliding_window) = request.sliding_window { + config.memory.sliding_window = validate_memory_sliding_window(sliding_window)?; + } + config.cli.system_prompt_addendum = request + .prompt_addendum + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + + let path_string = config_path.display().to_string(); + mvp::config::write(Some(path_string.as_str()), &config, true).map_err(WebApiError::internal)?; + + record_debug_operation( + &state, + "preferences_apply", + format!( + "{} preferences updated", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()) + ), + vec![ + format!("personality={}", request.personality.trim()), + format!("memory_profile={}", request.memory_profile.trim()), + format!( + "sliding_window={}", + request + .sliding_window + .map(|value| value.to_string()) + .unwrap_or_else(|| config.memory.sliding_window.to_string()) + ), + format!( + "prompt_addendum={}", + request + .prompt_addendum + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|_| "configured") + .unwrap_or("empty") + ), + ], + ); + + let payload = build_onboard_status_payload(state.as_ref(), true).await; + Ok(Json(ApiEnvelope { + ok: true, + data: payload, + })) +} + +pub(super) async fn onboard_pairing_auto( + State(state): State>, + headers: HeaderMap, +) -> Result { + if extract_allowed_local_origin(&headers).is_none() { + return Err(WebApiError::forbidden( + "automatic pairing is limited to trusted local loopback origins", + )); + } + + let payload = OnboardPairingPayload { + paired: true, + mode: "cookie", + status: build_onboard_status_payload(state.as_ref(), true).await, + }; + record_debug_operation( + &state, + "token_pairing", + format!( + "{} token pairing auto", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()) + ), + vec![ + "pairing.mode=cookie".to_owned(), + "pairing.result=paired".to_owned(), + ], + ); + let mut response = Json(ApiEnvelope { + ok: true, + data: payload, + }) + .into_response(); + response.headers_mut().append( + SET_COOKIE, + build_pairing_cookie(state.local_token.as_str())?, + ); + Ok(response) +} + +pub(super) async fn onboard_pairing_clear() -> Result { + let mut response = Json(ApiEnvelope { + ok: true, + data: Value::Object(Default::default()), + }) + .into_response(); + response + .headers_mut() + .append(SET_COOKIE, build_clear_pairing_cookie()?); + response + .headers_mut() + .append(SET_COOKIE, build_clear_same_origin_session_cookie()?); + Ok(response) +} + +pub(super) async fn onboard_validate( + State(state): State>, +) -> Result>, WebApiError> { + let snapshot = load_web_snapshot(state.as_ref())?; + if !provider_is_configured(&snapshot.config) { + return Err(WebApiError::bad_request( + "provider is not configured enough to validate yet", + )); + } + + let validation = validate_provider_config(&snapshot.config.provider).await; + let mut status = build_onboard_status_payload(state.as_ref(), true).await; + status.provider_reachable = validation.passed(); + if validation.passed() { + status.blocking_stage = "ready"; + status.next_action = "open_chat"; + } else { + status.blocking_stage = "provider_unreachable"; + status.next_action = "validate_provider_route"; + } + + record_debug_operation( + &state, + "provider_validate", + format!( + "{} provider validate {}", + format_timestamp(OffsetDateTime::now_utc().unix_timestamp()), + if validation.passed() { + "passed" + } else { + "failed" + } + ), + vec![ + format!("provider={}", snapshot.config.provider.kind.profile().id), + format!("model={}", snapshot.config.provider.model), + format!("endpoint_status={}", validation.endpoint_status), + format!("credential_status={}", validation.credential_status), + ], + ); + + Ok(Json(ApiEnvelope { + ok: true, + data: OnboardValidationPayload { + passed: validation.passed(), + endpoint_status: validation.endpoint_status, + endpoint_status_code: validation.endpoint_status_code, + credential_status: validation.credential_status, + credential_status_code: validation.credential_status_code, + status, + }, + })) +} + +fn provider_is_configured(config: &mvp::config::LoongClawConfig) -> bool { + let provider = &config.provider; + let item = provider_item_from_parts("active".to_owned(), provider, true, true); + !provider.model.trim().is_empty() + && !provider.endpoint().trim().is_empty() + && item.api_key_configured +} + +async fn build_onboard_status_payload( + state: &WebApiState, + token_paired: bool, +) -> OnboardStatusPayload { + let config_path = resolve_web_config_path(state); + let config_exists = config_path.is_file(); + let config_path_display = config_path.display().to_string(); + + let mut payload = OnboardStatusPayload { + runtime_online: true, + token_required: state.web_install_mode != "same_origin_static", + token_paired, + config_exists, + config_loadable: false, + provider_configured: false, + provider_reachable: false, + active_provider: None, + active_model: String::new(), + provider_base_url: String::new(), + provider_endpoint: String::new(), + provider_endpoint_explicit: false, + api_key_configured: false, + personality: "calm_engineering".to_owned(), + memory_profile: "window_only".to_owned(), + sliding_window: mvp::config::MemoryConfig::default().sliding_window, + prompt_addendum: String::new(), + config_path: config_path_display, + blocking_stage: if state.web_install_mode == "same_origin_static" { + "ready" + } else { + "token_pairing" + }, + next_action: if state.web_install_mode == "same_origin_static" { + "open_chat" + } else { + "enter_local_token" + }, + }; + + match load_web_snapshot(state) { + Ok(snapshot) => { + payload.config_loadable = true; + payload.active_provider = snapshot.config.active_provider_id().map(str::to_owned); + payload.active_model = snapshot.config.provider.model.clone(); + payload.provider_base_url = snapshot.config.provider.resolved_base_url(); + payload.provider_endpoint = snapshot.config.provider.endpoint(); + payload.provider_endpoint_explicit = snapshot.config.provider.endpoint_explicit + && snapshot + .config + .provider + .endpoint + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + payload.provider_configured = provider_is_configured(&snapshot.config); + payload.personality = crate::onboard_cli::prompt_personality_id( + snapshot.config.cli.resolved_personality(), + ) + .to_owned(); + payload.memory_profile = + crate::onboard_cli::memory_profile_id(snapshot.config.memory.resolved_profile()) + .to_owned(); + payload.sliding_window = snapshot.config.memory.sliding_window; + payload.prompt_addendum = snapshot + .config + .cli + .system_prompt_addendum + .clone() + .unwrap_or_default(); + payload.api_key_configured = provider_item_from_parts( + "active".to_owned(), + &snapshot.config.provider, + true, + true, + ) + .api_key_configured; + + if payload.provider_configured { + payload.provider_reachable = + probe_provider_reachability(&snapshot.config.provider).await; + } + } + Err(_) => { + payload.config_loadable = false; + } + } + + let (blocking_stage, next_action) = if !payload.token_paired { + if state.web_install_mode == "same_origin_static" { + ("session_refresh", "refresh_local_session") + } else { + ("token_pairing", "enter_local_token") + } + } else if !payload.config_exists { + ("missing_config", "create_local_config") + } else if !payload.config_loadable { + ("config_invalid", "fix_local_config") + } else if !payload.provider_configured { + ("provider_setup", "configure_provider") + } else if !payload.provider_reachable { + ("provider_unreachable", "validate_provider_route") + } else { + ("ready", "open_chat") + }; + payload.blocking_stage = blocking_stage; + payload.next_action = next_action; + payload +} + +fn validate_memory_sliding_window(sliding_window: usize) -> Result { + if !(1..=128).contains(&sliding_window) { + return Err(WebApiError::bad_request(format!( + "memory sliding window must be between 1 and 128 turns, got `{sliding_window}`" + ))); + } + + Ok(sliding_window) +} + +fn looks_like_provider_endpoint(value: &str) -> bool { + let normalized = value.trim().to_ascii_lowercase(); + (normalized.starts_with("http://") || normalized.starts_with("https://")) + && (normalized.contains("/chat/completions") + || normalized.ends_with("/completions") + || normalized.ends_with("/responses")) +} + +fn build_provider_probe_headers( + provider: &mvp::config::ProviderConfig, +) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + for (name, value) in provider.kind.default_headers() { + let header_name = reqwest::header::HeaderName::from_static(name); + let header_value = HeaderValue::from_str(value).map_err(|error| { + WebApiError::internal(format!("build provider probe headers failed: {error}")) + })?; + headers.insert(header_name, header_value); + } + + match provider.kind.auth_scheme() { + mvp::config::ProviderAuthScheme::Bearer => { + let Some(value) = provider.authorization_header() else { + return Ok(headers); + }; + let header_value = HeaderValue::from_str(value.as_str()).map_err(|error| { + WebApiError::internal(format!("build provider probe headers failed: {error}")) + })?; + headers.insert(AUTHORIZATION, header_value); + } + mvp::config::ProviderAuthScheme::XApiKey => { + let Some(secret) = provider.resolved_auth_secret() else { + return Ok(headers); + }; + let header_value = HeaderValue::from_str(secret.as_str()).map_err(|error| { + WebApiError::internal(format!("build provider probe headers failed: {error}")) + })?; + headers.insert( + reqwest::header::HeaderName::from_static("x-api-key"), + header_value, + ); + } + mvp::config::ProviderAuthScheme::XGoogApiKey => { + let Some(secret) = provider.resolved_auth_secret() else { + return Ok(headers); + }; + let header_value = HeaderValue::from_str(secret.as_str()).map_err(|error| { + WebApiError::internal(format!("build provider probe headers failed: {error}")) + })?; + headers.insert( + reqwest::header::HeaderName::from_static("x-goog-api-key"), + header_value, + ); + } + } + + Ok(headers) +} + +fn provider_probe_model(provider: &mvp::config::ProviderConfig) -> String { + if let Some(model) = provider.explicit_model() { + return model; + } + + match provider.model_catalog_probe_recovery() { + mvp::config::ModelCatalogProbeRecovery::ExplicitModel(model) => model, + mvp::config::ModelCatalogProbeRecovery::ConfiguredPreferredModels(models) => models + .into_iter() + .next() + .unwrap_or_else(|| provider.configured_model_value()), + mvp::config::ModelCatalogProbeRecovery::RequiresExplicitModel { + recommended_onboarding_model: Some(model), + } => model.to_owned(), + mvp::config::ModelCatalogProbeRecovery::RequiresExplicitModel { + recommended_onboarding_model: None, + } => provider.configured_model_value(), + } +} + +// Keep onboarding validation lightweight: prove the route is reachable and the +// provider accepts an authenticated probe. For first-run onboarding, a provider- +// specific request-shape rejection is still good enough to let users proceed, +// because it proves the endpoint and credentials are basically wired up. +async fn validate_provider_config( + provider: &mvp::config::ProviderConfig, +) -> ProviderValidationResult { + let endpoint = provider.endpoint(); + let (endpoint_status, endpoint_status_code) = probe_provider_endpoint(endpoint.as_str()).await; + if endpoint_status != "reachable" { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "transport_failure", + credential_status_code: None, + }; + } + + if provider.resolved_auth_secret().is_none() { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "missing_credentials", + credential_status_code: None, + }; + } + + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + else { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "transport_failure", + credential_status_code: None, + }; + }; + + let headers = match build_provider_probe_headers(provider) { + Ok(headers) => headers, + Err(_) => { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "transport_failure", + credential_status_code: None, + }; + } + }; + + let credential_result = match provider.kind.protocol_family() { + mvp::config::ProviderProtocolFamily::OpenAiChatCompletions => { + let request = json!({ + "model": provider_probe_model(provider), + "messages": [ + { + "role": "user", + "content": "ping" + } + ], + "max_tokens": 1, + "temperature": 0, + "stream": false + }); + + match client + .post(endpoint.as_str()) + .headers(headers) + .json(&request) + .send() + .await + { + Ok(response) if response.status().is_success() => { + ("validated", Some(response.status().as_u16())) + } + Ok(response) if matches!(response.status().as_u16(), 401 | 403) => { + ("auth_rejected", Some(response.status().as_u16())) + } + Ok(response) if response.status().is_server_error() => { + ("upstream_unavailable", Some(response.status().as_u16())) + } + Ok(response) => ("request_rejected", Some(response.status().as_u16())), + Err(_) => ("transport_failure", None), + } + } + mvp::config::ProviderProtocolFamily::AnthropicMessages + | mvp::config::ProviderProtocolFamily::BedrockConverse => { + match client.head(endpoint.as_str()).headers(headers).send().await { + Ok(response) if matches!(response.status().as_u16(), 401 | 403) => { + ("auth_rejected", Some(response.status().as_u16())) + } + Ok(response) + if response.status().is_success() || response.status().as_u16() == 405 => + { + ("validated", Some(response.status().as_u16())) + } + Ok(response) if response.status().is_server_error() => { + ("upstream_unavailable", Some(response.status().as_u16())) + } + Ok(response) => ("request_rejected", Some(response.status().as_u16())), + Err(_) => ("transport_failure", None), + } + } + }; + + ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: credential_result.0, + credential_status_code: credential_result.1, + } +} + +async fn probe_provider_reachability(provider: &mvp::config::ProviderConfig) -> bool { + let validation = validate_provider_headers_only(provider).await; + validation.endpoint_status == "reachable" + && !matches!( + validation.credential_status, + "transport_failure" | "missing_credentials" | "auth_rejected" + ) +} + +async fn validate_provider_headers_only( + provider: &mvp::config::ProviderConfig, +) -> ProviderValidationResult { + let endpoint = provider.endpoint(); + let (endpoint_status, endpoint_status_code) = probe_provider_endpoint(endpoint.as_str()).await; + if endpoint_status != "reachable" { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "transport_failure", + credential_status_code: None, + }; + } + + if provider.resolved_auth_secret().is_none() { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "missing_credentials", + credential_status_code: None, + }; + } + + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + else { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "transport_failure", + credential_status_code: None, + }; + }; + + let headers = match build_provider_probe_headers(provider) { + Ok(headers) => headers, + Err(_) => { + return ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: "transport_failure", + credential_status_code: None, + }; + } + }; + + let credential_result = match client.head(endpoint.as_str()).headers(headers).send().await { + Ok(response) if matches!(response.status().as_u16(), 401 | 403) => { + ("auth_rejected", Some(response.status().as_u16())) + } + Ok(response) if response.status().is_server_error() => { + ("upstream_unavailable", Some(response.status().as_u16())) + } + Ok(response) => ("validated", Some(response.status().as_u16())), + Err(_) => ("transport_failure", None), + }; + + ProviderValidationResult { + endpoint_status, + endpoint_status_code, + credential_status: credential_result.0, + credential_status_code: credential_result.1, + } +} diff --git a/crates/daemon/src/web/serve.rs b/crates/daemon/src/web/serve.rs new file mode 100644 index 000000000..90a021619 --- /dev/null +++ b/crates/daemon/src/web/serve.rs @@ -0,0 +1,355 @@ +use super::*; + +pub(super) async fn run_web_serve( + config_path: Option<&str>, + bind: &str, + static_root: Option<&str>, +) -> CliResult<()> { + let (local_token, local_token_path) = resolve_local_web_token() + .map_err(|error| format!("initialize local web api token failed: {}", error.message))?; + let token_path_display = local_token_path.display().to_string(); + let explicit_static_root = resolve_static_root(static_root)?; + let resolved_static_root = if explicit_static_root.is_some() { + explicit_static_root + } else { + let auto_dist = web_install_dist_dir(&default_web_install_dir()); + if auto_dist.join("index.html").is_file() { + Some(auto_dist) + } else { + None + } + }; + let web_install_mode = if resolved_static_root.is_some() { + "same_origin_static" + } else { + "api_only" + }; + let address: SocketAddr = bind + .parse() + .map_err(|error| format!("invalid web bind address `{bind}`: {error}"))?; + if web_install_mode == "same_origin_static" && !address.ip().is_loopback() { + return Err(format!( + "same-origin static mode only supports loopback binds, got `{bind}`" + )); + } + let exact_origin = matches!( + address.ip(), + std::net::IpAddr::V4(_) | std::net::IpAddr::V6(_) + ) + .then(|| format!("http://{address}")); + let state = Arc::new(WebApiState { + config_path: config_path.map(str::to_owned), + local_token, + local_token_path, + web_install_mode, + exact_origin, + static_root: resolved_static_root.clone(), + turn_streams: Mutex::new(HashMap::new()), + debug_state: StdMutex::new(DebugConsoleRuntimeState::default()), + }); + let public_api = Router::new() + .route("/meta", get(meta)) + .route("/onboard/status", get(onboarding::onboard_status)) + .route( + "/onboard/pairing/auto", + post(onboarding::onboard_pairing_auto), + ) + .route( + "/onboard/pairing/clear", + post(onboarding::onboard_pairing_clear), + ) + .with_state(state.clone()); + let protected_api = Router::new() + .route("/onboard/provider", post(onboarding::onboard_provider)) + .route( + "/onboard/provider/apply", + post(onboarding::onboard_provider_apply), + ) + .route("/providers/catalog", get(provider_catalog)) + .route( + "/onboard/preferences", + post(onboarding::onboard_preferences), + ) + .route("/onboard/validate", post(onboarding::onboard_validate)) + .route( + "/abilities/personalization", + get(abilities_personalization).post(abilities_personalization_save), + ) + .route("/abilities/channels", get(abilities_channels)) + .route("/abilities/skills", get(abilities_skills)) + .route("/dashboard/summary", get(dashboard_summary)) + .route("/dashboard/providers", get(dashboard_providers)) + .route("/dashboard/runtime", get(dashboard_runtime)) + .route("/dashboard/connectivity", get(dashboard_connectivity)) + .route("/dashboard/config", get(dashboard_config)) + .route("/dashboard/tools", get(dashboard_tools)) + .route("/dashboard/debug-console", get(dashboard_debug_console)) + .route( + "/chat/sessions", + get(chat_sessions).post(create_chat_session), + ) + .route("/chat/sessions/{id}", delete(delete_chat_session)) + .route("/chat/sessions/{id}/turn", post(chat_turn)) + .route( + "/chat/sessions/{id}/turns/{turn_id}/stream", + get(chat_turn_stream), + ) + .route("/chat/sessions/{id}/history", get(chat_history)) + .layer(middleware::from_fn_with_state( + state.clone(), + require_same_origin_write_origin, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + require_local_token, + )) + .with_state(state.clone()); + let app = Router::new() + .route("/healthz", get(healthz)) + .nest("/api", public_api.merge(protected_api)) + .fallback(get(serve_web_static)) + .layer(middleware::from_fn_with_state( + state.clone(), + local_web_cors, + )) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(address) + .await + .map_err(|error| format!("bind web api on {bind} failed: {error}"))?; + + println!("loongclaw web api listening on http://{address}"); + println!("loongclaw web api local token path: {token_path_display}"); + if let Some(static_root) = resolved_static_root.as_ref() { + println!( + "loongclaw web ui same-origin static root: {}", + static_root.display() + ); + } + with_graceful_shutdown(async move { + axum::serve(listener, app) + .await + .map_err(|error| format!("web api serve failed: {error}")) + }) + .await +} + +pub(super) async fn healthz() -> Json> { + Json(ApiEnvelope { + ok: true, + data: HealthPayload { status: "ok" }, + }) +} + +pub(super) async fn local_web_cors( + State(state): State>, + request: Request, + next: Next, +) -> Response { + let allowed_origin = allowed_cors_origin(state.as_ref(), request.headers()); + if request.method() == Method::OPTIONS { + return with_cors_headers( + StatusCode::NO_CONTENT.into_response(), + allowed_origin.as_deref(), + ); + } + + let response = next.run(request).await; + with_cors_headers(response, allowed_origin.as_deref()) +} + +fn allowed_cors_origin(state: &WebApiState, headers: &HeaderMap) -> Option { + if state.web_install_mode == "same_origin_static" { + let origin = headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::trim); + return state + .exact_origin + .as_deref() + .filter(|expected| origin == Some(*expected)) + .map(ToOwned::to_owned); + } + + extract_allowed_local_origin(headers) +} + +fn with_cors_headers(mut response: Response, allowed_origin: Option<&str>) -> Response { + if let Some(origin) = allowed_origin + && let Ok(value) = HeaderValue::from_str(origin) + { + response + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_ORIGIN, value); + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Origin")); + } + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, DELETE, OPTIONS"), + ); + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("content-type, authorization, x-loongclaw-token"), + ); + response +} + +pub(super) async fn meta(State(state): State>) -> Json> { + Json(ApiEnvelope { + ok: true, + data: MetaPayload { + app_version: env!("CARGO_PKG_VERSION").to_owned(), + api_version: "v1", + web_install_mode: state.web_install_mode, + supported_locales: ["en", "zh-CN"], + default_locale: "en", + auth: MetaAuthPayload { + required: true, + scheme: if state.web_install_mode == "same_origin_static" { + "cookie" + } else { + "bearer" + }, + header: if state.web_install_mode == "same_origin_static" { + "Cookie" + } else { + "Authorization" + }, + token_path: if state.web_install_mode == "same_origin_static" { + String::new() + } else { + state.local_token_path.display().to_string() + }, + token_env: if state.web_install_mode == "same_origin_static" { + "" + } else { + WEB_API_TOKEN_ENV + }, + mode: if state.web_install_mode == "same_origin_static" { + "same_origin_session" + } else { + "local_token" + }, + }, + }, + }) +} + +fn resolve_static_root(static_root: Option<&str>) -> CliResult> { + let Some(raw_root) = static_root else { + return Ok(None); + }; + let root = PathBuf::from(raw_root); + if !root.exists() { + return Err(format!( + "web static root `{}` does not exist", + root.display() + )); + } + if !root.is_dir() { + return Err(format!( + "web static root `{}` is not a directory", + root.display() + )); + } + let index_path = root.join("index.html"); + if !index_path.is_file() { + return Err(format!( + "web static root `{}` is missing `index.html`", + root.display() + )); + } + Ok(Some(root)) +} + +pub(super) async fn serve_web_static( + State(state): State>, + uri: Uri, +) -> Result { + let Some(static_root) = state.static_root.as_ref() else { + return Err(WebApiError::not_found("not found")); + }; + + let request_path = uri.path(); + let candidate = match resolve_static_asset_path(static_root, request_path) { + Some(path) => path, + None => return Err(WebApiError::not_found("not found")), + }; + let effective_path = if candidate.is_file() { + candidate + } else if is_asset_like_path(request_path) { + return Err(WebApiError::not_found("not found")); + } else { + static_root.join("index.html") + }; + let bytes = tokio::fs::read(&effective_path).await.map_err(|error| { + WebApiError::internal(format!( + "read web static asset `{}` failed: {error}", + effective_path.display() + )) + })?; + let mut response = Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, detect_static_content_type(&effective_path)) + .body(Body::from(bytes)) + .map_err(|error| WebApiError::internal(format!("build static response failed: {error}")))?; + if state.web_install_mode == "same_origin_static" { + response.headers_mut().append( + SET_COOKIE, + build_same_origin_session_cookie(state.local_token.as_str())?, + ); + } + Ok(response) +} + +fn resolve_static_asset_path(static_root: &FsPath, request_path: &str) -> Option { + let trimmed = request_path.trim_start_matches('/'); + if trimmed.is_empty() { + return Some(static_root.join("index.html")); + } + + let relative = FsPath::new(trimmed); + let mut resolved = static_root.to_path_buf(); + for component in relative.components() { + match component { + std::path::Component::Normal(segment) => resolved.push(segment), + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::CurDir + | std::path::Component::ParentDir => return None, + } + } + if resolved.is_dir() { + Some(resolved.join("index.html")) + } else { + Some(resolved) + } +} + +fn is_asset_like_path(request_path: &str) -> bool { + FsPath::new(request_path.trim_start_matches('/')) + .extension() + .is_some() +} + +fn detect_static_content_type(path: &FsPath) -> &'static str { + match path.extension().and_then(|value| value.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("js") => "application/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("json") => "application/json; charset=utf-8", + Some("webmanifest") => "application/manifest+json; charset=utf-8", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} diff --git a/crates/daemon/tests/integration/import_cli.rs b/crates/daemon/tests/integration/import_cli.rs index 17e6cf873..d339fc7b5 100644 --- a/crates/daemon/tests/integration/import_cli.rs +++ b/crates/daemon/tests/integration/import_cli.rs @@ -1827,6 +1827,7 @@ requires_openai_auth = true "role": "user", "content": "ping" })], + None, mvp::provider::ProviderRuntimeBinding::direct(), ) .await @@ -1943,6 +1944,7 @@ requires_openai_auth = true "role": "user", "content": "ping" })], + None, mvp::provider::ProviderRuntimeBinding::direct(), ) .await diff --git a/scripts/web/start-dev.ps1 b/scripts/web/start-dev.ps1 new file mode 100644 index 000000000..d1a4110f3 --- /dev/null +++ b/scripts/web/start-dev.ps1 @@ -0,0 +1,116 @@ +param( + [string]$ApiBind = "127.0.0.1:4317", + [string]$DevHost = "127.0.0.1", + [int]$DevPort = 4173 +) + +$ErrorActionPreference = "Stop" + +function Get-PortProcessIds { + param([int]$Port) + + $lines = netstat -ano -p tcp | Select-String -Pattern "[:.]$Port\s" + $ids = @() + foreach ($line in $lines) { + $parts = ($line.ToString().Trim() -split "\s+") | Where-Object { $_ } + if ($parts.Length -ge 5) { + $procId = $parts[-1] + if ($procId -match "^\d+$") { + $ids += [int]$procId + } + } + } + return $ids | Sort-Object -Unique +} + +function Stop-PortProcesses { + param([int]$Port) + + $ids = Get-PortProcessIds -Port $Port + if ($ids.Count -gt 0) { + Stop-Process -Id $ids -Force -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 500 + } +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$webRoot = Join-Path $repoRoot "web" +$logRoot = Join-Path $env:USERPROFILE ".loongclaw\logs" + +New-Item -ItemType Directory -Force -Path $logRoot | Out-Null + +$apiLog = Join-Path $logRoot "web-api.log" +$apiErr = Join-Path $logRoot "web-api.err.log" +$devLog = Join-Path $logRoot "web-dev.log" +$devErr = Join-Path $logRoot "web-dev.err.log" + +Stop-PortProcesses -Port 4317 +Stop-PortProcesses -Port $DevPort + +$daemonExe = Join-Path $repoRoot "target\debug\loongclaw.exe" +if (-not (Test-Path $daemonExe)) { + throw "Missing daemon binary: $daemonExe" +} + +$apiProc = Start-Process ` + -FilePath $daemonExe ` + -ArgumentList "web", "serve", "--bind", $ApiBind ` + -WorkingDirectory $repoRoot ` + -RedirectStandardOutput $apiLog ` + -RedirectStandardError $apiErr ` + -WindowStyle Hidden ` + -PassThru + +$viteCmd = Join-Path $webRoot "node_modules\.bin\vite.cmd" +if (-not (Test-Path $viteCmd)) { + throw "Missing Vite binary: $viteCmd" +} + +$devProc = Start-Process ` + -FilePath $viteCmd ` + -ArgumentList "--host", $DevHost, "--port", "$DevPort" ` + -WorkingDirectory $webRoot ` + -RedirectStandardOutput $devLog ` + -RedirectStandardError $devErr ` + -WindowStyle Hidden ` + -PassThru + +$apiReady = $false +for ($i = 0; $i -lt 20; $i++) { + Start-Sleep -Milliseconds 500 + try { + $status = (Invoke-WebRequest -UseBasicParsing "http://$ApiBind/healthz" -TimeoutSec 3).StatusCode + if ($status -eq 200) { + $apiReady = $true + break + } + } catch { + } +} + +$devReady = $false +for ($i = 0; $i -lt 20; $i++) { + Start-Sleep -Milliseconds 500 + try { + $status = (Invoke-WebRequest -UseBasicParsing "http://$DevHost`:$DevPort/" -TimeoutSec 3).StatusCode + if ($status -ge 200 -and $status -lt 500) { + $devReady = $true + break + } + } catch { + } +} + +if (-not $apiReady) { + throw "Web API did not become ready. Check $apiErr" +} + +if (-not $devReady) { + throw "Web dev server did not become ready. Check $devErr" +} + +Write-Output "Web API: http://$ApiBind" +Write-Output "Web Dev: http://$DevHost`:$DevPort" +Write-Output "Logs: $logRoot" +Write-Output "API PID: $($apiProc.Id)" +Write-Output "Dev PID: $($devProc.Id)" diff --git a/scripts/web/start-dev.sh b/scripts/web/start-dev.sh new file mode 100644 index 000000000..6022eea02 --- /dev/null +++ b/scripts/web/start-dev.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_BIND="${API_BIND:-127.0.0.1:4317}" +DEV_HOST="${DEV_HOST:-127.0.0.1}" +DEV_PORT="${DEV_PORT:-4173}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +WEB_ROOT="${REPO_ROOT}/web" +LOG_ROOT="${HOME}/.loongclaw/logs" +RUN_ROOT="${HOME}/.loongclaw/run" + +mkdir -p "${LOG_ROOT}" "${RUN_ROOT}" + +API_LOG="${LOG_ROOT}/web-api.log" +API_ERR="${LOG_ROOT}/web-api.err.log" +DEV_LOG="${LOG_ROOT}/web-dev.log" +DEV_ERR="${LOG_ROOT}/web-dev.err.log" +API_PID_FILE="${RUN_ROOT}/web-api.pid" +DEV_PID_FILE="${RUN_ROOT}/web-dev.pid" + +stop_port_processes() { + local port="$1" + local pids + pids="$(lsof -ti "tcp:${port}" 2>/dev/null || true)" + if [[ -n "${pids}" ]]; then + echo "${pids}" | xargs kill -9 >/dev/null 2>&1 || true + sleep 0.5 + fi +} + +wait_for_http() { + local url="$1" + local max_attempts="$2" + local ready=1 + + for ((i = 0; i < max_attempts; i++)); do + sleep 0.5 + if curl --silent --show-error --fail --max-time 3 "${url}" >/dev/null 2>&1; then + ready=0 + break + fi + done + + return "${ready}" +} + +stop_port_processes 4317 +stop_port_processes "${DEV_PORT}" + +DAEMON_EXE="${REPO_ROOT}/target/debug/loongclaw" +if [[ ! -f "${DAEMON_EXE}" ]]; then + echo "Missing daemon binary: ${DAEMON_EXE}" >&2 + echo "Run: cargo build --bin loongclaw" >&2 + exit 1 +fi + +VITE_CMD="${WEB_ROOT}/node_modules/.bin/vite" +if [[ ! -f "${VITE_CMD}" ]]; then + echo "Missing Vite binary: ${VITE_CMD}" >&2 + echo "Run: (cd web && npm install)" >&2 + exit 1 +fi + +( + cd "${REPO_ROOT}" + nohup "${DAEMON_EXE}" web serve --bind "${API_BIND}" >"${API_LOG}" 2>"${API_ERR}" & + echo $! >"${API_PID_FILE}" +) + +( + cd "${WEB_ROOT}" + nohup "${VITE_CMD}" --host "${DEV_HOST}" --port "${DEV_PORT}" >"${DEV_LOG}" 2>"${DEV_ERR}" & + echo $! >"${DEV_PID_FILE}" +) + +if ! wait_for_http "http://${API_BIND}/healthz" 20; then + echo "Web API did not become ready. Check ${API_ERR}" >&2 + exit 1 +fi + +if ! wait_for_http "http://${DEV_HOST}:${DEV_PORT}/" 20; then + echo "Web dev server did not become ready. Check ${DEV_ERR}" >&2 + exit 1 +fi + +echo "Web API: http://${API_BIND}" +echo "Web Dev: http://${DEV_HOST}:${DEV_PORT}" +echo "Logs: ${LOG_ROOT}" +echo "API PID: $(cat "${API_PID_FILE}")" +echo "Dev PID: $(cat "${DEV_PID_FILE}")" diff --git a/scripts/web/start-same-origin.ps1 b/scripts/web/start-same-origin.ps1 new file mode 100644 index 000000000..a84448578 --- /dev/null +++ b/scripts/web/start-same-origin.ps1 @@ -0,0 +1,104 @@ +param( + [string]$Bind = "127.0.0.1:4318", + [switch]$Build +) + +$ErrorActionPreference = "Stop" + +function Get-PortProcessIds { + param([int]$Port) + + $lines = netstat -ano -p tcp | Select-String -Pattern "[:.]$Port\s" + $ids = @() + foreach ($line in $lines) { + $parts = ($line.ToString().Trim() -split "\s+") | Where-Object { $_ } + if ($parts.Length -ge 5) { + $procId = $parts[-1] + if ($procId -match "^\d+$") { + $ids += [int]$procId + } + } + } + return $ids | Sort-Object -Unique +} + +function Stop-PortProcesses { + param([int]$Port) + + $ids = Get-PortProcessIds -Port $Port + if ($ids.Count -gt 0) { + Stop-Process -Id $ids -Force -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 500 + } +} + +$scriptRoot = (Resolve-Path $PSScriptRoot).Path +$repoRoot = (Resolve-Path (Join-Path $scriptRoot "..\..")).Path +$webRoot = Join-Path $repoRoot "web" +$distRoot = Join-Path $webRoot "dist" +$logRoot = Join-Path $env:USERPROFILE ".loongclaw\logs" + +New-Item -ItemType Directory -Force -Path $logRoot | Out-Null + +$uiLog = Join-Path $logRoot "web-same-origin.log" +$uiErr = Join-Path $logRoot "web-same-origin.err.log" + +$bindParts = $Bind.Split(":") +if ($bindParts.Length -lt 2) { + throw "Bind must look like host:port, got: $Bind" +} +$port = [int]$bindParts[-1] +Stop-PortProcesses -Port $port + +$daemonExe = Join-Path $repoRoot "target\debug\loongclaw.exe" +if (-not (Test-Path $daemonExe)) { + throw "Missing daemon binary: $daemonExe" +} + +if ($Build) { + Push-Location $webRoot + try { + npm.cmd run build | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Web build failed. Fix the build first, then rerun this script." + } + } finally { + Pop-Location + } +} + +$distIndex = Join-Path $distRoot "index.html" +if (-not (Test-Path $distIndex)) { + throw "Missing built Web assets: $distIndex`nRun: cd web; npm.cmd run build" +} + +$uiProc = Start-Process ` + -FilePath $daemonExe ` + -ArgumentList "web", "serve", "--bind", $Bind, "--static-root", $distRoot ` + -WorkingDirectory $repoRoot ` + -RedirectStandardOutput $uiLog ` + -RedirectStandardError $uiErr ` + -WindowStyle Hidden ` + -PassThru + +$uiReady = $false +for ($i = 0; $i -lt 20; $i++) { + Start-Sleep -Milliseconds 500 + try { + $status = (Invoke-WebRequest -UseBasicParsing "http://$Bind/" -TimeoutSec 3).StatusCode + if ($status -ge 200 -and $status -lt 500) { + $uiReady = $true + break + } + } catch { + } +} + +if (-not $uiReady) { + throw "Same-origin Web server did not become ready. Check $uiErr" +} + +Write-Output "Web UI + API: http://$Bind" +Write-Output "Mode: same-origin-static" +Write-Output "Logs: $logRoot" +Write-Output "PID: $($uiProc.Id)" diff --git a/scripts/web/start-same-origin.sh b/scripts/web/start-same-origin.sh new file mode 100644 index 000000000..092380bb5 --- /dev/null +++ b/scripts/web/start-same-origin.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +BIND="${BIND:-127.0.0.1:4318}" +BUILD="${BUILD:-0}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +WEB_ROOT="${REPO_ROOT}/web" +DIST_ROOT="${WEB_ROOT}/dist" +LOG_ROOT="${HOME}/.loongclaw/logs" + +mkdir -p "${LOG_ROOT}" + +UI_LOG="${LOG_ROOT}/web-same-origin.log" +UI_ERR="${LOG_ROOT}/web-same-origin.err.log" + +stop_port_processes() { + local port="$1" + local pids + pids="$(lsof -ti "tcp:${port}" 2>/dev/null || true)" + if [[ -n "${pids}" ]]; then + echo "${pids}" | xargs kill -9 >/dev/null 2>&1 || true + sleep 0.5 + fi +} + +wait_for_http() { + local url="$1" + local max_attempts="$2" + local ready=1 + + for ((i = 0; i < max_attempts; i++)); do + sleep 0.5 + if curl --silent --show-error --fail --max-time 3 "${url}" >/dev/null 2>&1; then + ready=0 + break + fi + done + + return "${ready}" +} + +PORT="${BIND##*:}" +stop_port_processes "${PORT}" + +DAEMON_EXE="${REPO_ROOT}/target/debug/loongclaw" +if [[ ! -f "${DAEMON_EXE}" ]]; then + echo "Missing daemon binary: ${DAEMON_EXE}" >&2 + echo "Run: cargo build --bin loongclaw" >&2 + exit 1 +fi + +DIST_INDEX="${DIST_ROOT}/index.html" +if [[ "${BUILD}" == "1" ]]; then + ( + cd "${WEB_ROOT}" + npm run build >/dev/null + ) +fi + +if [[ ! -f "${DIST_INDEX}" ]]; then + echo "Missing built Web assets: ${DIST_INDEX}" >&2 + echo "Run: (cd web && npm run build)" >&2 + exit 1 +fi + +( + cd "${REPO_ROOT}" + nohup "${DAEMON_EXE}" web serve --bind "${BIND}" --static-root "${DIST_ROOT}" >"${UI_LOG}" 2>"${UI_ERR}" & + UI_PID=$! +) + +if ! wait_for_http "http://${BIND}/" 20; then + echo "Same-origin Web server did not become ready. Check ${UI_ERR}" >&2 + exit 1 +fi + +echo "Web UI + API: http://${BIND}" +echo "Mode: same-origin-static" +echo "Logs: ${LOG_ROOT}" diff --git a/scripts/web/stop-dev.ps1 b/scripts/web/stop-dev.ps1 new file mode 100644 index 000000000..c379b94a3 --- /dev/null +++ b/scripts/web/stop-dev.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = "Stop" + +function Get-PortProcessIds { + param([int]$Port) + + $lines = netstat -ano -p tcp | Select-String -Pattern "[:.]$Port\s" + $ids = @() + foreach ($line in $lines) { + $parts = ($line.ToString().Trim() -split "\s+") | Where-Object { $_ } + if ($parts.Length -ge 5) { + $procId = $parts[-1] + if ($procId -match "^\d+$") { + $ids += [int]$procId + } + } + } + return $ids | Sort-Object -Unique +} + +$ports = @(4317, 4173) +foreach ($port in $ports) { + $ids = Get-PortProcessIds -Port $port + if ($ids.Count -gt 0) { + Stop-Process -Id $ids -Force -ErrorAction SilentlyContinue + } +} + +Write-Output "Stopped web dev processes on ports 4317 and 4173." diff --git a/scripts/web/stop-dev.sh b/scripts/web/stop-dev.sh new file mode 100644 index 000000000..40763e005 --- /dev/null +++ b/scripts/web/stop-dev.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUN_ROOT="${HOME}/.loongclaw/run" +API_PID_FILE="${RUN_ROOT}/web-api.pid" +DEV_PID_FILE="${RUN_ROOT}/web-dev.pid" + +kill_pid_file() { + local pid_file="$1" + if [[ -f "${pid_file}" ]]; then + local pid + pid="$(cat "${pid_file}" 2>/dev/null || true)" + if [[ -n "${pid}" ]]; then + kill -9 "${pid}" >/dev/null 2>&1 || true + fi + rm -f "${pid_file}" + fi +} + +kill_port() { + local port="$1" + local pids + pids="$(lsof -ti "tcp:${port}" 2>/dev/null || true)" + if [[ -n "${pids}" ]]; then + echo "${pids}" | xargs kill -9 >/dev/null 2>&1 || true + fi +} + +kill_pid_file "${API_PID_FILE}" +kill_pid_file "${DEV_PID_FILE}" +kill_port 4317 +kill_port 4173 + +echo "Stopped web dev processes on ports 4317 and 4173." diff --git a/scripts/web/stop-same-origin.ps1 b/scripts/web/stop-same-origin.ps1 new file mode 100644 index 000000000..9fb9e86a7 --- /dev/null +++ b/scripts/web/stop-same-origin.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = "Stop" + +function Get-PortProcessIds { + param([int]$Port) + + $lines = netstat -ano -p tcp | Select-String -Pattern "[:.]$Port\s" + $ids = @() + foreach ($line in $lines) { + $parts = ($line.ToString().Trim() -split "\s+") | Where-Object { $_ } + if ($parts.Length -ge 5) { + $procId = $parts[-1] + if ($procId -match "^\d+$") { + $ids += [int]$procId + } + } + } + return $ids | Sort-Object -Unique +} + +$port = 4318 +$ids = Get-PortProcessIds -Port $port +if ($ids.Count -gt 0) { + Stop-Process -Id $ids -Force -ErrorAction SilentlyContinue +} + +Write-Output "Stopped same-origin Web process on port $port." diff --git a/scripts/web/stop-same-origin.sh b/scripts/web/stop-same-origin.sh new file mode 100644 index 000000000..e7384cd2f --- /dev/null +++ b/scripts/web/stop-same-origin.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT="${PORT:-4318}" +pids="$(lsof -ti "tcp:${PORT}" 2>/dev/null || true)" +if [[ -n "${pids}" ]]; then + echo "${pids}" | xargs kill -9 >/dev/null 2>&1 || true +fi + +echo "Stopped same-origin Web process on port ${PORT}." diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..4c8e5db8c --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.vite/ +.vite-*.log +.web-*.log +tsconfig.app.tsbuildinfo +tsconfig.node.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/web/INSTALL.md b/web/INSTALL.md new file mode 100644 index 000000000..09b99e6ab --- /dev/null +++ b/web/INSTALL.md @@ -0,0 +1,144 @@ +# LoongClaw Web Installation Guide + +## 1. 前置条件 + +在运行 Web Console 之前,请先准备: + +- Rust 工具链 +- Node.js + npm +- 已在仓库根目录构建 `loongclaw` daemon:`cargo build --bin loongclaw` + +## 2. 安装前端依赖 + +进入 `web/` 后执行: + +```bash +npm install +``` + +## 3. 安装模式(推荐) + +将构建产物安装到用户目录后直接使用,适合日常体验与非开发场景。 + +### 构建并安装 + +```bash +# 在 web/ 目录内 +npm run build + +# 安装到 ~/.loong/web/(Windows 为 %USERPROFILE%\.loong\web\) +loongclaw web install --source ./dist +``` + +### 启动 + +```bash +loongclaw web serve +``` + +`web serve` 会自动检测已安装的前端,无需额外传参。 + +默认地址:`http://127.0.0.1:4317/` + +### 更新前端 + +重新构建后再次安装即可,`install` 会直接覆盖已安装内容: + +```bash +# 在 web/ 目录内 +npm run build +loongclaw web install --source ./dist +``` + +然后重启 `loongclaw web serve` 生效。 + +### 管理 + +```bash +# 查看安装状态(安装时间、来源路径、安装目录) +loongclaw web status + +# 卸载前端(保留 daemon) +loongclaw web remove + +# 强制卸载(跳过确认) +loongclaw web remove --force +``` + +安装目录: + +- Windows:`%USERPROFILE%\.loong\web\` +- macOS / Linux:`~/.loong/web/` + +当前运行态默认也会读取同一根目录下的本地数据文件: +- `config.toml` +- `memory.sqlite3` +- `web-api-token` + +## 4. 开发分离模式 + +适合前端开发、热更新与联调。 + +### 启动 + +- Windows:`powershell -File scripts/web/start-dev.ps1` +- macOS / Linux:`bash scripts/web/start-dev.sh` + +### 默认地址 + +- Web:`http://127.0.0.1:4173/` +- API:`http://127.0.0.1:4317/` + +### 停止 + +- Windows:`powershell -File scripts/web/stop-dev.ps1` +- macOS / Linux:`bash scripts/web/stop-dev.sh` + +## 5. 同源静态模式 + +适合验证更接近产品态的本地入口。daemon 同时托管静态资源和 API,并自动走同源 session。 + +### 构建前端 + +```bash +npm run build +``` + +### 启动 + +- Windows:`powershell -File scripts/web/start-same-origin.ps1` +- macOS / Linux:`bash scripts/web/start-same-origin.sh` + +默认地址: + +- Web UI + API:`http://127.0.0.1:4318/` + +### 可选:启动时顺带构建 + +- Windows:`powershell -File scripts/web/start-same-origin.ps1 -Build` +- macOS / Linux:`BUILD=1 bash scripts/web/start-same-origin.sh` + +### 停止 + +- Windows:`powershell -File scripts/web/stop-same-origin.ps1` +- macOS / Linux:`bash scripts/web/stop-same-origin.sh` + +## 6. 环境变量 + +- `VITE_API_BASE_URL`:开发态显式指定 API 基地址;未设置时,前端对 `4173 → 4317` 做默认映射,同源模式下回退到当前 `origin` + +## 7. 日志位置 + +日志默认写入用户目录: + +- Windows:`%USERPROFILE%\.loong\logs\` +- macOS / Linux:`~/.loong/logs/` + +常见日志文件: + +- `web-dev.log` +- `web-dev.err.log` +- `web-api.log` +- `web-api.err.log` +- `web-same-origin.log` +- `web-same-origin.err.log` diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..8561f876e --- /dev/null +++ b/web/README.md @@ -0,0 +1,83 @@ +# LoongClaw Web + +LoongClaw 的本地优先 Web Console。 + +> **安装与启动说明请参阅 [INSTALL.md](INSTALL.md)**。 + +当前已提供: + +- `Chat` +- `Status` +- `Abilities` +- `Onboarding` +- `Debug Console` + +当前补充进度: + +- `Chat` 已支持多会话、本地会话名覆写、最近活跃时间展示与生成中状态提示 +- `Abilities` 已接入第一版只读/轻编辑能力页:`Personalization / Channels / Skills` +- 中英文切换 +- 明暗主题切换 + +## 快速开始 + +### 方式一:安装模式(推荐体验) + +适合直接使用已构建的 Web Console,无需运行前端开发服务器: + +1. 构建 daemon:`cargo build --bin loongclaw` +2. 安装前端产物(需先构建,见下方): + ```bash + # 在 web/ 目录内 + npm install && npm run build + # 安装到用户目录 + loongclaw web install --source ./dist + ``` +3. 启动服务:`loongclaw web serve` + +默认地址:`http://127.0.0.1:4317/` + +管理命令: + +```bash +loongclaw web status # 查看安装状态 +loongclaw web remove # 卸载前端 +``` + +### 方式二:开发分离模式 + +适合前端开发与热更新联调: + +1. 构建 daemon:`cargo build --bin loongclaw` +2. 安装前端依赖:`npm install`(在 `web/` 目录内) +3. 启动前端与本地 API: + - Windows:`powershell -File scripts/web/start-dev.ps1` + - macOS / Linux:`bash scripts/web/start-dev.sh` + +默认地址: + +- Web:`http://127.0.0.1:4173/` +- API:`http://127.0.0.1:4317/` + +### 方式三:同源静态模式 + +适合验证更接近产品态的本地体验(daemon 同时托管静态资源与 API): + +1. 构建 daemon:`cargo build --bin loongclaw` +2. 安装前端依赖:`npm install` +3. 构建前端:`npm run build` +4. 启动同源服务: + - Windows:`powershell -File scripts/web/start-same-origin.ps1` + - macOS / Linux:`bash scripts/web/start-same-origin.sh` + +默认地址:`http://127.0.0.1:4318/` + +当前 Web 运行态默认读取 `~/.loong/`(Windows 为 `%USERPROFILE%\.loong\`)下的 +`config.toml`、`memory.sqlite3` 与 `web-api-token`。 + +## 相关文档 + +- `docs/STACK.zh-CN.md`:技术栈、目录与运行约定 +- `docs/DESIGN.zh-CN.md`:产品形态、onboarding、调试台与专项 review 结论 +- `docs/API.zh-CN.md`:当前前端实际依赖的 Web API +- `INSTALL.md`:完整安装步骤与选项 diff --git a/web/docs/API.zh-CN.md b/web/docs/API.zh-CN.md new file mode 100644 index 000000000..7ca28a613 --- /dev/null +++ b/web/docs/API.zh-CN.md @@ -0,0 +1,337 @@ +# LoongClaw Web API(当前状态) + +本文档只记录当前 Web Console 已落地、且前端正在实际依赖的接口与调用约定。 + +## 1. 基础接口 + +### `GET /healthz` + +用于确认本地 Web API 是否在线。 + +### `GET /api/meta` + +返回 Web 入口需要的基础元信息。当前前端实际依赖: + +- `appVersion` +- `apiVersion` +- `webInstallMode` +- `supportedLocales` +- `defaultLocale` +- `auth.required` +- `auth.scheme` +- `auth.header` +- `auth.tokenPath` +- `auth.tokenEnv` +- `auth.mode` + +当前已支持两类鉴权模式: + +- `local_token` +- `same_origin_session` + +## 2. 认证与客户端调用约定 + +当前 Web 客户端与本地 API 的交互约定: + +- 所有请求默认带 `credentials: include` +- 若浏览器本地保存了 token,请求会额外附带 `Authorization: Bearer ` +- `GET /api/meta` 与 `GET /api/onboard/status` 用于入口状态判断 +- `same_origin_static` 模式下,前端优先依赖同源 session cookie,而不是手动 token +- 同源写操作保留本地可信 `Origin` 校验 + +补充说明: + +- Chat 流式响应当前基于 `HTTP + NDJSON` +- 当前并未使用 WebSocket / SSE 作为主流式通道 + +## 3. Onboarding 接口 + +### `GET /api/onboard/status` + +用于首次进入状态聚合。重点字段包括: + +- `runtimeOnline` +- `tokenRequired` +- `tokenPaired` +- `configExists` +- `configLoadable` +- `providerConfigured` +- `providerReachable` +- `activeProvider` +- `activeModel` +- `providerBaseUrl` +- `providerEndpoint` +- `providerEndpointExplicit` +- `apiKeyConfigured` +- `personality` +- `memoryProfile` +- `promptAddendum` +- `configPath` +- `blockingStage` +- `nextAction` + +常见 `blockingStage`: + +- `runtime_offline` +- `token_pairing` +- `session_refresh` +- `missing_config` +- `config_invalid` +- `provider_setup` +- `provider_unreachable` +- `ready` + +常见 `nextAction`: + +- `start_local_runtime` +- `enter_local_token` +- `refresh_local_session` +- `create_local_config` +- `fix_local_config` +- `configure_provider` +- `validate_provider_route` +- `enter_web` +- `open_chat` + +### `POST /api/onboard/provider` + +最小 provider 配置写入接口。当前支持: + +- `kind` +- `model` +- `baseUrlOrEndpoint` +- `apiKey` + +### `POST /api/onboard/provider/apply` + +“应用并验证” provider 配置。当前语义: + +- 先按候选配置做最小验证 +- 仅验证通过时才正式落盘 +- 若 `kind` 与 route 明显错配(如标准 `volcengine` 指向 coding plan 路径),会直接返回 `400` +- 返回验证结果与最新 onboarding 状态 + +### `GET /api/providers/catalog` + +提供完整 provider catalog,供 onboarding / dashboard 下拉与默认 route 回填使用。常用字段: + +- `kind` +- `displayName` +- `defaultBaseUrl` +- `defaultChatPath` +- `defaultModelsPath` +- `authScheme` +- `featureFamily` +- `isCodingVariant` +- `aliases` +- `configurationHint` + +### `POST /api/onboard/preferences` + +保存轻配置项。当前支持: + +- `personality` +- `memoryProfile` +- `promptAddendum` + +### `POST /api/onboard/validate` + +执行最小 provider 验证。当前返回重点包括: + +- `passed` +- `endpointStatus` +- `endpointStatusCode` +- `credentialStatus` +- `credentialStatusCode` +- `status` + +### `POST /api/onboard/pairing/auto` + +轻量自动配对接口。当前行为: + +- 仅允许本地 loopback 可信来源尝试 +- 不把 token 明文返回给前端 +- 通过 `HttpOnly` cookie 建立当前浏览器配对状态 + +### `POST /api/onboard/pairing/clear` + +清理当前浏览器自动配对 cookie,用于退出本地配对状态。 + +## 4. Dashboard 接口 + +### `GET /api/dashboard/summary` + +提供 Dashboard 顶部摘要卡数据。 + +### `GET /api/dashboard/providers` + +提供 provider 列表与当前激活项。常用字段: + +- `id` +- `label` +- `enabled` +- `model` +- `endpoint` +- `apiKeyConfigured` +- `apiKeyMasked` +- `defaultForKind` + +### `GET /api/dashboard/runtime` + +提供 runtime 运行态信息。常用字段: + +- `status` +- `source` +- `configPath` +- `memoryBackend` +- `memoryMode` +- `ingestMode` +- `webInstallMode` +- `activeProvider` +- `activeModel` +- `acpEnabled` +- `strictMemory` + +### `GET /api/dashboard/config` + +提供 UI 关注的配置快照。常用字段: + +- `activeProvider` +- `lastProvider` +- `model` +- `providerBaseUrl` +- `providerEndpointExplicit` +- `endpoint` +- `apiKeyConfigured` +- `apiKeyMasked` +- `personality` +- `promptMode` +- `promptAddendumConfigured` +- `memoryProfile` +- `memorySystem` +- `sqlitePath` +- `fileRoot` +- `slidingWindow` +- `summaryMaxChars` + +### `GET /api/dashboard/connectivity` + +提供 provider route / connectivity 诊断。常用字段: + +- `status` +- `endpoint` +- `host` +- `dnsAddresses` +- `probeStatus` +- `probeStatusCode` +- `fakeIpDetected` +- `proxyEnvDetected` +- `recommendation` + +### `GET /api/dashboard/tools` + +提供工具启用状态与策略摘要。当前前端消费: + +- `approvalMode` +- `shellDefaultMode` +- `shellAllowCount` +- `shellDenyCount` +- `items` + +当前重点工具项包括: + +- `sessions` +- `messages` +- `delegate` +- `browser` +- `browser_companion` +- `web_fetch` +- `web_search` +- `file_tools` +- `external_skills` + +### `GET /api/dashboard/debug-console` + +提供只读 Debug Console 的块级数据。返回结构: + +- `generatedAt` +- `command` +- `blocks` + +当前 `blocks` 主要覆盖: + +- runtime snapshot +- 最近一次对话 turn +- 最近一次 provider apply / validate +- 最近一次 preferences 保存 +- 最近一次 token pairing +- process output + +## 5. Chat 接口 + +### `GET /api/chat/sessions` + +读取会话列表。 + +### `POST /api/chat/sessions` + +创建会话。 + +### `DELETE /api/chat/sessions/{id}` + +删除会话。 + +补充说明: +- 当前没有独立的“重命名会话”后端接口 +- Web 里的会话名修改目前是前端本地覆写,不会写回 daemon session 模型 + +### `GET /api/chat/sessions/{id}/history` + +读取会话历史。 + +当前前端语义: + +- 按可见消息计数 +- 不让内部 assistant 记录占掉消息泡额度 + +### `POST /api/chat/sessions/{id}/turn` + +创建 turn。当前请求体至少支持: + +- `input` +返回: + +- `sessionId` +- `turnId` +- `status = accepted` + +当前前端约定: + +- 一旦 `turn` 被 `accepted`,前端不应再把该轮用户消息整体回滚掉 + +### `GET /api/chat/sessions/{id}/turns/{turn_id}/stream` + +返回 NDJSON 流式事件。当前事件集合: + +- `turn.started` +- `message.delta` +- `tool.started` +- `tool.finished` +- `turn.completed` +- `turn.failed` + +当前前端消费约定: + +- 以换行分隔单位消费 NDJSON +- 保留单行解析失败容错 +- `turn.failed` 需要显式反馈到 UI + +## 6. 当前边界 + +当前 API 仍有这些边界: + +- Debug Console 还是只读观测面,不是 CLI 镜像 +- provider 验证仍是最小验证,不是完整 doctor +- Dashboard 写入仍以最小 provider / preferences 为主 +- `tool.search` 的中文 / 泛化意图召回问题仍未解决 +- Chat 流式仍缺少更完整的中断 / 重连 / 恢复语义 diff --git a/web/docs/DESIGN.zh-CN.md b/web/docs/DESIGN.zh-CN.md new file mode 100644 index 000000000..c22c17a4e --- /dev/null +++ b/web/docs/DESIGN.zh-CN.md @@ -0,0 +1,353 @@ +# LoongClaw Web 设计进度 + +## 1. 当前定位 + +> 最后更新:2026-03-23(已补 CI 相关 review 修复) + +LoongClaw Web 现在已经不是一个纯壳子,而是一个可实际使用的本地 Web Console: + +- 可以进入 `chat / abilities / dashboard` +- 已接入真实 runtime +- 已有 onboarding 首屏检查与放行 +- 已支持最小 provider 配置写入与验证 +- 已支持轻配置项写入(personality / memory / prompt addendum) +- 已支持 token 轻自动配对与 same-origin session 模式 +- 已有一个可用的 Dashboard Debug Console 原型 +- 已实现 `web install / status / remove` + +当前它已经具备: + +- 开发态分离运行 +- 同源静态产品态骨架 +- 第一版可选安装 + +但它仍然处于 **开发态优先、产品态逐步收口** 的阶段,还不是完整的“开箱即用 Web 产品入口”。 + +## 2. 架构方向 + +### 当前开发态 + +当前仍然采用: + +- 前端 dev server +- 本地 API +- 本地受保护 runtime + +也就是“分离式前端 + 本地 API”的开发结构。这样做的原因是: + +- 前端迭代快 +- 联调成本低 +- Vite 热更新体验好 + +### 长期产品方向 + +如果后续要做: + +- 可选安装 +- 官方 host +- 更顺滑的首次进入体验 + +那么 Web 更适合逐步收敛到 **同源设计**。 + +当前状态已经是: + +- 开发态:继续允许分离,保持开发效率 +- 本地产品态:已支持 `same_origin_static` 第一版 +- 鉴权:开发态保留 token / pairing;同源产品态已切到更轻的本地 session cookie 心智 +- 安装态:已具备第一版 `install / status / remove` +- 长期方向:继续减少 token / pairing 的显式负担,把产品心智收敛到同源 session + +一句话: + +> 现在保留分离,是为了开发快;当前已具备第一版同源入口;以后继续把同源体验做顺。 + +## 3. Onboarding 进度 + +### O1:首次进入状态检测 + +已完成。 + +当前已有: + +- `GET /api/onboard/status` +- runtime / token / config / provider 状态聚合 +- 首屏 onboarding 状态面板 +- ready 状态确认进入 + +### O2:最小可写配置 + +已完成第一版,并已同时接入 onboarding 与 Dashboard。 + +当前 Web 可写的最小配置项为: + +- provider kind +- model +- base_url / endpoint +- api key + +这条写入链当前被: + +- onboarding 首屏 +- Dashboard `Provider Settings` + +共同复用。 + +### O3:验证与放行 + +已完成第一版,并已补上当前页验证与失败回拉。 + +当前已有: + +- `POST /api/onboard/validate` +- `POST /api/onboard/provider/apply` +- provider 配置“应用并验证”原子路径 + +当前验证关注的最小问题是: + +- endpoint 是否可达 +- 凭证是否通过基础探测 + +Dashboard 里现在不会再因为 `Apply` 把用户踢回 onboarding,而是: + +- 留在当前页 +- 弹出“正在验证 / 验证成功 / 验证失败”的短时反馈 +- 验证失败时回到修改前状态 +- 若 provider 已应用、preferences 失败,会回拉真实状态并给出更准确的错误语义 + +### O4:token / pairing 收口 + +当前属于 **部分完成,开发态与产品态已经分流**。 + +已完成: + +- token 配对已收进 onboarding 面板 +- 不再依赖单独的顶部 token banner +- Web 会优先尝试一次轻量自动配对 +- 自动配对成功后,通过本地受信 cookie 建立当前浏览器的配对状态 +- 自动配对失败时,会回退到手动输入 token +- 手动 token 输入框不会因为自动配对尝试而消失 + +当前边界: + +- 安装态 / 同源产品态虽然已经不再把 token 当成主路径,但还不是彻底无感配对 +- 分离开发态仍然会在必要时暴露 token 文件路径 +- same-origin 模式下已新增 `session_refresh` 分支,用于本地 session 失效后的页面刷新恢复 +- 开发态仍然需要处理本地 API token 的概念 + +### O2.5:轻配置项补齐 + +已完成第一版落地,并已接入 Dashboard 写入。 + +当前已支持: + +- onboarding 首屏新增“可选个性化设置”折叠区 +- 可在首次进入时按需设置: + - `personality` + - `memory_profile` + - `prompt addendum` +- Dashboard 中也可编辑并保存同一组轻配置项 + +当前边界: + +- `system_prompt` 仍不可直接修改 +- 当前仍不开放更重的 prompt / tools / memory 底层参数 +- Dashboard 虽已支持最小写入,但还不是完整的配置控制台 + +## 4. Status / Chat 分工 + +### Chat + +当前更聚焦“这轮对话时最该看到的信息”: + +- 当前模型 +- 记忆窗口 +- 生成中状态 +- 流式输出 +- 会话列表 / 历史消息 + +补充: + +- 输入交互已改为:`Enter` 发送,`Shift + Enter` 换行 +- Chat 历史现在按**可见消息**计数,不让内部记录占掉消息泡额度 +- 多 session 已可用;会话上下文彼此独立,但底层运行配置仍是全局共享 +- 会话列表已支持前端本地会话名覆写,不改变后端 session 模型 +- 会话列表与顶部当前会话信息现在会显示更友好的“最近活跃时间” +- 当前正在生成的会话会在会话列表里给出显式状态提示 +- Chat 现在已移除早期的 Web 侧临时 `toolAssist` workaround,工具发现与后续跟进主要依赖 runtime / provider 主链路本身 +- `chat / dashboard / abilities` 已加路由级 keep-alive:切页返回后可保留进行中的可见状态 +- 会话切换已补齐每个 session 的临时视图态缓存(最新问话、思考中状态、流式占位消息、tool 状态) +- 消息区滚动行为已修正为“锁在聊天框内滚动”,避免消息把整页撑长 +- 流式失败时若 turn 已被后端接受,前端不再误删该轮用户消息 +- 发送失败时会恢复输入框内容 + +### Dashboard + +当前更聚焦“本地实例按什么配置在跑”: + +- provider 状态 +- runtime 状态 +- connectivity 诊断 +- 本地配置快照 +- Provider Settings 最小写入 +- preferences 轻配置项写入 +- 工具运行态概览 +- Debug Console 入口 + +### Abilities + +当前已经是一个可用的第三大页面初版,主要承接: + +- personalization 摘要与轻编辑入口 +- channels snapshot +- skills / external skills 能力面 + +当前第一版已经落地: + +- 左侧 section 导航:`Personalization / Channels / Skills` +- 右侧独立内容区与滚动容器 +- `Personalization` 真实值读取与最小编辑 +- `Channels` 只读 snapshot +- `Skills` 只读能力快照 + +## 5. Debug / Runtime Console + +### 当前状态 + +已落地一个 **Dashboard 内嵌的只读 Debug Console 原型**。 + +它不是完整浏览器终端,也不是可交互 CLI,而是: + +- 只读 +- 终端风格 +- 面向观测和排障 + +当前可以看到: + +- runtime snapshot +- 最近几次操作块 + - 对话 turn + - provider apply / validate + - preferences apply + - token pairing +- 简化过的 process output +- 本轮是否发生真实 tool call 的直接提示 + +当前设计重点已经从“把卡片塞进终端皮肤”调整为: + +- 一次操作一段反馈 +- 更像只读 CLI 输出块 +- 内容在窗口内滚动,不拉长整个页面 + +### 还没做到的 + +- 真正的 CLI stdout 原样镜像 +- 完整的连续事件流 +- 多 session 并行调试视图 +- 更细的 turn / provider / tool 历史筛选 + +## 6. Provider / Tool / Routing 诊断 + +最近这轮开发已经证明,很多问题不能简单当成“Web bug”。 + +### Provider transport + +尤其是 Volcengine / Ark 这类 host,在代理 / TUN / fake-ip 环境下会出现: + +- 短请求偶发成功 +- 稍长 completion 更容易失败 +- Web 和 CLI 都会继承同一条 provider transport 问题 + +因此当前已经补上: + +- provider host DNS 解析检查 +- fake-ip 命中判断 +- endpoint 基础 probe +- route guidance + +### Tools + +当前还存在一个重要产品/运行时问题: + +- `tool.search` 对中文和泛化工具意图的召回不足 +- 用户即使明确说“请使用 shell / file 工具”,模型也常常并没有真的发起工具调用 +- Debug Console 现在已经能明确显示: + - 本轮有没有真实 tool call + - 还是模型只是口头说“我检索过了” + +这部分当前更像 runtime / tools 侧问题,而不是单纯 Web 问题。 + +## 7. 近期新增事项 + +这段时间新增且值得记录的事项: + +- Dashboard `Provider Settings` 已接到真实写入接口,不再只是壳子 +- provider apply 改成”当前页验证”,不再强制回 onboarding +- Dashboard 工具区已对齐上游新增能力: + - `web_search` + - `browser_companion` 运行态 + - `file_tools` 聚合项(覆盖 `file.edit`) +- Mac 端已补 `start-dev.sh / stop-dev.sh` +- 同源静态模式脚本已补齐:`start-same-origin.* / stop-same-origin.*` +- 顶部导航已支持语言切换与明暗主题切换 +- Debug Console 已支持更像”按操作分段”的展示 +- Chat 历史显示已修正为按**可见消息**计数 +- `chat / dashboard / abilities` 已加入路由级 keep-alive,切页返回可保留进行中可见状态 +- 会话切换已补齐临时视图态缓存,减少”最新问话/思考态丢失” +- Chat 消息区滚动链路已修复为容器内滚动,避免整页被消息撑长 +- Chat 发送失败时会恢复输入框内容,不再直接吞掉用户 prompt +- 流式失败时若 turn 已被后端接受,前端不再误删该轮用户消息 +- 新建会话首条消息失败时,空白会话会被及时清理 +- Dashboard `Apply` 在 provider 成功、preferences 失败时会回拉真实状态,不再让 UI 和实际配置分叉 +- onboarding 现在会区分 `runtime offline` 与 `401 / session_refresh / token invalid` +- **`web install/status/remove` 命令已实现**(`crates/daemon/src/web/`): + - `loongclaw web install --source `:将构建产物复制到 `~/.loong/web/dist/`,并写入 `install.json` 清单 + - `loongclaw web status`:输出安装状态、安装时间与来源路径 + - `loongclaw web remove [--force]`:清理安装目录 + - `loongclaw web serve` 现在会自动检测 `~/.loong/web/dist/index.html`;检测到时无需传 `--static-root` 即可进入同源模式 + +## 8. 当前已知边界 + +当前仍未完成: + +- 所有 provider 路径的统一真流式 +- cancel / reconnect / resume +- 完整 tool trace 面板 +- 更完整的 memory / tools / prompt Web 写入 +- 更完整的 Dashboard 受控写入 +- 安装态级别的自动 token 配对(`web install` 已落地,但同源无感配对尚未打通完整链路) +- 更像真实 CLI 的连续输出流 Debug Console +- `tool.search` 的中文 / 泛化意图召回问题 + +## 9. CI 相关 review / 修复结果(2026-03-23) + +这轮围绕 CI 与 reviewer 反馈,优先收掉了会直接影响真实使用、状态一致性或安全边界的问题,而不是继续叠加新功能。当前已经修复并验证通过的项包括: + +- Chat 流式失败时,若 turn 已被后端接受,前端不再误删该轮用户消息 +- Chat 发送失败时会恢复输入框内容 +- Dashboard 在 provider 成功、preferences 失败时会回拉真实状态,避免 UI 与实际配置分叉 +- onboarding 不再把 `401 / session 失效 / token 无效` 误判成 `runtime_offline` +- `ConversationRuntime` 对 `event_sink` 的扩展改为兼容式 API,不再直接制造 public trait breaking change +- discovery-first follow-up 继续透传 ACP event sink,多轮工具发现链路不再丢 tracing +- Web API 生产路径改用 `bootstrap_kernel_context_with_config(...)`,不再默认走临时 in-memory audit +- `same_origin_static` 的写操作校验已从“任意 loopback origin”收紧为 daemon 自己的 exact origin +- Dashboard 表单已补 dirty tracking,轮询或刷新不会覆盖未保存编辑 +- Dashboard 数据加载已补“只认最新请求”的保护,旧请求不会回写过期状态 +- `config_invalid` 状态下重新开放 provider 修复入口,避免把用户卡死在 onboarding +- Dashboard 样式中已替换过时的 `word-break: break-word` + +这轮 review 里也有几项我们判断为**值得记账,但不阻塞当前 Web 主线**的问题,后续会继续收口: + +- onboarding 的 provider/preferences 写入仍然是 direct config write,尚未纳入统一 kernel capability / policy / audit 路径 +- recent-session 枚举目前仍走 direct SQLite path,而不是 first-class memory op +- `api_only` 开发态 token 目前已从 `localStorage` 收敛到 `sessionStorage`,长期仍可继续往更短暂的内存式凭证收敛 +- Debug Console 日志 tail 已改成按文件尾部字节读取;后续仍可继续优化连续流展示与日志分类 + +## 10. 当前仍值得优先关注的事项 + +当前最值得继续推进的是: + +- `streamTurn` 的中断与提前关闭语义仍值得继续补强 +- `ChatPage`、`DashboardPage`、`OnboardingStatusPanel` 仍偏大,后续继续优先抽 hook / 子组件 +- Chat 页仍有少量硬编码英文字符串,需要继续补齐 i18n +- Debug Console 仍需从“可用原型”进一步推进到更像连续只读输出流 +- 安装态与同源产品态第一版骨架已经具备,后续重点会转向更顺的安装体验与产品态文案/状态收口 diff --git a/web/docs/STACK.zh-CN.md b/web/docs/STACK.zh-CN.md new file mode 100644 index 000000000..a65e01b95 --- /dev/null +++ b/web/docs/STACK.zh-CN.md @@ -0,0 +1,285 @@ +# LoongClaw Web 技术栈与目录结构 + +状态:已进入可用 MVP,持续迭代中 +最后更新:2026-03-22 + +## 1. 目标 + +`web/` 目录承载 LoongClaw 的本地优先 Web Console。 + +当前目标不是独立云端产品,而是基于现有 runtime 提供: + +- Web Chat +- Web Status +- Web Abilities +- 首次进入 / onboarding +- 本地诊断与调试入口 + +## 2. 当前技术栈 + +前端主栈: + +- React 19 +- TypeScript 5.9 +- Vite 7 +- React Router 7 +- i18next / react-i18next +- `lucide-react` +- 原生 `fetch` + NDJSON 流读取 +- CSS Variables + 自定义主题样式 + +后端承接: + +- `crates/daemon/src/web/mod.rs` +- `crates/daemon/src/web/onboarding.rs` +- `crates/daemon/src/web/auth.rs` +- `crates/daemon/src/web/debug_console.rs` +- Axum 本地 API + +## 3. 运行模式 + +### 开发态 + +- 前端:Vite dev server +- 后端:本地 daemon API +- 默认地址: + - `http://127.0.0.1:4173/` + - `http://127.0.0.1:4317/` + +特点: + +- 分离前后端 +- 热更新快 +- 适合日常开发和联调 + +### 同源产品态骨架 + +当前已支持: + +- daemon 直接托管打包后的静态资源 +- 页面和 API 走同一个 origin +- 同源模式下走本地 session cookie,而不是手动 token 主路径 + +### 安装态 + +当前已实现第一版安装命令: + +- `loongclaw web install --source ` +- `loongclaw web status` +- `loongclaw web remove [--force]` + +安装目录: + +- `~/.loong/web/dist/` +- 清单:`~/.loong/web/install.json` + +## 4. 当前目录 + +```text +web/ + docs/ + API.zh-CN.md + DESIGN.zh-CN.md + STACK.zh-CN.md + public/ + src/ + app/ + assets/ + locales/ + en/ + zh-CN/ + components/ + layout/ + status/ + surfaces/ + contexts/ + features/ + abilities/ + chat/ + dashboard/ + onboarding/ + hooks/ + lib/ + api/ + auth/ + config/ + utils/ + styles/ + variables.css + themes.css + dashboard.css + index.css + main.tsx +``` + +## 5. 目录职责 + +### `web/src/features/chat/` + +承载: + +- session 列表 +- history +- turn 创建 +- turn 流式读取 +- 生成中状态 +- 轻量消息渲染 + +当前关键状态已拆进: + +- `hooks/useChatSessions.ts` +- `hooks/useChatStream.ts` +- 会话列表已支持前端本地会话名覆写 +- 会话列表与当前会话头部会显示更友好的最近活跃时间 +- 当前正在生成的会话会在会话列表里给出显式状态提示 + +### `web/src/features/abilities/` + +当前第一版已落地: + +- `pages/AbilitiesPage.tsx` +- `components/AbilitiesNav.tsx` +- `components/PersonalizationPanel.tsx` +- `components/ChannelsPanel.tsx` +- `components/SkillsPanel.tsx` +- `hooks/useAbilitiesData.ts` + +当前作为第三个大页面骨架,后续主要承接: + +- personalization +- channels snapshot +- skills / external skills + +### `web/src/features/dashboard/` + +承载: + +- runtime 摘要 +- tools 摘要 +- config 摘要 +- connectivity 诊断 +- provider 最小写入 +- Debug Console + +当前关键状态已拆进: + +- `hooks/useDashboardData.ts` +- `components/DebugConsolePanel.tsx` + +### `web/src/features/onboarding/` + +承载: + +- onboarding 状态读取 +- provider 最小写入 +- preferences 轻配置写入 +- validate 放行 +- token / session 进入流程 + +当前关键状态已拆进: + +- `components/OnboardingStatusPanel.tsx` +- `hooks/useOnboardingFlow.ts` +- `provider/providerConfig.ts` +- `provider/providerCatalog.ts` + +### `web/src/contexts/` 与 `web/src/hooks/` + +当前主要承载: + +- Web 会话连接状态 +- token / pairing / same-origin session +- onboarding gate + +当前关键入口: + +- `contexts/WebSessionContext.tsx` +- `hooks/useWebSessionManager.ts` +- `hooks/useWebConnection.ts` + +## 6. 当前实现特征 + +### 路由与页面保活 + +当前 `chat`、`dashboard` 与 `abilities` 已加入 keep-alive 语义,用来保留切页返回后的可见状态。 + +收益: + +- 流式中切页返回更稳定 +- 会话列表与当前可见状态不容易丢 + +边界: + +- 仍需持续关注初始化副作用与页面体积 + +### 数据访问 + +当前前端数据访问特点: + +- 默认 `credentials: include` +- 开发态可附带本地 token +- 同源产品态优先依赖 session cookie +- Chat 流式消费基于 `fetch + ReadableStream + NDJSON` + +### 状态组织 + +当前以“轻全局 + feature 本地状态”组合为主: + +- WebSessionContext:连接 / auth / onboarding gate +- feature hooks:各自页面的数据加载、交互与错误处理 + +当前尚未引入 Redux / Zustand / React Query 一类额外状态层。 + +## 7. 脚本与命令 + +推荐脚本: + +- Windows + - `scripts/web/start-dev.ps1` + - `scripts/web/stop-dev.ps1` + - `scripts/web/start-same-origin.ps1` + - `scripts/web/stop-same-origin.ps1` +- macOS / Linux + - `scripts/web/start-dev.sh` + - `scripts/web/stop-dev.sh` + - `scripts/web/start-same-origin.sh` + - `scripts/web/stop-same-origin.sh` + +## 8. 日志位置 + +运行日志统一落在用户目录,不再写回仓库: + +- `%USERPROFILE%\\.loong\\logs\\web-dev.log` +- `%USERPROFILE%\\.loong\\logs\\web-dev.err.log` +- `%USERPROFILE%\\.loong\\logs\\web-api.log` +- `%USERPROFILE%\\.loong\\logs\\web-api.err.log` + +## 9. 专项 review 后的当前重点 + +这轮 WebUI 专项 review 之后,当前结构上的结论是: + +- 大状态机已经开始从页面文件拆到 feature hooks,方向是对的 +- Chat / Status / Onboarding 三条主链现在都已有自己的状态 hook +- 近期已修复几条真实运行时问题: + - 流式失败时不再误删已接受的用户消息 + - 新建会话失败时不再残留空白会话 + - Dashboard 部分成功保存后会回拉真实状态 + - onboarding 不再把 401/session 失效误判成 runtime offline + - 发送失败后会恢复输入框内容 + +当前仍值得继续关注: + +- `ChatPage.tsx` +- `DashboardPage.tsx` +- `OnboardingStatusPanel.tsx` + +这三个页面文件仍偏大,后续继续开发时应优先保持“先抽 hook / 子组件,再加功能”。 + +## 10. 当前仍未完成 + +- 更像真实 CLI 的连续输出流 Debug Console +- 更完整的 tool trace / event timeline +- 更完整的 Dashboard 受控写入 +- 更顺的安装态产品化体验 +- `tool.search` 中文 / 泛化意图召回问题 +- Chat 流式的更完整中断 / 重连 / 恢复语义 diff --git a/web/docs/note.md b/web/docs/note.md new file mode 100644 index 000000000..26be94fd8 --- /dev/null +++ b/web/docs/note.md @@ -0,0 +1,154 @@ +# Web Notes + +## Personalization Prompt State + +`daemon` 侧的 personalization 模型目前带有一个 `prompt_state` 字段,可选值包括: + +- `pending` +- `configured` +- `deferred` +- `suppressed` + +这个字段不是用户偏好本身,而是在表示: + +> 后续是否还要继续把 `loong personalize` 作为一个可选的后续引导提示出来。 + +当前 Web 侧的产品结论: + +- 不在 `Abilities -> Personalization` 的编辑表单里暴露 `prompt_state` +- 不在当前 `Abilities` 页面里展示 `deferred / suppressed / pending` 这类流程态文案 +- `Personalization` 页面只聚焦真正的操作员偏好: + - preferred name + - response density + - initiative level + - standing boundaries + - locale + - timezone + +如果后面 Web 新增专门的 next-steps / advisory 页面,再考虑把 `prompt_state` 放到那类“提示链”界面里,而不是继续塞进主个性化编辑器。 + +## Channels Follow-up + +`Abilities -> Channels` 目前已经具备: + +- 左侧摘要 +- 右侧渠道列表 +- source / readiness / account / service 状态 + +后续值得继续补的点: + +- 区分每个 channel 的 `send` 与 `serve` 能力,而不只是一个笼统 `ready` +- 更明确显示来源: + - native + - bridge + - plugin + - stub / runtime-backed +- 给 misconfigured channel 增加更具体的原因,而不是只显示计数 +- 如果后端 doctor/readiness 继续增强,可以把修复建议接进来,但仍保持只读,而不是先做成管理后台 +- 如果内容继续增长,优先在 `Channels` 内做展开详情,不急着拆成单独 `Bridge / Plugin` 页面 + +当前结论: + +- `Channels` 继续作为“渠道接入面板”来做 +- 不要过早把它做成完整配置后台 +- `bridge / plugin` 更适合作为来源信息出现在这里,而不是先单独成页 + +## Skills Follow-up + +`Abilities -> Skills` 现在的定位应该是: + +> 当前有哪些能力,这些能力从哪里来,现在能不能用。 + +当前已经做了: + +- 动态读取 runtime 可见工具列表 +- 显示原始 tool id +- 显示来源 +- 通过 hover 查看简介 + +后续值得继续补的点: + +- 新 tools 需要继续自动显示,尤其是最近已经出现的: + - `session_search` + - `approval_request_*` + - `delegate_async` + - `provider.switch` + - `browser.*` + - `file.*` + - `tool.search` + - `tool.invoke` +- `session_search` 应该作为重点能力被强调,它代表“搜索历史会话内容”,不是普通网页搜索 +- 如果后端继续补 catalog/source 关系,可以把来源再做细一点,例如: + - builtin + - session + - browser companion + - external skill + - provider + - delegation +- browser companion 不只显示开关,还应继续显示: + - 是否 ready + - command 是否配置 + - 哪些能力依赖它 +- external skills 后面可以从摘要继续长成“来源清单”,但仍要保持能力目录感,不要变成另一张状态页 + +当前结论: + +- `Skills` 不只是工具名字列表,而是能力目录 +- 后续优先继续接: + - 新 tools + - `session_search` + - source / dependency 关系 +- 不要把它做成另一张“状态页”或“插件后台” + +## Chat Personalization Follow-up + +- 当前本地 personalization 的保存和读取链路是通的:`preferred_name`、`response_density`、`initiative_level` 会写入 `~/.loong/config.toml`,并由 `/api/abilities/personalization` 返回 +- 当前剩下的问题是后端 prompt 行为,不是前端保存问题 +- 现象:chat 在 personalization 已经存在时,仍可能回答成“我不知道你的偏好称呼” + +根因方向: + +- personalization 会被渲染成 `## Session Profile` +- 这段 profile 会被注入到 chat 上下文 +- 但它目前仍属于 advisory context +- prompt contract 太弱,没能阻止“明明已知却回答不知道”这种自相矛盾回复 + +最低期望行为: + +- 只要 `preferred_name` 已配置,chat 就不应该声称“不知道这个偏好” +- 是否严格服从可以暂时仍保持 advisory,但不能允许这种事实性自相矛盾 + +## Chat Turn Phase UI Follow-up + +`turn.phase` 适合继续接到 Web chat UI,但不应该把后端原始事件名直接暴露给用户。 + +更合适的方式是把生命周期翻译成轻量、可读的过程提示,例如: + +- `preparing` -> 准备上下文 +- `requesting_provider` -> 正在请求模型 +- `running_tools` -> 正在调用工具 +- `requesting_followup_provider` -> 正在整理工具结果 +- `finalizing_reply` -> 正在整理最终回答 + +更推荐的 UI 形态: + +- assistant 占位消息上方的小状态条 / chip +- 或输入框上方的一行次级状态文案 + +主要价值: + +- 不再只显示模糊的“生成中” +- 用户能看懂当前是在等模型、跑工具,还是已经接近结束 +- chat streaming / tool runtime / session lifecycle 的真实状态可以更自然地被解释出来 + +## Skills Runtime Truth Follow-up + +- `Abilities -> Skills` 需要从“前端静态映射 + 少量名字”升级到“后端 runtime truth 投影” +- 优先接后端已有的 tools catalog / external skills / MCP registry / runtime gating 信息 +- 目标不是多加文案,而是让 Web 看到的能力面和后端当前真实能力面一致 + +## Tool Explainability Follow-up + +- `Dashboard / Abilities` 需要更明确解释“为什么可用 / 为什么不可用” +- 不应只停留在 enabled / disabled +- 优先把 workspace、shell cwd、file-root、runtime snapshot、channel readiness 这些后端真值转成可读原因 diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..d03fa629c --- /dev/null +++ b/web/index.html @@ -0,0 +1,18 @@ + + + + + + + + LoongClaw Web + + +
+ + + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 000000000..bb55c4aa3 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3673 @@ +{ + "name": "loongclaw-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loongclaw-web", + "version": "0.1.0", + "dependencies": { + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^0.577.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.5.8", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.1", + "react-syntax-highlighter": "^16.1.1", + "react-textarea-autosize": "^8.5.9", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "~5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/i18next": { + "version": "25.8.18", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", + "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-i18next": { + "version": "16.5.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz", + "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..514fa3e0b --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "loongclaw-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.app.json && vite build", + "preview": "vite preview", + "clean": "node -e \"const fs=require('fs');['dist','node_modules/.vite','tsconfig.app.tsbuildinfo','tsconfig.node.tsbuildinfo','vite.config.js','vite.config.d.ts'].forEach((p)=>fs.rmSync(p,{recursive:true,force:true}))\"" + }, + "dependencies": { + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^0.577.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.5.8", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.1", + "react-syntax-highlighter": "^16.1.1", + "react-textarea-autosize": "^8.5.9", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "~5.9.3", + "vite": "^7.3.1" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 000000000..9c422a7cb --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/public/site.webmanifest b/web/public/site.webmanifest new file mode 100644 index 000000000..5f6a7266b --- /dev/null +++ b/web/public/site.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "LoongClaw Web", + "short_name": "LoongClaw", + "start_url": "/", + "display": "standalone", + "background_color": "#0d1116", + "theme_color": "#0d1116", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 000000000..99a97d14f --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,11 @@ +import { RouterProvider } from "react-router-dom"; +import { router } from "./app/router"; +import { AppProviders } from "./app/providers"; + +export default function App() { + return ( + + + + ); +} diff --git a/web/src/app/i18n.ts b/web/src/app/i18n.ts new file mode 100644 index 000000000..d7b736397 --- /dev/null +++ b/web/src/app/i18n.ts @@ -0,0 +1,55 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; +import enApp from "../assets/locales/en/app.json"; +import enAbilities from "../assets/locales/en/abilities.json"; +import enChat from "../assets/locales/en/chat.json"; +import enDashboard from "../assets/locales/en/dashboard.json"; +import zhApp from "../assets/locales/zh-CN/app.json"; +import zhAbilities from "../assets/locales/zh-CN/abilities.json"; +import zhChat from "../assets/locales/zh-CN/chat.json"; +import zhDashboard from "../assets/locales/zh-CN/dashboard.json"; + +const resources = { + en: { + translation: { + ...enApp, + ...enAbilities, + chat: enChat, + dashboard: enDashboard, + }, + }, + "zh-CN": { + translation: { + ...zhApp, + ...zhAbilities, + chat: zhChat, + dashboard: zhDashboard, + }, + }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: "en", + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + }, + }); + +i18n.on("languageChanged", (language) => { + document.documentElement.lang = language; +}); + +if (document.documentElement) { + document.documentElement.lang = i18n.language || "en"; +} + +export default i18n; diff --git a/web/src/app/layouts/RootLayout.tsx b/web/src/app/layouts/RootLayout.tsx new file mode 100644 index 000000000..b2848ca07 --- /dev/null +++ b/web/src/app/layouts/RootLayout.tsx @@ -0,0 +1,61 @@ +import { Suspense, useRef, type ReactNode } from "react"; +import { useLocation, useOutlet } from "react-router-dom"; +import NavBar from "../../components/layout/NavBar"; +import { OnboardingStatusPanel } from "../../features/onboarding/components/OnboardingStatusPanel"; +import { useWebConnection } from "../../hooks/useWebConnection"; + +const KEEP_ALIVE_ROUTE_PATHS = new Set(["/chat", "/dashboard", "/abilities"]); + +export default function RootLayout() { + const { onboardingBlocked } = useWebConnection(); + const location = useLocation(); + const outlet = useOutlet(); + const cachedOutletsRef = useRef>(new Map()); + const isKeepAliveRoute = KEEP_ALIVE_ROUTE_PATHS.has(location.pathname); + + if ( + !onboardingBlocked && + outlet && + isKeepAliveRoute && + !cachedOutletsRef.current.has(location.pathname) + ) { + cachedOutletsRef.current.set(location.pathname, outlet); + } + + return ( +
+