mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.95.0
This commit is contained in:
@@ -1412,11 +1412,15 @@ pub fn run() {
|
||||
commands::aster_agent_cmd::command_api::provider_api::aster_agent_configure_from_pool,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_submit_turn,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_interrupt_turn,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_compact_session,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_resume_thread,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_promote_queued_turn,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_remove_queued_turn,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_replay_request,
|
||||
commands::aster_agent_cmd::command_api::session_api::agent_runtime_create_session,
|
||||
commands::aster_agent_cmd::command_api::session_api::agent_runtime_list_sessions,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_session,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_thread_read,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_send_subagent_input,
|
||||
|
||||
@@ -47,8 +47,10 @@ pub(crate) use provider_api::{
|
||||
aster_agent_reset, aster_agent_status,
|
||||
};
|
||||
pub(crate) use runtime_api::{
|
||||
agent_runtime_get_session, agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn,
|
||||
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, agent_runtime_submit_turn,
|
||||
agent_runtime_compact_session, agent_runtime_get_session, agent_runtime_get_thread_read,
|
||||
agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn,
|
||||
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn,
|
||||
agent_runtime_replay_request, agent_runtime_resume_thread, agent_runtime_submit_turn,
|
||||
};
|
||||
pub(crate) use session_api::{
|
||||
agent_runtime_create_session, agent_runtime_list_sessions, agent_runtime_update_session,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_submit_turn(
|
||||
@@ -44,6 +45,56 @@ pub async fn agent_runtime_interrupt_turn(
|
||||
Ok(cancelled || !cleared.is_empty())
|
||||
}
|
||||
|
||||
/// 统一运行时:压缩当前会话上下文。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_compact_session(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
request: AgentRuntimeCompactSessionRequest,
|
||||
) -> Result<(), String> {
|
||||
crate::commands::aster_agent_cmd::runtime_turn::compact_runtime_session_internal(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 统一运行时:恢复当前线程的排队执行。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_resume_thread(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeResumeThreadRequest,
|
||||
) -> Result<bool, String> {
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
if session_id.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
resume_runtime_queue_if_needed_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 统一运行时:获取会话详情。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_get_session(
|
||||
@@ -58,8 +109,6 @@ pub async fn agent_runtime_get_session(
|
||||
session_id: String,
|
||||
) -> Result<AgentRuntimeSessionDetail, String> {
|
||||
tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id);
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
|
||||
|
||||
if let Err(error) = resume_runtime_queue_if_needed_service(
|
||||
app,
|
||||
state.inner(),
|
||||
@@ -81,10 +130,88 @@ pub async fn agent_runtime_get_session(
|
||||
);
|
||||
}
|
||||
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
|
||||
let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?;
|
||||
Ok(AgentRuntimeSessionDetail::from_session_detail(
|
||||
detail,
|
||||
queued_turns,
|
||||
let projection = sync_thread_reliability_projection(db.inner(), &detail)?;
|
||||
let thread_read = AgentRuntimeThreadReadModel::from_parts(
|
||||
&detail,
|
||||
&queued_turns,
|
||||
projection.pending_requests,
|
||||
projection.last_outcome,
|
||||
projection.incidents,
|
||||
);
|
||||
Ok(
|
||||
AgentRuntimeSessionDetail::from_session_detail_with_thread_read(
|
||||
detail,
|
||||
queued_turns,
|
||||
thread_read,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// 统一运行时:仅获取线程稳定读模型。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_get_thread_read(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<AgentRuntimeThreadReadModel, String> {
|
||||
tracing::info!("[AsterAgent] 获取运行时线程读模型: {}", session_id);
|
||||
if let Err(error) = resume_runtime_queue_if_needed_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id.clone(),
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Queue] 获取线程读模型后恢复排队执行失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
|
||||
let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?;
|
||||
let projection = sync_thread_reliability_projection(db.inner(), &detail)?;
|
||||
Ok(AgentRuntimeThreadReadModel::from_parts(
|
||||
&detail,
|
||||
&queued_turns,
|
||||
projection.pending_requests,
|
||||
projection.last_outcome,
|
||||
projection.incidents,
|
||||
))
|
||||
}
|
||||
|
||||
/// 统一运行时:重新拉起指定 pending request 的前端交互载荷。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_replay_request(
|
||||
db: State<'_, DbConnection>,
|
||||
request: AgentRuntimeReplayRequestRequest,
|
||||
) -> Result<Option<AgentRuntimeReplayedActionRequiredView>, String> {
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
let request_id = request.request_id.trim().to_string();
|
||||
if session_id.is_empty() || request_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
|
||||
Ok(AgentRuntimeReplayedActionRequiredView::from_session_detail(
|
||||
&detail,
|
||||
&request_id,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Aster Agent 状态信息
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -177,6 +178,28 @@ pub struct AgentRuntimeInterruptTurnRequest {
|
||||
pub turn_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeCompactSessionRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(alias = "eventName")]
|
||||
pub event_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeResumeThreadRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeReplayRequestRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(alias = "requestId")]
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeRemoveQueuedTurnRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
@@ -208,16 +231,118 @@ pub struct AgentRuntimeSessionDetail {
|
||||
pub todo_items: Vec<lime_agent::SessionTodoItem>,
|
||||
#[serde(default)]
|
||||
pub queued_turns: Vec<QueuedTurnSnapshot>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_read: Option<AgentRuntimeThreadReadModel>,
|
||||
#[serde(default)]
|
||||
pub child_subagent_sessions: Vec<lime_agent::ChildSubagentSession>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_parent_context: Option<lime_agent::SubagentParentContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeRequestView {
|
||||
pub id: String,
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_id: Option<String>,
|
||||
pub request_type: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decision: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeOutcomeView {
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
pub outcome_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_cause: Option<String>,
|
||||
pub retryable: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ended_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeIncidentView {
|
||||
pub id: String,
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_id: Option<String>,
|
||||
pub incident_type: String,
|
||||
pub severity: String,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detected_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cleared_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeThreadReadModel {
|
||||
pub thread_id: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub active_turn_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pending_requests: Vec<AgentRuntimeRequestView>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_outcome: Option<AgentRuntimeOutcomeView>,
|
||||
#[serde(default)]
|
||||
pub incidents: Vec<AgentRuntimeIncidentView>,
|
||||
#[serde(default)]
|
||||
pub queued_turns: Vec<QueuedTurnSnapshot>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interrupt_state: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeReplayedActionRequiredView {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub request_id: String,
|
||||
pub action_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub questions: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requested_schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<AgentRuntimeActionScope>,
|
||||
}
|
||||
|
||||
impl AgentRuntimeSessionDetail {
|
||||
pub(crate) fn from_session_detail(
|
||||
pub(crate) fn from_session_detail_with_thread_read(
|
||||
detail: SessionDetail,
|
||||
queued_turns: Vec<QueuedTurnSnapshot>,
|
||||
thread_read: AgentRuntimeThreadReadModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: detail.id,
|
||||
@@ -231,12 +356,580 @@ impl AgentRuntimeSessionDetail {
|
||||
items: detail.items,
|
||||
todo_items: detail.todo_items,
|
||||
queued_turns,
|
||||
thread_read: Some(thread_read),
|
||||
child_subagent_sessions: detail.child_subagent_sessions,
|
||||
subagent_parent_context: detail.subagent_parent_context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentRuntimeReplayedActionRequiredView {
|
||||
pub(crate) fn from_session_detail(detail: &SessionDetail, request_id: &str) -> Option<Self> {
|
||||
let trimmed_request_id = request_id.trim();
|
||||
if trimmed_request_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
detail.items.iter().rev().find_map(|item| {
|
||||
if !matches!(
|
||||
item.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let scope = Some(AgentRuntimeActionScope {
|
||||
session_id: Some(detail.id.clone()),
|
||||
thread_id: Some(item.thread_id.clone()),
|
||||
turn_id: Some(item.turn_id.clone()),
|
||||
});
|
||||
|
||||
match &item.payload {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ApprovalRequest {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
tool_name,
|
||||
arguments,
|
||||
..
|
||||
} if request_id == trimmed_request_id => Some(Self {
|
||||
event_type: "action_required".to_string(),
|
||||
request_id: request_id.clone(),
|
||||
action_type: action_type.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
arguments: arguments.clone(),
|
||||
prompt: prompt.clone(),
|
||||
questions: None,
|
||||
requested_schema: None,
|
||||
scope,
|
||||
}),
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
questions,
|
||||
..
|
||||
} if request_id == trimmed_request_id => Some(Self {
|
||||
event_type: "action_required".to_string(),
|
||||
request_id: request_id.clone(),
|
||||
action_type: action_type.clone(),
|
||||
tool_name: None,
|
||||
arguments: None,
|
||||
prompt: prompt.clone(),
|
||||
questions: questions
|
||||
.as_ref()
|
||||
.and_then(|value| serde_json::to_value(value).ok()),
|
||||
requested_schema: None,
|
||||
scope,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentRuntimeThreadReadModel {
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn from_session_detail(
|
||||
detail: &SessionDetail,
|
||||
queued_turns: &[QueuedTurnSnapshot],
|
||||
) -> Self {
|
||||
let pending_requests = build_pending_requests(detail);
|
||||
let last_outcome = build_last_outcome(detail);
|
||||
let incidents = build_incidents(detail, &pending_requests);
|
||||
Self::from_parts(
|
||||
detail,
|
||||
queued_turns,
|
||||
pending_requests,
|
||||
last_outcome,
|
||||
incidents,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(
|
||||
detail: &SessionDetail,
|
||||
queued_turns: &[QueuedTurnSnapshot],
|
||||
pending_requests: Vec<AgentRuntimeRequestView>,
|
||||
last_outcome: Option<AgentRuntimeOutcomeView>,
|
||||
incidents: Vec<AgentRuntimeIncidentView>,
|
||||
) -> Self {
|
||||
let latest_turn = detail.turns.last();
|
||||
let active_turn = detail
|
||||
.turns
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|turn| {
|
||||
matches!(
|
||||
turn.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running
|
||||
)
|
||||
})
|
||||
.or(latest_turn);
|
||||
let status = if !pending_requests.is_empty() {
|
||||
"waiting_request".to_string()
|
||||
} else if active_turn
|
||||
.map(|turn| {
|
||||
matches!(
|
||||
turn.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"running".to_string()
|
||||
} else if let Some(turn) = latest_turn {
|
||||
turn.status.as_str().to_string()
|
||||
} else if !queued_turns.is_empty() {
|
||||
"queued".to_string()
|
||||
} else {
|
||||
"idle".to_string()
|
||||
};
|
||||
let interrupt_state = latest_turn.and_then(|turn| {
|
||||
if matches!(
|
||||
turn.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Aborted
|
||||
) {
|
||||
Some("interrupted".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
thread_id: detail.thread_id.clone(),
|
||||
status,
|
||||
active_turn_id: active_turn.map(|turn| turn.id.clone()),
|
||||
pending_requests,
|
||||
last_outcome,
|
||||
incidents,
|
||||
queued_turns: queued_turns.to_vec(),
|
||||
interrupt_state,
|
||||
updated_at: latest_turn
|
||||
.map(|turn| turn.updated_at.clone())
|
||||
.or_else(|| Some(detail.updated_at.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const APPROVAL_TIMEOUT_SECONDS: i64 = 180;
|
||||
const USER_INPUT_TIMEOUT_SECONDS: i64 = 300;
|
||||
const TURN_STUCK_TIMEOUT_SECONDS: i64 = 180;
|
||||
|
||||
fn parse_rfc3339_utc(raw: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
.ok()
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn elapsed_seconds_since(raw: Option<&str>, now: &DateTime<Utc>) -> Option<i64> {
|
||||
let parsed = parse_rfc3339_utc(raw?)?;
|
||||
Some(now.signed_duration_since(parsed).num_seconds().max(0))
|
||||
}
|
||||
|
||||
fn is_tool_confirmation_request(request_type: &str) -> bool {
|
||||
let normalized = request_type.to_ascii_lowercase();
|
||||
normalized.contains("tool") || normalized.contains("approval")
|
||||
}
|
||||
|
||||
fn is_user_input_request(request_type: &str) -> bool {
|
||||
let normalized = request_type.to_ascii_lowercase();
|
||||
normalized.contains("ask") || normalized.contains("user") || normalized.contains("elicitation")
|
||||
}
|
||||
|
||||
fn request_timeout_threshold_seconds(request: &AgentRuntimeRequestView) -> Option<i64> {
|
||||
if is_tool_confirmation_request(&request.request_type) {
|
||||
return Some(APPROVAL_TIMEOUT_SECONDS);
|
||||
}
|
||||
if is_user_input_request(&request.request_type) {
|
||||
return Some(USER_INPUT_TIMEOUT_SECONDS);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_pending_request_incident(
|
||||
request: &AgentRuntimeRequestView,
|
||||
now: &DateTime<Utc>,
|
||||
) -> AgentRuntimeIncidentView {
|
||||
let waited_seconds = elapsed_seconds_since(request.created_at.as_deref(), now).unwrap_or(0);
|
||||
let timeout_seconds =
|
||||
request_timeout_threshold_seconds(request).unwrap_or(USER_INPUT_TIMEOUT_SECONDS);
|
||||
let waited_minutes = ((waited_seconds + 59) / 60).max(1);
|
||||
let request_title = request
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "线程正在等待人工处理".to_string());
|
||||
|
||||
let (incident_type, severity, title, details) =
|
||||
if is_tool_confirmation_request(&request.request_type) {
|
||||
if waited_seconds >= timeout_seconds {
|
||||
(
|
||||
"approval_timeout".to_string(),
|
||||
"high".to_string(),
|
||||
"审批等待超过阈值".to_string(),
|
||||
Some(serde_json::Value::String(format!(
|
||||
"工具确认已等待 {waited_minutes} 分钟:{request_title}"
|
||||
))),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"waiting_approval".to_string(),
|
||||
"medium".to_string(),
|
||||
"线程正在等待工具确认".to_string(),
|
||||
Some(serde_json::Value::String(request_title)),
|
||||
)
|
||||
}
|
||||
} else if waited_seconds >= timeout_seconds {
|
||||
(
|
||||
"user_input_timeout".to_string(),
|
||||
"high".to_string(),
|
||||
"人工输入等待超过阈值".to_string(),
|
||||
Some(serde_json::Value::String(format!(
|
||||
"人工输入已等待 {waited_minutes} 分钟:{request_title}"
|
||||
))),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"waiting_user_input".to_string(),
|
||||
"medium".to_string(),
|
||||
"线程正在等待人工输入".to_string(),
|
||||
Some(serde_json::Value::String(request_title)),
|
||||
)
|
||||
};
|
||||
|
||||
AgentRuntimeIncidentView {
|
||||
id: format!("incident-{}", request.id),
|
||||
thread_id: request.thread_id.clone(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
item_id: request.item_id.clone(),
|
||||
incident_type,
|
||||
severity,
|
||||
status: "active".to_string(),
|
||||
title,
|
||||
details,
|
||||
detected_at: request.created_at.clone(),
|
||||
cleared_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_pending_requests(detail: &SessionDetail) -> Vec<AgentRuntimeRequestView> {
|
||||
detail
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match &item.payload {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ApprovalRequest {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
tool_name,
|
||||
arguments,
|
||||
response,
|
||||
} if matches!(
|
||||
item.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress
|
||||
) =>
|
||||
{
|
||||
Some(AgentRuntimeRequestView {
|
||||
id: request_id.clone(),
|
||||
thread_id: item.thread_id.clone(),
|
||||
turn_id: Some(item.turn_id.clone()),
|
||||
item_id: Some(item.id.clone()),
|
||||
request_type: action_type.clone(),
|
||||
status: "pending".to_string(),
|
||||
title: prompt
|
||||
.clone()
|
||||
.or_else(|| tool_name.as_ref().map(|value| format!("等待确认工具:{value}"))),
|
||||
payload: arguments.clone(),
|
||||
decision: response.clone(),
|
||||
scope: Some(serde_json::json!({
|
||||
"thread_id": item.thread_id,
|
||||
"turn_id": item.turn_id,
|
||||
"item_id": item.id,
|
||||
})),
|
||||
created_at: Some(item.started_at.clone()),
|
||||
resolved_at: None,
|
||||
})
|
||||
}
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
questions,
|
||||
response,
|
||||
} if matches!(
|
||||
item.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress
|
||||
) =>
|
||||
{
|
||||
Some(AgentRuntimeRequestView {
|
||||
id: request_id.clone(),
|
||||
thread_id: item.thread_id.clone(),
|
||||
turn_id: Some(item.turn_id.clone()),
|
||||
item_id: Some(item.id.clone()),
|
||||
request_type: action_type.clone(),
|
||||
status: "pending".to_string(),
|
||||
title: prompt.clone().or_else(|| {
|
||||
questions
|
||||
.as_ref()
|
||||
.and_then(|items| items.first())
|
||||
.map(|question| question.question.clone())
|
||||
}),
|
||||
payload: questions
|
||||
.as_ref()
|
||||
.and_then(|value| serde_json::to_value(value).ok()),
|
||||
decision: response.clone(),
|
||||
scope: Some(serde_json::json!({
|
||||
"thread_id": item.thread_id,
|
||||
"turn_id": item.turn_id,
|
||||
"item_id": item.id,
|
||||
})),
|
||||
created_at: Some(item.started_at.clone()),
|
||||
resolved_at: None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_last_outcome(detail: &SessionDetail) -> Option<AgentRuntimeOutcomeView> {
|
||||
let latest_turn = detail.turns.last()?;
|
||||
let latest_turn_summary = detail
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|item| match &item.payload {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::TurnSummary {
|
||||
text,
|
||||
} if item.turn_id == latest_turn.id => Some(text.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let latest_failed_item = detail.items.iter().rev().find(|item| {
|
||||
item.turn_id == latest_turn.id
|
||||
&& matches!(
|
||||
item.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed
|
||||
)
|
||||
});
|
||||
|
||||
match latest_turn.status {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Completed => {
|
||||
Some(AgentRuntimeOutcomeView {
|
||||
thread_id: latest_turn.thread_id.clone(),
|
||||
turn_id: Some(latest_turn.id.clone()),
|
||||
outcome_type: "completed".to_string(),
|
||||
summary: latest_turn_summary.or_else(|| Some("最近一次回合已稳定完成".to_string())),
|
||||
primary_cause: None,
|
||||
retryable: false,
|
||||
ended_at: latest_turn.completed_at.clone(),
|
||||
})
|
||||
}
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Aborted => {
|
||||
Some(AgentRuntimeOutcomeView {
|
||||
thread_id: latest_turn.thread_id.clone(),
|
||||
turn_id: Some(latest_turn.id.clone()),
|
||||
outcome_type: "interrupted".to_string(),
|
||||
summary: Some("最近一次回合已被中断".to_string()),
|
||||
primary_cause: None,
|
||||
retryable: true,
|
||||
ended_at: latest_turn.completed_at.clone(),
|
||||
})
|
||||
}
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Failed => {
|
||||
let (outcome_type, primary_cause) =
|
||||
classify_failed_turn(latest_turn, latest_failed_item);
|
||||
Some(AgentRuntimeOutcomeView {
|
||||
thread_id: latest_turn.thread_id.clone(),
|
||||
turn_id: Some(latest_turn.id.clone()),
|
||||
outcome_type,
|
||||
summary: latest_turn
|
||||
.error_message
|
||||
.clone()
|
||||
.or_else(|| primary_cause.clone())
|
||||
.or_else(|| Some("最近一次回合执行失败".to_string())),
|
||||
primary_cause,
|
||||
retryable: true,
|
||||
ended_at: latest_turn.completed_at.clone(),
|
||||
})
|
||||
}
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_failed_turn(
|
||||
turn: &lime_core::database::dao::agent_timeline::AgentThreadTurn,
|
||||
failed_item: Option<&lime_core::database::dao::agent_timeline::AgentThreadItem>,
|
||||
) -> (String, Option<String>) {
|
||||
if let Some(item) = failed_item {
|
||||
match &item.payload {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ToolCall {
|
||||
error, ..
|
||||
}
|
||||
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::CommandExecution {
|
||||
error, ..
|
||||
} => {
|
||||
return (
|
||||
"failed_tool".to_string(),
|
||||
error.clone().or_else(|| turn.error_message.clone()),
|
||||
);
|
||||
}
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error { message } => {
|
||||
return ("failed_tool".to_string(), Some(message.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let lowered_error = turn
|
||||
.error_message
|
||||
.as_deref()
|
||||
.map(|value| value.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
if lowered_error.contains("provider")
|
||||
|| lowered_error.contains("rate limit")
|
||||
|| lowered_error.contains("authentication")
|
||||
|| lowered_error.contains("network")
|
||||
|| lowered_error.contains("api")
|
||||
{
|
||||
return ("failed_provider".to_string(), turn.error_message.clone());
|
||||
}
|
||||
|
||||
("failed_model".to_string(), turn.error_message.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn build_incidents(
|
||||
detail: &SessionDetail,
|
||||
pending_requests: &[AgentRuntimeRequestView],
|
||||
) -> Vec<AgentRuntimeIncidentView> {
|
||||
let now = Utc::now();
|
||||
|
||||
if let Some(request) = pending_requests.first() {
|
||||
return vec![build_pending_request_incident(request, &now)];
|
||||
}
|
||||
|
||||
let latest_turn = match detail.turns.last() {
|
||||
Some(value) => value,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
if matches!(
|
||||
latest_turn.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Failed
|
||||
) {
|
||||
let latest_failed_item = detail.items.iter().rev().find(|item| {
|
||||
item.turn_id == latest_turn.id
|
||||
&& matches!(
|
||||
item.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed
|
||||
)
|
||||
});
|
||||
let (outcome_type, primary_cause) = classify_failed_turn(latest_turn, latest_failed_item);
|
||||
let (incident_type, title) = match outcome_type.as_str() {
|
||||
"failed_tool" => ("tool_failed".to_string(), "工具执行失败".to_string()),
|
||||
"failed_provider" => (
|
||||
"provider_error".to_string(),
|
||||
"Provider 请求失败".to_string(),
|
||||
),
|
||||
_ => (
|
||||
"turn_failed".to_string(),
|
||||
"最近一次回合执行失败".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
return vec![AgentRuntimeIncidentView {
|
||||
id: format!("incident-turn-failed-{}", latest_turn.id),
|
||||
thread_id: latest_turn.thread_id.clone(),
|
||||
turn_id: Some(latest_turn.id.clone()),
|
||||
item_id: latest_failed_item.map(|item| item.id.clone()),
|
||||
incident_type,
|
||||
severity: "high".to_string(),
|
||||
status: "active".to_string(),
|
||||
title,
|
||||
details: primary_cause.map(serde_json::Value::String),
|
||||
detected_at: Some(latest_turn.updated_at.clone()),
|
||||
cleared_at: None,
|
||||
}];
|
||||
}
|
||||
|
||||
if matches!(
|
||||
latest_turn.status,
|
||||
lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running
|
||||
) && elapsed_seconds_since(Some(latest_turn.updated_at.as_str()), &now)
|
||||
.map(|value| value >= TURN_STUCK_TIMEOUT_SECONDS)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let waited_seconds =
|
||||
elapsed_seconds_since(Some(latest_turn.updated_at.as_str()), &now).unwrap_or(0);
|
||||
let waited_minutes = ((waited_seconds + 59) / 60).max(1);
|
||||
let prompt_preview = latest_turn.prompt_text.trim();
|
||||
let details = if prompt_preview.is_empty() {
|
||||
format!("最近 {waited_minutes} 分钟内没有新的线程更新,可尝试停止后恢复执行。")
|
||||
} else {
|
||||
format!(
|
||||
"回合“{prompt_preview}”最近 {waited_minutes} 分钟内没有新的线程更新,可尝试停止后恢复执行。"
|
||||
)
|
||||
};
|
||||
|
||||
return vec![AgentRuntimeIncidentView {
|
||||
id: format!("incident-turn-stuck-{}", latest_turn.id),
|
||||
thread_id: latest_turn.thread_id.clone(),
|
||||
turn_id: Some(latest_turn.id.clone()),
|
||||
item_id: None,
|
||||
incident_type: "turn_stuck".to_string(),
|
||||
severity: "high".to_string(),
|
||||
status: "active".to_string(),
|
||||
title: "当前回合长时间无进展".to_string(),
|
||||
details: Some(serde_json::Value::String(details)),
|
||||
detected_at: Some(latest_turn.updated_at.clone()),
|
||||
cleared_at: None,
|
||||
}];
|
||||
}
|
||||
|
||||
let latest_issue_item = detail.items.iter().rev().find(|item| match &item.payload {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Warning { .. }
|
||||
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error { .. } => true,
|
||||
_ => false,
|
||||
});
|
||||
|
||||
match latest_issue_item {
|
||||
Some(item) => {
|
||||
let (incident_type, title, details, severity) = match &item.payload {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Warning {
|
||||
message,
|
||||
code,
|
||||
} => (
|
||||
"runtime_warning".to_string(),
|
||||
"时间线记录到警告项".to_string(),
|
||||
Some(serde_json::json!({ "message": message, "code": code })),
|
||||
"medium".to_string(),
|
||||
),
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error {
|
||||
message,
|
||||
} => (
|
||||
"runtime_error".to_string(),
|
||||
"时间线记录到异常项".to_string(),
|
||||
Some(serde_json::json!({ "message": message })),
|
||||
"high".to_string(),
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
vec![AgentRuntimeIncidentView {
|
||||
id: format!("incident-item-{}", item.id),
|
||||
thread_id: item.thread_id.clone(),
|
||||
turn_id: Some(item.turn_id.clone()),
|
||||
item_id: Some(item.id.clone()),
|
||||
incident_type,
|
||||
severity,
|
||||
status: "active".to_string(),
|
||||
title,
|
||||
details,
|
||||
detected_at: Some(item.updated_at.clone()),
|
||||
cleared_at: None,
|
||||
}]
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentRuntimeSpawnSubagentRequest {
|
||||
#[serde(alias = "parentSessionId")]
|
||||
@@ -429,3 +1122,249 @@ impl AutoContinuePayload {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Duration, Utc};
|
||||
use lime_agent::queued_turn::QueuedTurnSnapshot;
|
||||
use lime_core::database::dao::agent_timeline::{
|
||||
AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus,
|
||||
AgentThreadTurn, AgentThreadTurnStatus,
|
||||
};
|
||||
|
||||
fn build_session_detail(
|
||||
turns: Vec<AgentThreadTurn>,
|
||||
items: Vec<AgentThreadItem>,
|
||||
) -> SessionDetail {
|
||||
SessionDetail {
|
||||
id: "session-1".to_string(),
|
||||
name: "测试会话".to_string(),
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
thread_id: "thread-1".to_string(),
|
||||
model: None,
|
||||
working_dir: None,
|
||||
workspace_id: None,
|
||||
messages: Vec::new(),
|
||||
execution_strategy: None,
|
||||
turns,
|
||||
items,
|
||||
todo_items: Vec::new(),
|
||||
child_subagent_sessions: Vec::new(),
|
||||
subagent_parent_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds_ago(seconds: i64) -> String {
|
||||
(Utc::now() - Duration::seconds(seconds)).to_rfc3339()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_read_should_expose_pending_request_and_waiting_incident() {
|
||||
let detail = build_session_detail(
|
||||
vec![AgentThreadTurn {
|
||||
id: "turn-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "继续发布".to_string(),
|
||||
status: AgentThreadTurnStatus::Running,
|
||||
started_at: seconds_ago(20),
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
created_at: seconds_ago(20),
|
||||
updated_at: seconds_ago(10),
|
||||
}],
|
||||
vec![AgentThreadItem {
|
||||
id: "item-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 1,
|
||||
status: AgentThreadItemStatus::InProgress,
|
||||
started_at: seconds_ago(15),
|
||||
completed_at: None,
|
||||
updated_at: seconds_ago(15),
|
||||
payload: AgentThreadItemPayload::RequestUserInput {
|
||||
request_id: "req-1".to_string(),
|
||||
action_type: "ask_user".to_string(),
|
||||
prompt: Some("请确认是否继续发布".to_string()),
|
||||
questions: Some(vec![AgentRequestQuestion {
|
||||
question: "是否继续?".to_string(),
|
||||
header: None,
|
||||
options: None,
|
||||
multi_select: None,
|
||||
}]),
|
||||
response: None,
|
||||
},
|
||||
}],
|
||||
);
|
||||
|
||||
let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]);
|
||||
|
||||
assert_eq!(thread_read.status, "waiting_request");
|
||||
assert_eq!(thread_read.active_turn_id.as_deref(), Some("turn-1"));
|
||||
assert_eq!(thread_read.pending_requests.len(), 1);
|
||||
assert_eq!(thread_read.pending_requests[0].id, "req-1");
|
||||
assert_eq!(thread_read.incidents.len(), 1);
|
||||
assert_eq!(thread_read.incidents[0].incident_type, "waiting_user_input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_read_should_escalate_tool_confirmation_timeout() {
|
||||
let detail = build_session_detail(
|
||||
vec![AgentThreadTurn {
|
||||
id: "turn-timeout".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "继续执行工具调用".to_string(),
|
||||
status: AgentThreadTurnStatus::Running,
|
||||
started_at: seconds_ago(400),
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
created_at: seconds_ago(400),
|
||||
updated_at: seconds_ago(200),
|
||||
}],
|
||||
vec![AgentThreadItem {
|
||||
id: "item-timeout".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-timeout".to_string(),
|
||||
sequence: 1,
|
||||
status: AgentThreadItemStatus::InProgress,
|
||||
started_at: seconds_ago(APPROVAL_TIMEOUT_SECONDS + 60),
|
||||
completed_at: None,
|
||||
updated_at: seconds_ago(APPROVAL_TIMEOUT_SECONDS + 60),
|
||||
payload: AgentThreadItemPayload::ApprovalRequest {
|
||||
request_id: "req-timeout".to_string(),
|
||||
action_type: "tool_confirmation".to_string(),
|
||||
prompt: Some("请确认是否执行 apply_patch".to_string()),
|
||||
tool_name: Some("apply_patch".to_string()),
|
||||
arguments: None,
|
||||
response: None,
|
||||
},
|
||||
}],
|
||||
);
|
||||
|
||||
let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]);
|
||||
|
||||
assert_eq!(thread_read.status, "waiting_request");
|
||||
assert_eq!(thread_read.incidents.len(), 1);
|
||||
assert_eq!(thread_read.incidents[0].incident_type, "approval_timeout");
|
||||
assert_eq!(thread_read.incidents[0].severity, "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_read_should_expose_failed_outcome_and_queue_snapshot() {
|
||||
let detail = build_session_detail(
|
||||
vec![AgentThreadTurn {
|
||||
id: "turn-2".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "执行外部调用".to_string(),
|
||||
status: AgentThreadTurnStatus::Failed,
|
||||
started_at: "2026-03-23T09:10:00Z".to_string(),
|
||||
completed_at: Some("2026-03-23T09:10:30Z".to_string()),
|
||||
error_message: Some("Provider 错误: rate limit".to_string()),
|
||||
created_at: "2026-03-23T09:10:00Z".to_string(),
|
||||
updated_at: "2026-03-23T09:10:30Z".to_string(),
|
||||
}],
|
||||
Vec::new(),
|
||||
);
|
||||
let queued_turns = vec![QueuedTurnSnapshot {
|
||||
queued_turn_id: "queued-1".to_string(),
|
||||
message_preview: "继续重试".to_string(),
|
||||
message_text: "继续重试 provider 请求".to_string(),
|
||||
created_at: 1_742_721_830,
|
||||
image_count: 0,
|
||||
position: 1,
|
||||
}];
|
||||
|
||||
let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &queued_turns);
|
||||
|
||||
assert_eq!(thread_read.status, "failed");
|
||||
assert_eq!(thread_read.queued_turns.len(), 1);
|
||||
assert_eq!(
|
||||
thread_read
|
||||
.last_outcome
|
||||
.as_ref()
|
||||
.map(|value| value.outcome_type.as_str()),
|
||||
Some("failed_provider")
|
||||
);
|
||||
assert_eq!(thread_read.incidents.len(), 1);
|
||||
assert_eq!(thread_read.incidents[0].incident_type, "provider_error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_read_should_classify_running_turn_stuck() {
|
||||
let detail = build_session_detail(
|
||||
vec![AgentThreadTurn {
|
||||
id: "turn-stuck".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "长时间执行无响应".to_string(),
|
||||
status: AgentThreadTurnStatus::Running,
|
||||
started_at: seconds_ago(TURN_STUCK_TIMEOUT_SECONDS + 120),
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
created_at: seconds_ago(TURN_STUCK_TIMEOUT_SECONDS + 120),
|
||||
updated_at: seconds_ago(TURN_STUCK_TIMEOUT_SECONDS + 30),
|
||||
}],
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]);
|
||||
|
||||
assert_eq!(thread_read.status, "running");
|
||||
assert_eq!(thread_read.incidents.len(), 1);
|
||||
assert_eq!(thread_read.incidents[0].incident_type, "turn_stuck");
|
||||
assert_eq!(thread_read.incidents[0].severity, "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_request_should_rebuild_pending_action_payload() {
|
||||
let detail = build_session_detail(
|
||||
vec![AgentThreadTurn {
|
||||
id: "turn-replay".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "继续等待输入".to_string(),
|
||||
status: AgentThreadTurnStatus::Running,
|
||||
started_at: seconds_ago(30),
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
created_at: seconds_ago(30),
|
||||
updated_at: seconds_ago(10),
|
||||
}],
|
||||
vec![AgentThreadItem {
|
||||
id: "item-replay".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-replay".to_string(),
|
||||
sequence: 1,
|
||||
status: AgentThreadItemStatus::InProgress,
|
||||
started_at: seconds_ago(20),
|
||||
completed_at: None,
|
||||
updated_at: seconds_ago(15),
|
||||
payload: AgentThreadItemPayload::RequestUserInput {
|
||||
request_id: "req-replay".to_string(),
|
||||
action_type: "ask_user".to_string(),
|
||||
prompt: Some("请确认是否继续发布".to_string()),
|
||||
questions: Some(vec![AgentRequestQuestion {
|
||||
question: "是否继续?".to_string(),
|
||||
header: None,
|
||||
options: None,
|
||||
multi_select: None,
|
||||
}]),
|
||||
response: None,
|
||||
},
|
||||
}],
|
||||
);
|
||||
|
||||
let replayed =
|
||||
AgentRuntimeReplayedActionRequiredView::from_session_detail(&detail, "req-replay")
|
||||
.expect("应能重建 replay 请求");
|
||||
|
||||
assert_eq!(replayed.event_type, "action_required");
|
||||
assert_eq!(replayed.request_id, "req-replay");
|
||||
assert_eq!(replayed.action_type, "ask_user");
|
||||
assert_eq!(replayed.prompt.as_deref(), Some("请确认是否继续发布"));
|
||||
assert!(replayed.questions.is_some());
|
||||
assert_eq!(
|
||||
replayed.scope.and_then(|scope| scope.turn_id),
|
||||
Some("turn-replay".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +70,7 @@ use aster::sandbox::{
|
||||
detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig,
|
||||
};
|
||||
use aster::session::extension_data::{ExtensionData, ExtensionState};
|
||||
use aster::session::{
|
||||
list_subagent_child_sessions, require_shared_thread_runtime_store,
|
||||
resolve_subagent_session_metadata, SessionManager, SessionType, SubagentSessionMetadata,
|
||||
};
|
||||
use aster::session::{SessionType, SubagentSessionMetadata};
|
||||
use aster::tools::task_output_tool::TaskOutputInput;
|
||||
use aster::tools::{
|
||||
BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager,
|
||||
@@ -93,19 +90,21 @@ use lime_agent::request_tool_policy::{
|
||||
use lime_agent::{
|
||||
acquire_provider_runtime_permit, acquire_team_runtime_permit,
|
||||
build_subagent_customization_prompt, builtin_profile_descriptor_by_id,
|
||||
builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, is_virtual_memory_path,
|
||||
list_subagent_cascade_session_ids, load_subagent_runtime_status,
|
||||
builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, create_subagent_session,
|
||||
is_virtual_memory_path, list_child_subagent_sessions, list_subagent_cascade_session_ids,
|
||||
list_subagent_status_scope_session_ids, load_subagent_runtime_status,
|
||||
merge_system_prompt_with_runtime_agents, message_suggests_news_expansion,
|
||||
normalize_team_runtime_provider_group, preview_provider_runtime_wait_snapshot,
|
||||
preview_team_runtime_wait_snapshot, read_subagent_control_state,
|
||||
release_provider_runtime_permit, release_team_runtime_permit,
|
||||
normalize_team_runtime_provider_group, persist_compaction_session_metrics_update,
|
||||
persist_session_extension_data, preview_provider_runtime_wait_snapshot,
|
||||
preview_team_runtime_wait_snapshot, read_session, read_subagent_control_state,
|
||||
release_provider_runtime_permit, release_team_runtime_permit, replace_session_conversation,
|
||||
resolve_provider_runtime_parallel_budget, resolve_virtual_memory_path,
|
||||
snapshot_provider_runtime_lease, snapshot_team_runtime_session, summarize_builtin_skill,
|
||||
virtual_memory_relative_path, write_subagent_control_state, ProviderContinuationCapability,
|
||||
ProviderContinuationCapable, ProviderContinuationState, ProviderRuntimeGovernorSnapshot,
|
||||
RuntimeProjectionSnapshot, SessionStateSnapshot, SubagentControlState,
|
||||
SubagentCustomizationState, SubagentRuntimeStatus, SubagentRuntimeStatusKind,
|
||||
SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus,
|
||||
virtual_memory_relative_path, write_subagent_control_state, CompactionSessionMetricsUpdate,
|
||||
ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState,
|
||||
ProviderRuntimeGovernorSnapshot, RuntimeProjectionSnapshot, SessionStateSnapshot,
|
||||
SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus,
|
||||
SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus,
|
||||
TeamRuntimeGovernorSnapshot, TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind,
|
||||
TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource,
|
||||
DURABLE_MEMORY_VIRTUAL_ROOT,
|
||||
@@ -297,22 +296,27 @@ pub(crate) use browser_assist::{
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use command_api::{
|
||||
agent_runtime_close_subagent, agent_runtime_create_session, agent_runtime_get_session,
|
||||
agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, agent_runtime_list_sessions,
|
||||
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn,
|
||||
agent_runtime_resume_subagent, agent_runtime_send_subagent_input, agent_runtime_spawn_subagent,
|
||||
agent_runtime_close_subagent, agent_runtime_compact_session, agent_runtime_create_session,
|
||||
agent_runtime_get_session, agent_runtime_get_thread_read, agent_runtime_get_tool_inventory,
|
||||
agent_runtime_interrupt_turn, agent_runtime_list_sessions, agent_runtime_promote_queued_turn,
|
||||
agent_runtime_remove_queued_turn, agent_runtime_replay_request, agent_runtime_resume_subagent,
|
||||
agent_runtime_resume_thread, agent_runtime_send_subagent_input, agent_runtime_spawn_subagent,
|
||||
agent_runtime_submit_turn, agent_runtime_update_session, agent_runtime_wait_subagents,
|
||||
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
|
||||
aster_agent_reset, aster_agent_status,
|
||||
};
|
||||
pub(crate) use dto::{
|
||||
AgentRuntimeActionType, AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse,
|
||||
AgentRuntimeInterruptTurnRequest, AgentRuntimePromoteQueuedTurnRequest,
|
||||
AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeRespondActionRequest,
|
||||
AgentRuntimeResumeSubagentRequest, AgentRuntimeResumeSubagentResponse,
|
||||
build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeActionType,
|
||||
AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse,
|
||||
AgentRuntimeCompactSessionRequest, AgentRuntimeIncidentView, AgentRuntimeInterruptTurnRequest,
|
||||
AgentRuntimeOutcomeView, AgentRuntimePromoteQueuedTurnRequest,
|
||||
AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeReplayRequestRequest,
|
||||
AgentRuntimeReplayedActionRequiredView, AgentRuntimeRequestView,
|
||||
AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest,
|
||||
AgentRuntimeResumeSubagentResponse, AgentRuntimeResumeThreadRequest,
|
||||
AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse,
|
||||
AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse,
|
||||
AgentRuntimeSubmitTurnRequest, AgentRuntimeToolInventoryRequest,
|
||||
AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadReadModel, AgentRuntimeToolInventoryRequest,
|
||||
AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest,
|
||||
AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload,
|
||||
ConfigureFromPoolRequest, ConfigureProviderRequest,
|
||||
|
||||
@@ -216,9 +216,9 @@ pub(crate) fn build_runtime_prepared_team_spawn_message(
|
||||
async fn collect_runtime_prepared_team_candidates(
|
||||
parent_session_id: &str,
|
||||
) -> Result<Vec<RuntimePreparedTeamSessionCandidate>, String> {
|
||||
let child_sessions = list_subagent_child_sessions(parent_session_id)
|
||||
.await
|
||||
.map_err(|error| format!("读取 runtime team child sessions 失败: {error}"))?;
|
||||
let child_sessions =
|
||||
list_child_subagent_sessions(parent_session_id, "读取 runtime team child sessions 失败")
|
||||
.await?;
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for child_session in child_sessions {
|
||||
@@ -1310,6 +1310,236 @@ fn build_queued_turn_preview(message: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_compaction_session_metrics(
|
||||
session_config: &aster::agents::SessionConfig,
|
||||
usage: &aster::providers::base::ProviderUsage,
|
||||
) -> Result<(), String> {
|
||||
let session = read_session(&session_config.id, false, "读取会话 token 统计失败").await?;
|
||||
|
||||
let update = build_compaction_session_metrics_update(&session, session_config, usage);
|
||||
persist_compaction_session_metrics_update(&session_config.id, &update).await
|
||||
}
|
||||
|
||||
fn build_compaction_session_metrics_update(
|
||||
session: &aster::session::Session,
|
||||
session_config: &aster::agents::SessionConfig,
|
||||
usage: &aster::providers::base::ProviderUsage,
|
||||
) -> CompactionSessionMetricsUpdate {
|
||||
let schedule_id = session_config
|
||||
.schedule_id
|
||||
.clone()
|
||||
.or(session.schedule_id.clone());
|
||||
|
||||
let accumulate = |current: Option<i32>, delta: Option<i32>| match (current, delta) {
|
||||
(Some(lhs), Some(rhs)) => Some(lhs + rhs),
|
||||
_ => current.or(delta),
|
||||
};
|
||||
|
||||
let accumulated_total = accumulate(session.accumulated_total_tokens, usage.usage.total_tokens);
|
||||
let accumulated_input = accumulate(session.accumulated_input_tokens, usage.usage.input_tokens);
|
||||
let accumulated_output =
|
||||
accumulate(session.accumulated_output_tokens, usage.usage.output_tokens);
|
||||
|
||||
let current_window_tokens = usage
|
||||
.usage
|
||||
.output_tokens
|
||||
.or(usage.usage.total_tokens)
|
||||
.unwrap_or(0);
|
||||
|
||||
CompactionSessionMetricsUpdate {
|
||||
schedule_id,
|
||||
current_window_tokens,
|
||||
accumulated_total_tokens: accumulated_total,
|
||||
accumulated_input_tokens: accumulated_input,
|
||||
accumulated_output_tokens: accumulated_output,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn compact_runtime_session_internal(
|
||||
app: &AppHandle,
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
request: AgentRuntimeCompactSessionRequest,
|
||||
) -> Result<(), String> {
|
||||
let session_id = normalize_required_text(&request.session_id, "session_id")?;
|
||||
let event_name = normalize_required_text(&request.event_name, "event_name")?;
|
||||
let cancel_token = state.create_cancel_token(&session_id).await;
|
||||
let agent_arc = state.get_agent_arc();
|
||||
|
||||
let runtime_snapshot = {
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
match agent.runtime_snapshot(&session_id).await {
|
||||
Ok(snapshot) => Some(snapshot),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 压缩上下文前读取 runtime snapshot 失败,继续使用 session 默认线程: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let runtime_projection_snapshot =
|
||||
RuntimeProjectionSnapshot::from_snapshot(&session_id, runtime_snapshot.as_ref());
|
||||
let resolved_thread_id = runtime_projection_snapshot
|
||||
.primary_thread_id()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| session_id.clone());
|
||||
let resolved_turn_id = Uuid::new_v4().to_string();
|
||||
let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create(
|
||||
db.clone(),
|
||||
resolved_thread_id.clone(),
|
||||
resolved_turn_id.clone(),
|
||||
"压缩上下文",
|
||||
)?));
|
||||
let session_config = SessionConfigBuilder::new(&session_id)
|
||||
.thread_id(resolved_thread_id)
|
||||
.turn_id(resolved_turn_id)
|
||||
.build();
|
||||
|
||||
let final_result: Result<(), String> = {
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
let turn = agent
|
||||
.ensure_runtime_turn_initialized(&session_config, Some("压缩上下文".to_string()))
|
||||
.await
|
||||
.map_err(|error| format!("初始化压缩 turn 失败: {error}"))?;
|
||||
for event in
|
||||
lime_agent::event_converter::convert_agent_event(AgentEvent::TurnStarted { turn })
|
||||
{
|
||||
{
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.record_runtime_event(app, &event_name, &event, "") {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录压缩时间线事件失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(error) = app.emit(&event_name, &event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩事件失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
let compaction_turn_id = session_config
|
||||
.turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| session_id.clone());
|
||||
let compaction_item_id = format!("context_compaction:{compaction_turn_id}");
|
||||
let start_event = TauriAgentEvent::ContextCompactionStarted {
|
||||
item_id: compaction_item_id.clone(),
|
||||
trigger: "manual".to_string(),
|
||||
detail: Some("系统正在将较早消息整理为摘要,以释放上下文窗口。".to_string()),
|
||||
};
|
||||
{
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.record_runtime_event(app, &event_name, &start_event, "") {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录压缩开始时间线失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(error) = app.emit(&event_name, &start_event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩开始事件失败: {}", error);
|
||||
}
|
||||
|
||||
let session = read_session(&session_id, true, "读取会话失败").await?;
|
||||
let conversation = session
|
||||
.conversation
|
||||
.ok_or_else(|| "Session has no conversation".to_string())?;
|
||||
let provider = agent
|
||||
.provider()
|
||||
.await
|
||||
.map_err(|error| format!("读取 provider 失败: {error}"))?;
|
||||
let (compacted_conversation, usage) =
|
||||
aster::context_mgmt::compact_messages(provider.as_ref(), &conversation, true)
|
||||
.await
|
||||
.map_err(|error| format!("压缩上下文失败: {error}"))?;
|
||||
replace_session_conversation(&session_id, &compacted_conversation, "写回压缩后的会话")
|
||||
.await?;
|
||||
update_compaction_session_metrics(&session_config, &usage).await?;
|
||||
|
||||
let completed_event = TauriAgentEvent::ContextCompactionCompleted {
|
||||
item_id: compaction_item_id,
|
||||
trigger: "manual".to_string(),
|
||||
detail: Some("较早消息已替换为摘要,后续回复会基于压缩后的上下文继续。".to_string()),
|
||||
};
|
||||
{
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) =
|
||||
recorder.record_runtime_event(app, &event_name, &completed_event, "")
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录压缩完成时间线失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(error) = app.emit(&event_name, &completed_event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
match final_result {
|
||||
Ok(()) => {
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.complete_turn_success(app, &event_name) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 完成压缩 turn 时间线失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
let done_event = TauriAgentEvent::FinalDone { usage: None };
|
||||
if let Err(error) = app.emit(&event_name, &done_event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
{
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(timeline_error) = recorder.fail_turn(app, &event_name, &error) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录压缩失败 turn 时间线失败(已降级继续): {}",
|
||||
timeline_error
|
||||
);
|
||||
}
|
||||
let error_event = TauriAgentEvent::Error {
|
||||
message: error.clone(),
|
||||
};
|
||||
if let Err(emit_error) = app.emit(&event_name, &error_event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩错误事件失败: {}", emit_error);
|
||||
}
|
||||
}
|
||||
state.remove_cancel_token(&session_id).await;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
drop(cancel_token);
|
||||
state.remove_cancel_token(&session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_subagent_parent_session_id(metadata: Option<&serde_json::Value>) -> Option<String> {
|
||||
metadata
|
||||
.and_then(|value| value.get("subagent"))
|
||||
@@ -1337,7 +1567,7 @@ async fn resolve_team_runtime_provider_group_for_request(request: &AsterChatRequ
|
||||
return normalize_team_runtime_provider_group(&provider_config.provider_name);
|
||||
}
|
||||
|
||||
match SessionManager::get_session(&request.session_id, false).await {
|
||||
match read_session(&request.session_id, false, "读取 provider 会话上下文失败").await {
|
||||
Ok(session) => {
|
||||
let provider_selector = resolve_session_provider_selector(&session)
|
||||
.or_else(|| normalize_optional_text(session.provider_name.clone()));
|
||||
@@ -1720,3 +1950,197 @@ pub(crate) fn build_runtime_queue_executor() -> RuntimeQueueExecutor {
|
||||
.boxed()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aster::providers::base::{ProviderUsage, Usage};
|
||||
use aster::session::{
|
||||
initialize_shared_session_runtime_with_root, is_global_session_store_set, SessionManager,
|
||||
SessionType,
|
||||
};
|
||||
use lime_core::database::schema::create_tables;
|
||||
use lime_services::aster_session_store::LimeSessionStore;
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
async fn ensure_runtime_turn_test_session_manager() {
|
||||
static INIT: OnceCell<()> = OnceCell::const_new();
|
||||
|
||||
INIT.get_or_init(|| async {
|
||||
if is_global_session_store_set() {
|
||||
return;
|
||||
}
|
||||
|
||||
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
|
||||
create_tables(&conn).expect("初始化表结构失败");
|
||||
|
||||
let runtime_root =
|
||||
std::env::temp_dir().join(format!("lime-runtime-turn-tests-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&runtime_root).expect("创建 runtime 测试目录失败");
|
||||
|
||||
let session_store = Arc::new(LimeSessionStore::new(Arc::new(Mutex::new(conn))));
|
||||
initialize_shared_session_runtime_with_root(runtime_root, Some(session_store))
|
||||
.await
|
||||
.expect("初始化测试 session manager 失败");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() {
|
||||
ensure_runtime_turn_test_session_manager().await;
|
||||
|
||||
let session = SessionManager::create_session(
|
||||
PathBuf::from("."),
|
||||
"压缩统计测试".to_string(),
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
.expect("创建测试会话失败");
|
||||
|
||||
SessionManager::update_session(&session.id)
|
||||
.schedule_id(Some("job-before".to_string()))
|
||||
.total_tokens(Some(90))
|
||||
.input_tokens(Some(60))
|
||||
.output_tokens(Some(30))
|
||||
.accumulated_total_tokens(Some(300))
|
||||
.accumulated_input_tokens(Some(200))
|
||||
.accumulated_output_tokens(Some(100))
|
||||
.apply()
|
||||
.await
|
||||
.expect("预置 token 统计失败");
|
||||
|
||||
let mut session_config = SessionConfigBuilder::new(&session.id).build();
|
||||
session_config.schedule_id = Some("job-compact".to_string());
|
||||
|
||||
let usage = ProviderUsage::new(
|
||||
"gpt-4.1".to_string(),
|
||||
Usage::new(Some(120), Some(45), Some(165)),
|
||||
);
|
||||
|
||||
update_compaction_session_metrics(&session_config, &usage)
|
||||
.await
|
||||
.expect("更新压缩 token 统计失败");
|
||||
|
||||
let updated = SessionManager::get_session(&session.id, false)
|
||||
.await
|
||||
.expect("读取更新后的会话失败");
|
||||
|
||||
assert_eq!(updated.schedule_id.as_deref(), Some("job-compact"));
|
||||
assert_eq!(updated.total_tokens, Some(45));
|
||||
assert_eq!(updated.input_tokens, Some(45));
|
||||
assert_eq!(updated.output_tokens, Some(0));
|
||||
assert_eq!(updated.accumulated_total_tokens, Some(465));
|
||||
assert_eq!(updated.accumulated_input_tokens, Some(320));
|
||||
assert_eq!(updated.accumulated_output_tokens, Some(145));
|
||||
|
||||
SessionManager::delete_session(&session.id)
|
||||
.await
|
||||
.expect("清理测试会话失败");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_compaction_session_metrics_should_reset_current_window_when_usage_tokens_missing(
|
||||
) {
|
||||
ensure_runtime_turn_test_session_manager().await;
|
||||
|
||||
let session = SessionManager::create_session(
|
||||
PathBuf::from("."),
|
||||
"压缩统计缺字段测试".to_string(),
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
.expect("创建测试会话失败");
|
||||
|
||||
SessionManager::update_session(&session.id)
|
||||
.schedule_id(Some("job-before".to_string()))
|
||||
.total_tokens(Some(180))
|
||||
.input_tokens(Some(120))
|
||||
.output_tokens(Some(60))
|
||||
.accumulated_total_tokens(Some(700))
|
||||
.accumulated_input_tokens(Some(500))
|
||||
.accumulated_output_tokens(Some(200))
|
||||
.apply()
|
||||
.await
|
||||
.expect("预置 token 统计失败");
|
||||
|
||||
let mut session_config = SessionConfigBuilder::new(&session.id).build();
|
||||
session_config.schedule_id = Some("job-compact-missing".to_string());
|
||||
|
||||
let usage = ProviderUsage::new("gpt-4.1".to_string(), Usage::default());
|
||||
|
||||
update_compaction_session_metrics(&session_config, &usage)
|
||||
.await
|
||||
.expect("更新压缩 token 统计失败");
|
||||
|
||||
let updated = SessionManager::get_session(&session.id, false)
|
||||
.await
|
||||
.expect("读取更新后的会话失败");
|
||||
|
||||
assert_eq!(updated.schedule_id.as_deref(), Some("job-compact-missing"));
|
||||
assert_eq!(updated.total_tokens, Some(0));
|
||||
assert_eq!(updated.input_tokens, Some(0));
|
||||
assert_eq!(updated.output_tokens, Some(0));
|
||||
assert_eq!(updated.accumulated_total_tokens, Some(700));
|
||||
assert_eq!(updated.accumulated_input_tokens, Some(500));
|
||||
assert_eq!(updated.accumulated_output_tokens, Some(200));
|
||||
|
||||
SessionManager::delete_session(&session.id)
|
||||
.await
|
||||
.expect("清理测试会话失败");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_compaction_session_metrics_should_preserve_existing_schedule_id_when_request_is_empty(
|
||||
) {
|
||||
ensure_runtime_turn_test_session_manager().await;
|
||||
|
||||
let session = SessionManager::create_session(
|
||||
PathBuf::from("."),
|
||||
"压缩统计保留任务测试".to_string(),
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
.expect("创建测试会话失败");
|
||||
|
||||
SessionManager::update_session(&session.id)
|
||||
.schedule_id(Some("job-existing".to_string()))
|
||||
.total_tokens(Some(20))
|
||||
.input_tokens(Some(10))
|
||||
.output_tokens(Some(10))
|
||||
.accumulated_total_tokens(Some(200))
|
||||
.accumulated_input_tokens(Some(120))
|
||||
.accumulated_output_tokens(Some(80))
|
||||
.apply()
|
||||
.await
|
||||
.expect("预置 token 统计失败");
|
||||
|
||||
let session_config = SessionConfigBuilder::new(&session.id).build();
|
||||
let usage = ProviderUsage::new(
|
||||
"gpt-4.1".to_string(),
|
||||
Usage::new(Some(30), Some(15), Some(45)),
|
||||
);
|
||||
|
||||
update_compaction_session_metrics(&session_config, &usage)
|
||||
.await
|
||||
.expect("更新压缩 token 统计失败");
|
||||
|
||||
let updated = SessionManager::get_session(&session.id, false)
|
||||
.await
|
||||
.expect("读取更新后的会话失败");
|
||||
|
||||
assert_eq!(updated.schedule_id.as_deref(), Some("job-existing"));
|
||||
assert_eq!(updated.total_tokens, Some(15));
|
||||
assert_eq!(updated.input_tokens, Some(15));
|
||||
assert_eq!(updated.output_tokens, Some(0));
|
||||
assert_eq!(updated.accumulated_total_tokens, Some(245));
|
||||
assert_eq!(updated.accumulated_input_tokens, Some(150));
|
||||
assert_eq!(updated.accumulated_output_tokens, Some(95));
|
||||
|
||||
SessionManager::delete_session(&session.id)
|
||||
.await
|
||||
.expect("清理测试会话失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,10 @@ pub(crate) async fn persist_session_provider_routing(
|
||||
let Some(state) = SessionProviderRoutingState::new(provider_selector.to_string()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let session = SessionManager::get_session(session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取会话 provider 路由上下文失败: {error}"))?;
|
||||
let session = read_session(session_id, false, "读取会话 provider 路由上下文失败").await?;
|
||||
let extension_data = state.into_updated_extension_data(&session)?;
|
||||
SessionManager::update_session(session_id)
|
||||
.extension_data(extension_data)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|error| format!("持久化会话 provider 路由上下文失败: {error}"))?;
|
||||
persist_session_extension_data(session_id, extension_data, "持久化会话 provider 路由上下文")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use lime_agent::restore_aster_runtime_queued_turns;
|
||||
|
||||
const SUBAGENT_RUNTIME_EVENT_PREFIX: &str = "agent_subagent_stream";
|
||||
const SUBAGENT_STATUS_EVENT_PREFIX: &str = "agent_subagent_status";
|
||||
@@ -338,38 +339,6 @@ fn should_emit_subagent_status_for_runtime_event(event: &TauriAgentEvent) -> boo
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_subagent_status_scope_session_ids(session_id: &str) -> Vec<String> {
|
||||
let mut scope_ids = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut current_session_id = session_id.to_string();
|
||||
|
||||
while seen.insert(current_session_id.clone()) {
|
||||
scope_ids.push(current_session_id.clone());
|
||||
|
||||
let session = match SessionManager::get_session(¤t_session_id, false).await {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Subagent] 解析 team 事件 scope 失败: session_id={}, error={}",
|
||||
current_session_id,
|
||||
error
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else {
|
||||
break;
|
||||
};
|
||||
let Some(parent_session_id) = normalize_optional_text(Some(metadata.parent_session_id))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
current_session_id = parent_session_id;
|
||||
}
|
||||
|
||||
scope_ids
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_subagent_status_changed_events(app: &AppHandle, session_id: &str) {
|
||||
let status = match load_subagent_runtime_status(session_id).await {
|
||||
Ok(status) => status,
|
||||
@@ -454,9 +423,8 @@ fn normalize_wait_timeout_ms(timeout_ms: Option<i64>) -> Result<i64, String> {
|
||||
}
|
||||
|
||||
async fn count_active_team_subagents(parent_session_id: &str) -> Result<usize, String> {
|
||||
let child_sessions = list_subagent_child_sessions(parent_session_id)
|
||||
.await
|
||||
.map_err(|error| format!("读取 team child sessions 失败: {error}"))?;
|
||||
let child_sessions =
|
||||
list_child_subagent_sessions(parent_session_id, "读取 team child sessions 失败").await?;
|
||||
let mut active_count = 0usize;
|
||||
|
||||
for child_session in child_sessions {
|
||||
@@ -479,9 +447,7 @@ pub(crate) fn subagent_counts_toward_team_limit(status: SubagentRuntimeStatusKin
|
||||
}
|
||||
|
||||
async fn enforce_team_spawn_limits(parent_session_id: &str) -> Result<(), String> {
|
||||
let parent_session = SessionManager::get_session(parent_session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取父会话失败: {error}"))?;
|
||||
let parent_session = read_session(parent_session_id, false, "读取父会话失败").await?;
|
||||
|
||||
if parent_session.session_type == SessionType::SubAgent {
|
||||
return Err(
|
||||
@@ -526,15 +492,9 @@ async fn restore_stashed_subagent_queue(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let store = require_shared_thread_runtime_store()
|
||||
.map_err(|error| format!("读取 shared runtime store 失败: {error}"))?;
|
||||
for queued_turn in queued_turns {
|
||||
store
|
||||
.enqueue_turn(queued_turn)
|
||||
.await
|
||||
.map_err(|error| format!("恢复 subagent queued turn 失败: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
restore_aster_runtime_queued_turns(queued_turns)
|
||||
.await
|
||||
.map_err(|error| format!("恢复 subagent queued turn 失败: {error}"))
|
||||
}
|
||||
|
||||
async fn inherit_subagent_provider(
|
||||
@@ -543,9 +503,8 @@ async fn inherit_subagent_provider(
|
||||
child_session_id: &str,
|
||||
model_override: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let parent_session = SessionManager::get_session(parent_session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取父会话 provider 信息失败: {error}"))?;
|
||||
let parent_session =
|
||||
read_session(parent_session_id, false, "读取父会话 provider 信息失败").await?;
|
||||
let parent_provider_selector = resolve_session_provider_selector(&parent_session)
|
||||
.or_else(|| normalize_optional_text(parent_session.provider_name.clone()));
|
||||
|
||||
@@ -599,9 +558,7 @@ async fn create_runtime_subagent_session(
|
||||
normalize_required_text(&request.parent_session_id, "parent_session_id")?;
|
||||
let message = normalize_required_text(&request.message, "message")?;
|
||||
enforce_team_spawn_limits(&parent_session_id).await?;
|
||||
let parent_session = SessionManager::get_session(&parent_session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取父会话失败: {error}"))?;
|
||||
let parent_session = read_session(&parent_session_id, false, "读取父会话失败").await?;
|
||||
let customization = build_subagent_customization_state(request)?;
|
||||
let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?;
|
||||
let profile_name = customization
|
||||
@@ -609,7 +566,7 @@ async fn create_runtime_subagent_session(
|
||||
.and_then(|state| state.profile_name.as_deref());
|
||||
let role_hint = resolve_subagent_role_hint(request, customization.as_ref());
|
||||
|
||||
let session = SessionManager::create_session(
|
||||
let session = create_subagent_session(
|
||||
parent_session.working_dir.clone(),
|
||||
build_subagent_session_name(
|
||||
&message,
|
||||
@@ -619,10 +576,8 @@ async fn create_runtime_subagent_session(
|
||||
.and_then(|state| state.blueprint_role_label.as_deref()),
|
||||
profile_name,
|
||||
),
|
||||
SessionType::SubAgent,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("创建 subagent session 失败: {error}"))?;
|
||||
.await?;
|
||||
|
||||
if let Some(parent_metadata) =
|
||||
AsterAgentWrapper::get_persisted_session_metadata_sync(&runtime.db, &parent_session_id)?
|
||||
@@ -652,11 +607,12 @@ async fn create_runtime_subagent_session(
|
||||
.to_extension_data(&mut extension_data)
|
||||
.map_err(|error| format!("持久化 subagent customization 失败: {error}"))?;
|
||||
}
|
||||
SessionManager::update_session(&session.id)
|
||||
.extension_data(extension_data)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|error| format!("写入 subagent session metadata 失败: {error}"))?;
|
||||
persist_session_extension_data(
|
||||
&session.id,
|
||||
extension_data,
|
||||
"写入 subagent session metadata",
|
||||
)
|
||||
.await?;
|
||||
|
||||
inherit_subagent_provider(
|
||||
runtime,
|
||||
|
||||
@@ -19,9 +19,13 @@ pub(super) async fn try_handle(
|
||||
cmd,
|
||||
"agent_runtime_submit_turn"
|
||||
| "agent_runtime_interrupt_turn"
|
||||
| "agent_runtime_compact_session"
|
||||
| "agent_runtime_resume_thread"
|
||||
| "agent_runtime_create_session"
|
||||
| "agent_runtime_list_sessions"
|
||||
| "agent_runtime_get_session"
|
||||
| "agent_runtime_get_thread_read"
|
||||
| "agent_runtime_replay_request"
|
||||
| "agent_runtime_update_session"
|
||||
| "agent_runtime_delete_session"
|
||||
| "agent_runtime_promote_queued_turn"
|
||||
@@ -77,6 +81,51 @@ pub(super) async fn try_handle(
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_compact_session" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeCompactSessionRequest,
|
||||
>(args)?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
crate::commands::aster_agent_cmd::agent_runtime_compact_session(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
request,
|
||||
)
|
||||
.await?;
|
||||
JsonValue::Null
|
||||
}
|
||||
"agent_runtime_resume_thread" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeResumeThreadRequest,
|
||||
>(args)?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let api_key_provider_service =
|
||||
app_handle
|
||||
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
|
||||
let logs = app_handle.state::<crate::app::LogState>();
|
||||
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
|
||||
let automation_state =
|
||||
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_resume_thread(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
request,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_create_session" => {
|
||||
let args = args_or_default(args);
|
||||
let workspace_id = get_string_arg(&args, "workspaceId", "workspace_id")?;
|
||||
@@ -143,6 +192,45 @@ pub(super) async fn try_handle(
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_get_thread_read" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = get_string_arg(&args, "sessionId", "session_id")?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let api_key_provider_service =
|
||||
app_handle
|
||||
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
|
||||
let logs = app_handle.state::<crate::app::LogState>();
|
||||
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
|
||||
let automation_state =
|
||||
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_get_thread_read(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
session_id,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_replay_request" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeReplayRequestRequest,
|
||||
>(args)?;
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_replay_request(db, request).await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_update_session" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeUpdateSessionRequest,
|
||||
|
||||
@@ -171,6 +171,40 @@ impl AgentTimelineRecorder {
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ActionRequired { .. } => {}
|
||||
TauriAgentEvent::ContextCompactionStarted {
|
||||
item_id,
|
||||
trigger,
|
||||
detail,
|
||||
} => {
|
||||
let item = self.build_item(
|
||||
item_id.clone(),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
AgentThreadItemPayload::ContextCompaction {
|
||||
stage: "started".to_string(),
|
||||
trigger: Some(trigger.clone()),
|
||||
detail: detail.clone(),
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ContextCompactionCompleted {
|
||||
item_id,
|
||||
trigger,
|
||||
detail,
|
||||
} => {
|
||||
let item = self.build_item(
|
||||
item_id.clone(),
|
||||
AgentThreadItemStatus::Completed,
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::ContextCompaction {
|
||||
stage: "completed".to_string(),
|
||||
trigger: Some(trigger.clone()),
|
||||
detail: detail.clone(),
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::Warning { code, message } => {
|
||||
let item = self.build_item(
|
||||
format!("warning:{}:{}", self.turn_id, self.sequence_counter + 1),
|
||||
|
||||
@@ -325,7 +325,9 @@ pub fn build_memory_sources_prompt_with_options(
|
||||
fn runtime_agent_overlap_paths(working_dir: &Path) -> HashSet<PathBuf> {
|
||||
let mut paths = HashSet::new();
|
||||
paths.insert(normalize_path(&app_paths::best_effort_user_memory_path()));
|
||||
paths.insert(normalize_path(&working_dir.join(".lime").join("AGENTS.md")));
|
||||
paths.insert(normalize_path(
|
||||
&app_paths::resolve_workspace_runtime_agents_path(working_dir),
|
||||
));
|
||||
paths
|
||||
}
|
||||
|
||||
@@ -1059,18 +1061,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_local_instruction_path_should_not_walk_ancestors() {
|
||||
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let project_root = tmp.path().join("repo");
|
||||
let nested = project_root.join("workspace");
|
||||
let empty_durable_root = tmp.path().join("durable-empty");
|
||||
fs::create_dir_all(project_root.join(".git")).expect("create git marker");
|
||||
fs::create_dir_all(project_root.join(".lime")).expect("create root .lime");
|
||||
fs::create_dir_all(nested.join(".lime")).expect("create nested .lime");
|
||||
fs::create_dir_all(&empty_durable_root).expect("create empty durable root");
|
||||
fs::write(project_root.join(".lime/AGENTS.md"), "root agents").expect("write root agents");
|
||||
fs::write(nested.join(".lime/AGENTS.md"), "workspace agents")
|
||||
.expect("write workspace agents");
|
||||
let _durable_guard = DurableMemoryEnvGuard::set(&empty_durable_root);
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory.enabled = true;
|
||||
cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
|
||||
|
||||
let resolved = resolve_effective_sources(&cfg, &nested, None);
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod novel_service;
|
||||
pub mod openclaw_service;
|
||||
pub mod runtime_agents_template_service;
|
||||
pub mod sysinfo_service;
|
||||
pub mod thread_reliability_projection_service;
|
||||
pub mod update_check_service;
|
||||
pub mod update_window;
|
||||
pub mod web_search_prompt_service;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub fn sync_provider_config(
|
||||
&mut self,
|
||||
provider: &ApiKeyProvider,
|
||||
api_key: &str,
|
||||
primary_model_id: &str,
|
||||
models: &[SyncModelEntry],
|
||||
) -> Result<ActionResult, String> {
|
||||
if api_key.trim().is_empty() && provider.provider_type != ApiProviderType::Ollama {
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message: "该 Provider 没有可用的 API Key。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let api_type = determine_api_type(provider.provider_type)?;
|
||||
let base_url = format_provider_base_url(provider)?;
|
||||
let provider_key = format!("lime-{}", provider.id);
|
||||
|
||||
let normalized_models = if models.is_empty() {
|
||||
vec![SyncModelEntry {
|
||||
id: primary_model_id.to_string(),
|
||||
name: primary_model_id.to_string(),
|
||||
context_window: None,
|
||||
}]
|
||||
} else {
|
||||
let mut items = models.to_vec();
|
||||
if !items.iter().any(|item| item.id == primary_model_id) {
|
||||
items.insert(
|
||||
0,
|
||||
SyncModelEntry {
|
||||
id: primary_model_id.to_string(),
|
||||
name: primary_model_id.to_string(),
|
||||
context_window: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
items
|
||||
};
|
||||
|
||||
self.ensure_runtime_config(
|
||||
Some((
|
||||
&provider_key,
|
||||
json!({
|
||||
"baseUrl": base_url,
|
||||
"apiKey": api_key,
|
||||
"api": api_type,
|
||||
"models": normalized_models
|
||||
.iter()
|
||||
.map(sync_model_entry_to_config_value)
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
)),
|
||||
Some(format!("{provider_key}/{primary_model_id}")),
|
||||
)?;
|
||||
|
||||
Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("已同步 Provider“{}”到 OpenClaw。", provider.name),
|
||||
})
|
||||
}
|
||||
pub(crate) fn ensure_runtime_config(
|
||||
&mut self,
|
||||
provider_entry: Option<(&str, Value)>,
|
||||
primary_model: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let config_dir = openclaw_config_dir();
|
||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建配置目录失败: {e}"))?;
|
||||
|
||||
let lime_config_path = openclaw_lime_config_path();
|
||||
let mut config = read_base_openclaw_config()?;
|
||||
sanitize_runtime_config(&mut config);
|
||||
|
||||
if self.gateway_auth_token.is_empty() {
|
||||
self.gateway_auth_token = generate_auth_token();
|
||||
}
|
||||
|
||||
apply_gateway_runtime_defaults(&mut config, self.gateway_port, &self.gateway_auth_token);
|
||||
|
||||
if let Some((provider_key, provider_value)) = provider_entry {
|
||||
set_json_path(
|
||||
&mut config,
|
||||
&["models", "mode"],
|
||||
Value::String("merge".to_string()),
|
||||
);
|
||||
set_json_path(
|
||||
&mut config,
|
||||
&["models", "providers", provider_key],
|
||||
provider_value,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(primary) = primary_model {
|
||||
set_json_path(
|
||||
&mut config,
|
||||
&["agents", "defaults", "model", "primary"],
|
||||
Value::String(primary),
|
||||
);
|
||||
}
|
||||
|
||||
let content =
|
||||
serde_json::to_string_pretty(&config).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
std::fs::write(lime_config_path, content).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn openclaw_config_dir() -> PathBuf {
|
||||
home_dir()
|
||||
.or_else(data_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".openclaw")
|
||||
}
|
||||
|
||||
pub(crate) fn openclaw_original_config_path() -> PathBuf {
|
||||
openclaw_config_dir().join("openclaw.json")
|
||||
}
|
||||
|
||||
pub(crate) fn openclaw_lime_config_path() -> PathBuf {
|
||||
openclaw_config_dir().join("openclaw.lime.json")
|
||||
}
|
||||
|
||||
pub(crate) fn read_base_openclaw_config() -> Result<Value, String> {
|
||||
let lime_path = openclaw_lime_config_path();
|
||||
if lime_path.exists() {
|
||||
return read_json_file(&lime_path);
|
||||
}
|
||||
|
||||
let original_path = openclaw_original_config_path();
|
||||
if original_path.exists() {
|
||||
return read_json_file(&original_path);
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
pub(crate) fn read_json_file(path: &Path) -> Result<Value, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("读取配置文件失败({}): {e}", path.display()))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败({}): {e}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_path_object<'a>(
|
||||
root: &'a mut Value,
|
||||
path: &[&str],
|
||||
) -> &'a mut Map<String, Value> {
|
||||
let mut current = root;
|
||||
for segment in path {
|
||||
let object = ensure_value_object(current);
|
||||
current = object
|
||||
.entry((*segment).to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
}
|
||||
ensure_value_object(current)
|
||||
}
|
||||
|
||||
pub(crate) fn set_json_path(root: &mut Value, path: &[&str], value: Value) {
|
||||
if path.is_empty() {
|
||||
*root = value;
|
||||
return;
|
||||
}
|
||||
|
||||
let parent = ensure_path_object(root, &path[..path.len() - 1]);
|
||||
parent.insert(path[path.len() - 1].to_string(), value);
|
||||
}
|
||||
|
||||
pub(crate) fn sync_model_entry_to_config_value(model: &SyncModelEntry) -> Value {
|
||||
let mut entry = Map::new();
|
||||
entry.insert("id".to_string(), Value::String(model.id.clone()));
|
||||
entry.insert("name".to_string(), Value::String(model.name.clone()));
|
||||
if let Some(context_window) = model.context_window {
|
||||
entry.insert(
|
||||
"contextWindow".to_string(),
|
||||
Value::Number(context_window.into()),
|
||||
);
|
||||
}
|
||||
Value::Object(entry)
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_runtime_config(config: &mut Value) {
|
||||
let Some(providers) = config
|
||||
.get_mut("models")
|
||||
.and_then(|models| models.get_mut("providers"))
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for provider in providers.values_mut() {
|
||||
let Some(models) = provider.get_mut("models").and_then(Value::as_array_mut) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for model in models {
|
||||
let Some(entry) = model.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if matches!(entry.get("contextWindow"), Some(Value::Null)) {
|
||||
entry.remove("contextWindow");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_gateway_runtime_defaults(
|
||||
config: &mut Value,
|
||||
gateway_port: u16,
|
||||
gateway_auth_token: &str,
|
||||
) {
|
||||
ensure_path_object(config, &["gateway"]);
|
||||
set_json_path(
|
||||
config,
|
||||
&["gateway", "mode"],
|
||||
Value::String("local".to_string()),
|
||||
);
|
||||
set_json_path(
|
||||
config,
|
||||
&["gateway", "bind"],
|
||||
Value::String("loopback".to_string()),
|
||||
);
|
||||
set_json_path(
|
||||
config,
|
||||
&["gateway", "port"],
|
||||
Value::Number(gateway_port.into()),
|
||||
);
|
||||
set_json_path(
|
||||
config,
|
||||
&["gateway", "auth", "mode"],
|
||||
Value::String("token".to_string()),
|
||||
);
|
||||
set_json_path(
|
||||
config,
|
||||
&["gateway", "auth", "token"],
|
||||
Value::String(gateway_auth_token.to_string()),
|
||||
);
|
||||
set_json_path(
|
||||
config,
|
||||
&["gateway", "remote", "token"],
|
||||
Value::String(gateway_auth_token.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_value_object(value: &mut Value) -> &mut Map<String, Value> {
|
||||
if !value.is_object() {
|
||||
*value = Value::Object(Map::new());
|
||||
}
|
||||
value.as_object_mut().expect("value should be object")
|
||||
}
|
||||
|
||||
pub(crate) fn extract_gateway_auth_token(config: &Value) -> Option<String> {
|
||||
config
|
||||
.get("gateway")
|
||||
.and_then(|gateway| {
|
||||
gateway
|
||||
.get("auth")
|
||||
.and_then(|auth| auth.get("token"))
|
||||
.or_else(|| gateway.get("remote").and_then(|remote| remote.get("token")))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
pub(crate) fn determine_api_type(provider_type: ApiProviderType) -> Result<&'static str, String> {
|
||||
match provider_type {
|
||||
ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => {
|
||||
Ok("anthropic-messages")
|
||||
}
|
||||
ApiProviderType::OpenaiResponse => Ok("openai-responses"),
|
||||
ApiProviderType::Openai
|
||||
| ApiProviderType::Codex
|
||||
| ApiProviderType::Gemini
|
||||
| ApiProviderType::Ollama
|
||||
| ApiProviderType::Fal
|
||||
| ApiProviderType::NewApi
|
||||
| ApiProviderType::Gateway => Ok("openai-completions"),
|
||||
ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => {
|
||||
Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_provider_base_url(provider: &ApiKeyProvider) -> Result<String, String> {
|
||||
let api_host = trim_trailing_slash(&provider.api_host);
|
||||
|
||||
match provider.provider_type {
|
||||
ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => Ok(api_host),
|
||||
ApiProviderType::Gemini => {
|
||||
if api_host.contains("generativelanguage.googleapis.com") {
|
||||
if api_host.ends_with("/v1beta/openai") {
|
||||
Ok(api_host)
|
||||
} else {
|
||||
Ok(format!("{api_host}/v1beta/openai"))
|
||||
}
|
||||
} else if has_api_version(&api_host) {
|
||||
Ok(api_host)
|
||||
} else {
|
||||
Ok(format!("{api_host}/v1"))
|
||||
}
|
||||
}
|
||||
ApiProviderType::Gateway => {
|
||||
if api_host.ends_with("/v1/ai") {
|
||||
Ok(api_host.trim_end_matches("/ai").to_string())
|
||||
} else if has_api_version(&api_host) {
|
||||
Ok(api_host)
|
||||
} else {
|
||||
Ok(format!("{api_host}/v1"))
|
||||
}
|
||||
}
|
||||
ApiProviderType::Openai
|
||||
| ApiProviderType::OpenaiResponse
|
||||
| ApiProviderType::Codex
|
||||
| ApiProviderType::Ollama
|
||||
| ApiProviderType::Fal
|
||||
| ApiProviderType::NewApi => {
|
||||
if has_api_version(&api_host) {
|
||||
Ok(api_host)
|
||||
} else {
|
||||
Ok(format!("{api_host}/v1"))
|
||||
}
|
||||
}
|
||||
ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => {
|
||||
Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trim_trailing_slash(value: &str) -> String {
|
||||
value.trim().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn has_api_version(url: &str) -> bool {
|
||||
static VERSION_RE: OnceLock<Regex> = OnceLock::new();
|
||||
VERSION_RE
|
||||
.get_or_init(|| Regex::new(r"/v\d+(?:[./]|$)").expect("regex should compile"))
|
||||
.is_match(url)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn get_environment_status(&self) -> Result<EnvironmentStatus, String> {
|
||||
let node = inspect_node_dependency_status().await?;
|
||||
let git = inspect_git_dependency_status().await?;
|
||||
let openclaw = inspect_openclaw_dependency_status().await?;
|
||||
let diagnostics = collect_environment_diagnostics().await;
|
||||
|
||||
Ok(build_environment_status(node, git, openclaw, diagnostics))
|
||||
}
|
||||
|
||||
pub async fn check_installed(&self) -> Result<BinaryInstallStatus, String> {
|
||||
let openclaw = inspect_openclaw_dependency_status().await?;
|
||||
Ok(BinaryInstallStatus {
|
||||
installed: openclaw.status == "ok",
|
||||
path: openclaw.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_git_available(&self) -> Result<BinaryAvailabilityStatus, String> {
|
||||
let git = inspect_git_dependency_status().await?;
|
||||
Ok(BinaryAvailabilityStatus {
|
||||
available: git.status == "ok",
|
||||
path: git.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_node_version(&self) -> Result<NodeCheckResult, String> {
|
||||
let node = inspect_node_dependency_status().await?;
|
||||
Ok(NodeCheckResult {
|
||||
status: match node.status.as_str() {
|
||||
"missing" => "not_found".to_string(),
|
||||
other => other.to_string(),
|
||||
},
|
||||
version: node.version,
|
||||
path: node.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_node_download_url(&self) -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"https://nodejs.org/en/download".to_string()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"https://nodejs.org/en/download".to_string()
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"https://nodejs.org/en/download".to_string()
|
||||
} else {
|
||||
"https://nodejs.org/en/download".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_git_download_url(&self) -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"https://git-scm.com/download/win".to_string()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"https://git-scm.com/download/mac".to_string()
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"https://git-scm.com/download/linux".to_string()
|
||||
} else {
|
||||
"https://git-scm.com/downloads".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(crate) fn windows_dependency_setup_message(
|
||||
dependency: DependencyKind,
|
||||
status: &DependencyStatus,
|
||||
) -> String {
|
||||
let guidance = match dependency {
|
||||
DependencyKind::Node => format!(
|
||||
"Windows 下请先从 nodejs.org 安装或升级 Node.js {}+,完成后点击“重新检测”,再安装 OpenClaw。",
|
||||
NODE_MIN_VERSION.0
|
||||
),
|
||||
DependencyKind::Git => {
|
||||
"Windows 下请先从 git-scm.com 安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
format!("{} {}", status.message, guidance)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(crate) fn windows_dependency_action_result(
|
||||
dependency: DependencyKind,
|
||||
status: &DependencyStatus,
|
||||
) -> ActionResult {
|
||||
ActionResult {
|
||||
success: false,
|
||||
message: windows_dependency_setup_message(dependency, status),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(crate) fn windows_install_block_result(
|
||||
node_status: &DependencyStatus,
|
||||
git_status: &DependencyStatus,
|
||||
) -> Option<ActionResult> {
|
||||
if node_status.status != "ok" {
|
||||
return Some(windows_dependency_action_result(
|
||||
DependencyKind::Node,
|
||||
node_status,
|
||||
));
|
||||
}
|
||||
|
||||
if git_status.status != "ok" {
|
||||
return Some(windows_dependency_action_result(
|
||||
DependencyKind::Git,
|
||||
git_status,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn dependency_setup_summary(dependency: DependencyKind) -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
return match dependency {
|
||||
DependencyKind::Node => format!(
|
||||
"当前缺少可用的 Node.js {}+ 运行时,Windows 下请先手动安装 Node.js,完成后点击“重新检测”,再安装 OpenClaw。",
|
||||
NODE_MIN_VERSION.0
|
||||
),
|
||||
DependencyKind::Git => {
|
||||
"当前缺少可用的 Git,Windows 下请先手动安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
return match dependency {
|
||||
DependencyKind::Node => format!(
|
||||
"当前缺少可用的 Node.js {}+ 运行时,建议先一键安装或修复 Node.js。",
|
||||
format_semver(NODE_MIN_VERSION)
|
||||
),
|
||||
DependencyKind::Git => "当前缺少可用的 Git,建议先一键安装或修复 Git。".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
match dependency {
|
||||
DependencyKind::Node => format!(
|
||||
"当前缺少可用的 Node.js {}+ 运行时,请先手动安装后重新检测。",
|
||||
format_semver(NODE_MIN_VERSION)
|
||||
),
|
||||
DependencyKind::Git => "当前缺少可用的 Git,请先手动安装后重新检测。".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_environment_status(
|
||||
node: DependencyStatus,
|
||||
git: DependencyStatus,
|
||||
mut openclaw: DependencyStatus,
|
||||
diagnostics: EnvironmentDiagnostics,
|
||||
) -> EnvironmentStatus {
|
||||
let node_ready = node.status == "ok";
|
||||
let git_ready = git.status == "ok";
|
||||
openclaw.auto_install_supported = node_ready && git_ready;
|
||||
|
||||
let (recommended_action, summary) = if !node_ready {
|
||||
(
|
||||
"install_node".to_string(),
|
||||
dependency_setup_summary(DependencyKind::Node),
|
||||
)
|
||||
} else if !git_ready {
|
||||
(
|
||||
"install_git".to_string(),
|
||||
dependency_setup_summary(DependencyKind::Git),
|
||||
)
|
||||
} else if openclaw.status == "needs_reload" {
|
||||
(
|
||||
"refresh_openclaw_env".to_string(),
|
||||
"已检测到 OpenClaw 包,但命令尚未生效;请点击“重新检测”,必要时重启 Lime。".to_string(),
|
||||
)
|
||||
} else if openclaw.status != "ok" {
|
||||
(
|
||||
"install_openclaw".to_string(),
|
||||
"运行环境已就绪,可以继续一键安装 OpenClaw。".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"ready".to_string(),
|
||||
"Node.js、Git 和 OpenClaw 均已就绪,可以继续配置与启动。".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
EnvironmentStatus {
|
||||
node,
|
||||
git,
|
||||
openclaw,
|
||||
recommended_action,
|
||||
summary,
|
||||
diagnostics,
|
||||
temp_artifacts: collect_temp_artifact_paths(None)
|
||||
.into_iter()
|
||||
.filter(|path| path.exists())
|
||||
.map(|path| path.display().to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn inspect_node_dependency_status() -> Result<DependencyStatus, String> {
|
||||
let Some(path) = find_command_in_shell("node").await? else {
|
||||
return Ok(DependencyStatus {
|
||||
status: "missing".to_string(),
|
||||
version: None,
|
||||
path: None,
|
||||
message: format!(
|
||||
"未检测到 Node.js,需要安装 {}+。",
|
||||
format_semver(NODE_MIN_VERSION)
|
||||
),
|
||||
auto_install_supported: cfg!(target_os = "macos"),
|
||||
});
|
||||
};
|
||||
|
||||
let version_text = read_command_version_text(&path, &["--version"]).await?;
|
||||
let Some(version) = parse_semver_from_text(&version_text) else {
|
||||
return Ok(DependencyStatus {
|
||||
status: "version_low".to_string(),
|
||||
version: Some(version_text.clone()),
|
||||
path: Some(path),
|
||||
message: format!(
|
||||
"检测到 Node.js,但无法识别版本:{version_text}。请安装 {}+。",
|
||||
format_semver(NODE_MIN_VERSION)
|
||||
),
|
||||
auto_install_supported: cfg!(target_os = "macos"),
|
||||
});
|
||||
};
|
||||
|
||||
let normalized = format_semver(version);
|
||||
if version >= NODE_MIN_VERSION {
|
||||
Ok(DependencyStatus {
|
||||
status: "ok".to_string(),
|
||||
version: Some(normalized.clone()),
|
||||
path: Some(path),
|
||||
message: format!("Node.js 已就绪:{normalized}"),
|
||||
auto_install_supported: cfg!(target_os = "macos"),
|
||||
})
|
||||
} else {
|
||||
Ok(DependencyStatus {
|
||||
status: "version_low".to_string(),
|
||||
version: Some(normalized.clone()),
|
||||
path: Some(path),
|
||||
message: format!(
|
||||
"Node.js 版本过低:{normalized},需要 {}+。",
|
||||
format_semver(NODE_MIN_VERSION)
|
||||
),
|
||||
auto_install_supported: cfg!(target_os = "macos"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn inspect_git_dependency_status() -> Result<DependencyStatus, String> {
|
||||
let Some(path) = find_command_in_shell("git").await? else {
|
||||
return Ok(DependencyStatus {
|
||||
status: "missing".to_string(),
|
||||
version: None,
|
||||
path: None,
|
||||
message: "未检测到 Git。".to_string(),
|
||||
auto_install_supported: git_auto_install_supported().await?,
|
||||
});
|
||||
};
|
||||
|
||||
let version_text = read_command_version_text(&path, &["--version"]).await?;
|
||||
let version = parse_semver_from_text(&version_text).map(format_semver);
|
||||
let detail = version.clone().unwrap_or(version_text);
|
||||
|
||||
Ok(DependencyStatus {
|
||||
status: "ok".to_string(),
|
||||
version,
|
||||
path: Some(path),
|
||||
message: format!("Git 已就绪:{detail}"),
|
||||
auto_install_supported: git_auto_install_supported().await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String> {
|
||||
let Some(command) = resolve_openclaw_command().await? else {
|
||||
if let Some(status) = inspect_openclaw_package_reload_status().await? {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
return Ok(DependencyStatus {
|
||||
status: "missing".to_string(),
|
||||
version: None,
|
||||
path: None,
|
||||
message: "未检测到 OpenClaw,可在环境就绪后一键安装。".to_string(),
|
||||
auto_install_supported: false,
|
||||
});
|
||||
};
|
||||
|
||||
let version_text = read_openclaw_version_from_command(&command)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
Ok(DependencyStatus {
|
||||
status: "ok".to_string(),
|
||||
version: if version_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(version_text.clone())
|
||||
},
|
||||
path: Some(command.install_path_display()),
|
||||
message: if matches!(command, ResolvedOpenClawCommand::NodeCli { .. }) {
|
||||
if version_text.is_empty() {
|
||||
"已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动。".to_string()
|
||||
} else {
|
||||
format!("已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动:{version_text}")
|
||||
}
|
||||
} else if version_text.is_empty() {
|
||||
"已检测到 OpenClaw。".to_string()
|
||||
} else {
|
||||
format!("已检测到 OpenClaw:{version_text}")
|
||||
},
|
||||
auto_install_supported: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn inspect_openclaw_package_reload_status(
|
||||
) -> Result<Option<DependencyStatus>, String> {
|
||||
let Some(npm_path) = find_command_in_standard_locations("npm").await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(prefix) = detect_npm_global_prefix(&npm_path).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(package) = find_installed_openclaw_package_details(&prefix) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let version_suffix = package
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|item| format!("({item})"))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Some(DependencyStatus {
|
||||
status: "needs_reload".to_string(),
|
||||
version: package.version.clone(),
|
||||
path: Some(prefix.clone()),
|
||||
message: format!(
|
||||
"已在 npm 全局目录检测到 {}{},但当前进程尚未解析到 openclaw 命令。请点击“重新检测”;若仍失败,请重启 Lime,或确认 {prefix} 已加入 PATH。", package.name, version_suffix
|
||||
),
|
||||
auto_install_supported: false,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn git_auto_install_supported() -> Result<bool, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_command_version_text(
|
||||
command_path: &str,
|
||||
args: &[&str],
|
||||
) -> Result<String, String> {
|
||||
let mut command = Command::new(command_path);
|
||||
apply_binary_runtime_path(&mut command, command_path);
|
||||
for arg in args {
|
||||
command.arg(arg);
|
||||
}
|
||||
let output = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行命令失败({command_path}): {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Ok(stderr)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
|
||||
let npm_path = find_command_in_standard_locations("npm")
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let npm_global_prefix = match npm_path.as_deref() {
|
||||
Some(path) => detect_npm_global_prefix(path).await,
|
||||
None => None,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let where_candidates = find_commands_via_where("openclaw")
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let where_candidates = Vec::new();
|
||||
|
||||
let supplemental_search_dirs =
|
||||
collect_supplemental_openclaw_search_dirs(npm_global_prefix.as_deref());
|
||||
let supplemental_command_candidates =
|
||||
find_all_commands_in_paths("openclaw", &supplemental_search_dirs)
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
let openclaw_package_path = npm_global_prefix
|
||||
.as_deref()
|
||||
.and_then(find_installed_openclaw_package_details)
|
||||
.map(|package| package.path.display().to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let git_where_candidates = find_commands_via_where("git")
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let git_where_candidates = Vec::new();
|
||||
|
||||
let git_supplemental_search_dirs = collect_supplemental_git_search_dirs();
|
||||
let git_supplemental_command_candidates =
|
||||
find_all_commands_in_paths("git", &git_supplemental_search_dirs)
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
|
||||
EnvironmentDiagnostics {
|
||||
npm_path,
|
||||
npm_global_prefix,
|
||||
openclaw_package_path,
|
||||
where_candidates,
|
||||
supplemental_search_dirs: supplemental_search_dirs
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect(),
|
||||
supplemental_command_candidates,
|
||||
git_where_candidates,
|
||||
git_supplemental_search_dirs: git_supplemental_search_dirs
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect(),
|
||||
git_supplemental_command_candidates,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_supplemental_openclaw_search_dirs(
|
||||
npm_global_prefix: Option<&str>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
dirs.extend(windows_known_command_dirs_from_env());
|
||||
}
|
||||
|
||||
if let Some(prefix) = npm_global_prefix {
|
||||
dirs.extend(npm_global_command_dirs(prefix));
|
||||
}
|
||||
|
||||
collect_existing_unique_dirs(dirs)
|
||||
}
|
||||
|
||||
pub(crate) fn collect_supplemental_git_search_dirs() -> Vec<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn start_gateway(
|
||||
&mut self,
|
||||
app: Option<&AppHandle>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ActionResult, String> {
|
||||
if let Some(next_port) = port {
|
||||
self.gateway_port = next_port.max(1);
|
||||
}
|
||||
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("准备启动 Gateway,目标端口 {}。", self.gateway_port),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
self.ensure_runtime_config(None, None)?;
|
||||
self.refresh_process_state().await?;
|
||||
|
||||
if self.gateway_status == GatewayStatus::Running {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("检测到 Gateway 已在端口 {} 运行。", self.gateway_port),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("Gateway 已在端口 {} 运行", self.gateway_port),
|
||||
});
|
||||
}
|
||||
|
||||
let Some(openclaw_command) = resolve_openclaw_command().await? else {
|
||||
self.gateway_status = GatewayStatus::Error;
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error");
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
self.gateway_status = GatewayStatus::Starting;
|
||||
|
||||
let config_path = openclaw_lime_config_path();
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("使用配置文件启动 Gateway: {}", config_path.display()),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token);
|
||||
let mut command = openclaw_command.build_command_with_args(&start_args);
|
||||
command
|
||||
.env(OPENCLAW_CONFIG_ENV, &config_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Gateway 失败: {e}"))?;
|
||||
|
||||
let gateway_error_lines = Arc::new(StdMutex::new(Vec::<String>::new()));
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let app = app.cloned();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::info!(target: "openclaw", "Gateway stdout: {}", line);
|
||||
if let Some(app) = app.as_ref() {
|
||||
emit_install_progress(app, &line, classify_progress_level(&line, "info"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let app = app.cloned();
|
||||
let gateway_error_lines = gateway_error_lines.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::warn!(target: "openclaw", "Gateway stderr: {}", line);
|
||||
if let Ok(mut slot) = gateway_error_lines.lock() {
|
||||
push_gateway_error_line(&mut slot, &line);
|
||||
}
|
||||
if let Some(app) = app.as_ref() {
|
||||
emit_install_progress(app, &line, classify_progress_level(&line, "warn"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.gateway_process = Some(child);
|
||||
self.gateway_started_at = Some(SystemTime::now());
|
||||
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "Gateway 进程已拉起,等待服务就绪。", "info");
|
||||
}
|
||||
|
||||
let start_at = tokio::time::Instant::now();
|
||||
while start_at.elapsed() < Duration::from_secs(30) {
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
self.refresh_process_state().await?;
|
||||
let gateway_error_lines = gateway_error_lines
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|slot| slot.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if self.gateway_process.is_none() && self.gateway_status == GatewayStatus::Error {
|
||||
let message = format_gateway_start_failure_message(
|
||||
select_gateway_start_failure_detail(&gateway_error_lines),
|
||||
);
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, &message, "error");
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
if self.gateway_status == GatewayStatus::Running {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("Gateway 启动成功,监听端口 {}。", self.gateway_port),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("Gateway 已启动,端口 {}", self.gateway_port),
|
||||
});
|
||||
}
|
||||
|
||||
if self.check_port_open().await {
|
||||
self.gateway_status = GatewayStatus::Running;
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("Gateway 探测成功,监听端口 {}。", self.gateway_port),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("Gateway 已启动,端口 {}", self.gateway_port),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.gateway_status = GatewayStatus::Error;
|
||||
let gateway_error_lines = gateway_error_lines
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|slot| slot.clone())
|
||||
.unwrap_or_default();
|
||||
let message = format_gateway_start_failure_message(select_gateway_start_failure_detail(
|
||||
&gateway_error_lines,
|
||||
));
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, &message, "error");
|
||||
}
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_gateway(&mut self, app: Option<&AppHandle>) -> Result<ActionResult, String> {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "准备停止 Gateway。", "info");
|
||||
}
|
||||
|
||||
self.restore_auth_token_from_config();
|
||||
|
||||
if let Some(mut child) = self.gateway_process.take() {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "正在终止当前托管的 Gateway 子进程。", "info");
|
||||
}
|
||||
let _ = child.kill().await;
|
||||
let _ = timeout(Duration::from_secs(3), child.wait()).await;
|
||||
}
|
||||
|
||||
if self
|
||||
.wait_for_gateway_shutdown(Duration::from_secs(3))
|
||||
.await?
|
||||
{
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "Gateway 已停止。", "info");
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: "Gateway 已停止。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let stop_commands = self.collect_gateway_stop_commands().await?;
|
||||
if stop_commands.is_empty() {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
"未检测到可用的 OpenClaw 停止命令,将尝试按端口回收旧 Gateway。",
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for command_spec in &stop_commands {
|
||||
self.request_gateway_stop_via_command(command_spec, app)
|
||||
.await;
|
||||
if self
|
||||
.wait_for_gateway_shutdown(Duration::from_secs(4))
|
||||
.await?
|
||||
{
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "Gateway 已停止。", "info");
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: "Gateway 已停止。".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reclaimed_by_pid = self.force_stop_gateway_listener_processes(app).await?;
|
||||
if reclaimed_by_pid
|
||||
&& self
|
||||
.wait_for_gateway_shutdown(Duration::from_secs(5))
|
||||
.await?
|
||||
{
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "Gateway 已停止。", "info");
|
||||
}
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: "Gateway 已停止。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
self.refresh_process_state().await?;
|
||||
let message = if self.check_port_open().await {
|
||||
format!(
|
||||
"Gateway 停止失败:端口 {} 仍被旧进程占用,升级已中止。请使用“立即重启生效”或结束旧 OpenClaw 进程后重试。",
|
||||
self.gateway_port
|
||||
)
|
||||
} else {
|
||||
"Gateway 停止流程已结束,但未能确认运行态完全退出,请重试。".to_string()
|
||||
};
|
||||
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, &message, "error");
|
||||
}
|
||||
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn restart_gateway(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
emit_install_progress(app, "开始重启 Gateway。", "info");
|
||||
let stop_result = self.stop_gateway(Some(app)).await?;
|
||||
if !stop_result.success {
|
||||
return Ok(stop_result);
|
||||
}
|
||||
emit_install_progress(app, "Gateway 停止阶段结束,开始重新启动。", "info");
|
||||
self.start_gateway(Some(app), Some(self.gateway_port)).await
|
||||
}
|
||||
|
||||
pub async fn get_status(&mut self) -> Result<GatewayStatusInfo, String> {
|
||||
self.refresh_process_state().await?;
|
||||
Ok(GatewayStatusInfo {
|
||||
status: self.gateway_status.clone(),
|
||||
port: self.gateway_port,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_health(&mut self) -> Result<HealthInfo, String> {
|
||||
self.refresh_process_state().await?;
|
||||
|
||||
self.restore_auth_token_from_config();
|
||||
|
||||
let health_snapshot = self.fetch_authenticated_gateway_health_json().await;
|
||||
let healthy = self.gateway_status == GatewayStatus::Running
|
||||
&& self.check_port_open().await
|
||||
&& health_snapshot
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("ok").and_then(Value::as_bool))
|
||||
.unwrap_or(false);
|
||||
let version = self.read_openclaw_version().await.ok().flatten();
|
||||
let uptime = self.gateway_started_at.and_then(|start| {
|
||||
SystemTime::now()
|
||||
.duration_since(start)
|
||||
.ok()
|
||||
.map(|elapsed| elapsed.as_secs())
|
||||
});
|
||||
|
||||
Ok(HealthInfo {
|
||||
status: if healthy { "healthy" } else { "unhealthy" }.to_string(),
|
||||
gateway_port: self.gateway_port,
|
||||
uptime,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_dashboard_url(&mut self) -> String {
|
||||
self.restore_auth_token_from_config();
|
||||
let mut url = format!("http://127.0.0.1:{}", self.gateway_port);
|
||||
if !self.gateway_auth_token.is_empty() {
|
||||
url.push_str(&format!(
|
||||
"/#token={}",
|
||||
urlencoding::encode(&self.gateway_auth_token)
|
||||
));
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
pub async fn get_channels(&mut self) -> Result<Vec<ChannelInfo>, String> {
|
||||
self.refresh_process_state().await?;
|
||||
if self.gateway_status != GatewayStatus::Running {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
self.restore_auth_token_from_config();
|
||||
|
||||
let Some(body) = self.fetch_authenticated_gateway_health_json().await else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let channels_map = body
|
||||
.get("channels")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let labels = body
|
||||
.get("channelLabels")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let ordered_ids = body
|
||||
.get("channelOrder")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut ordered = Vec::new();
|
||||
for channel_id in ordered_ids.iter().filter_map(Value::as_str) {
|
||||
if let Some(entry) = channels_map.get(channel_id) {
|
||||
ordered.push(build_channel_info(
|
||||
channel_id,
|
||||
entry,
|
||||
labels.get(channel_id),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if ordered.is_empty() {
|
||||
ordered = channels_map
|
||||
.iter()
|
||||
.map(|(channel_id, entry)| {
|
||||
build_channel_info(channel_id, entry, labels.get(channel_id))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
async fn wait_for_gateway_shutdown(&mut self, max_wait: Duration) -> Result<bool, String> {
|
||||
let start_at = tokio::time::Instant::now();
|
||||
while start_at.elapsed() < max_wait {
|
||||
self.refresh_process_state().await?;
|
||||
if !self.check_port_open().await {
|
||||
self.clear_gateway_runtime_state();
|
||||
return Ok(true);
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
self.refresh_process_state().await?;
|
||||
if !self.check_port_open().await {
|
||||
self.clear_gateway_runtime_state();
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn collect_gateway_stop_commands(&self) -> Result<Vec<ResolvedOpenClawCommand>, String> {
|
||||
let mut commands = Vec::new();
|
||||
|
||||
if let Some(command) = resolve_openclaw_command().await? {
|
||||
commands.push(command);
|
||||
}
|
||||
|
||||
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
|
||||
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
|
||||
commands.extend(
|
||||
runtime_candidates
|
||||
.iter()
|
||||
.filter_map(resolve_openclaw_command_from_runtime_candidate),
|
||||
);
|
||||
|
||||
Ok(dedupe_openclaw_commands(commands))
|
||||
}
|
||||
|
||||
async fn request_gateway_stop_via_command(
|
||||
&self,
|
||||
command_spec: &ResolvedOpenClawCommand,
|
||||
app: Option<&AppHandle>,
|
||||
) {
|
||||
let binary_label = command_spec.invocation_display();
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("尝试通过 {} 停止 Gateway。", binary_label),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
let stop_args = vec![
|
||||
"gateway".to_string(),
|
||||
"stop".to_string(),
|
||||
"--url".to_string(),
|
||||
self.gateway_ws_url(),
|
||||
"--token".to_string(),
|
||||
self.gateway_auth_token.clone(),
|
||||
];
|
||||
let mut command = command_spec.build_command_with_args(&stop_args);
|
||||
let output = timeout(
|
||||
Duration::from_secs(8),
|
||||
command
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(Ok(result)) => {
|
||||
for line in command_output_lines(&result.stdout) {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, &line, classify_progress_level(&line, "info"));
|
||||
}
|
||||
}
|
||||
for line in command_output_lines(&result.stderr) {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, &line, classify_progress_level(&line, "warn"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(app) = app {
|
||||
if result.status.success() {
|
||||
emit_install_progress(app, "已发送 Gateway 停止命令。", "info");
|
||||
} else {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("Gateway 停止命令返回异常状态: {:?}", result.status.code()),
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("执行 Gateway 停止命令失败: {error}"),
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
"Gateway 停止命令超时,继续尝试自动回收旧进程。",
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn force_stop_gateway_listener_processes(
|
||||
&mut self,
|
||||
app: Option<&AppHandle>,
|
||||
) -> Result<bool, String> {
|
||||
let listener_pids = collect_listening_port_pids(self.gateway_port).await;
|
||||
if listener_pids.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut system = System::new_all();
|
||||
system.refresh_all();
|
||||
|
||||
let target_pids = collect_openclaw_process_family_pids(&system, &listener_pids);
|
||||
if target_pids.is_empty() {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"检测到端口 {} 仍被占用,但监听进程不是 OpenClaw,未执行自动终止。",
|
||||
self.gateway_port
|
||||
),
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"检测到旧 Gateway 仍占用端口 {},准备回收进程:{}。",
|
||||
self.gateway_port,
|
||||
target_pids
|
||||
.iter()
|
||||
.map(|pid| pid.as_u32().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
|
||||
terminate_sysinfo_processes(&mut system, &target_pids).await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_process_state(&mut self) -> Result<(), String> {
|
||||
let mut process_exited = false;
|
||||
|
||||
if let Some(child) = self.gateway_process.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
tracing::info!(target: "openclaw", "Gateway 进程已退出: {}", status);
|
||||
process_exited = true;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "openclaw", "检查 Gateway 进程状态失败: {}", error);
|
||||
process_exited = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if process_exited {
|
||||
self.gateway_process = None;
|
||||
self.gateway_started_at = None;
|
||||
}
|
||||
|
||||
let openclaw_command = resolve_openclaw_command().await?;
|
||||
let running = self.check_port_open().await
|
||||
|| self.check_gateway_status(openclaw_command.as_ref()).await?;
|
||||
|
||||
self.gateway_status = if running {
|
||||
GatewayStatus::Running
|
||||
} else if self.gateway_status == GatewayStatus::Starting {
|
||||
GatewayStatus::Error
|
||||
} else {
|
||||
GatewayStatus::Stopped
|
||||
};
|
||||
|
||||
if !running {
|
||||
self.gateway_process = None;
|
||||
self.gateway_started_at = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_port_open(&self) -> bool {
|
||||
timeout(
|
||||
Duration::from_secs(2),
|
||||
TcpStream::connect(("127.0.0.1", self.gateway_port)),
|
||||
)
|
||||
.await
|
||||
.map(|result| result.is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn check_gateway_status(
|
||||
&self,
|
||||
command_spec: Option<&ResolvedOpenClawCommand>,
|
||||
) -> Result<bool, String> {
|
||||
let Some(command_spec) = command_spec else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let status_args = vec![
|
||||
"gateway".to_string(),
|
||||
"status".to_string(),
|
||||
"--url".to_string(),
|
||||
self.gateway_ws_url(),
|
||||
"--token".to_string(),
|
||||
self.gateway_auth_token.clone(),
|
||||
];
|
||||
let mut command = command_spec.build_command_with_args(&status_args);
|
||||
let output = command
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(result) => {
|
||||
let stdout = String::from_utf8_lossy(&result.stdout).to_lowercase();
|
||||
let stderr = String::from_utf8_lossy(&result.stderr).to_lowercase();
|
||||
Ok(result.status.success()
|
||||
&& (stdout.contains("listening")
|
||||
|| stdout.contains("running")
|
||||
|| stderr.contains("listening")))
|
||||
}
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_openclaw_version(&self) -> Result<Option<String>, String> {
|
||||
let Some(command_spec) = resolve_openclaw_command().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
read_openclaw_version_from_command(&command_spec).await
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_ws_url(&self) -> String {
|
||||
format!("ws://127.0.0.1:{}", self.gateway_port)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_auth_token_from_config(&mut self) {
|
||||
if !self.gateway_auth_token.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match read_base_openclaw_config()
|
||||
.ok()
|
||||
.and_then(|config| extract_gateway_auth_token(&config))
|
||||
{
|
||||
Some(token) => {
|
||||
self.gateway_auth_token = token;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "openclaw",
|
||||
"未能从 OpenClaw 配置恢复 gateway token,Dashboard 访问可能鉴权失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_authenticated_gateway_health_json(&self) -> Option<Value> {
|
||||
if self.gateway_auth_token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let health_args = vec![
|
||||
"gateway".to_string(),
|
||||
"health".to_string(),
|
||||
"--url".to_string(),
|
||||
self.gateway_ws_url(),
|
||||
"--token".to_string(),
|
||||
self.gateway_auth_token.clone(),
|
||||
"--json".to_string(),
|
||||
];
|
||||
let mut command = command_spec.build_command_with_args(&health_args);
|
||||
let output = command
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
serde_json::from_slice::<Value>(&output.stdout)
|
||||
.map_err(|error| {
|
||||
tracing::warn!(
|
||||
target: "openclaw",
|
||||
"解析 Gateway 官方健康检查结果失败: {}",
|
||||
error
|
||||
);
|
||||
error
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::warn!(
|
||||
target: "openclaw",
|
||||
"Gateway 官方健康检查失败: {}",
|
||||
stderr.trim()
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "openclaw", "执行 Gateway 官方健康检查失败: {}", error);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_start_args(gateway_port: u16, gateway_auth_token: &str) -> Vec<String> {
|
||||
vec![
|
||||
"gateway".to_string(),
|
||||
"--allow-unconfigured".to_string(),
|
||||
"--bind".to_string(),
|
||||
"loopback".to_string(),
|
||||
"--auth".to_string(),
|
||||
"token".to_string(),
|
||||
"--token".to_string(),
|
||||
gateway_auth_token.to_string(),
|
||||
"--port".to_string(),
|
||||
gateway_port.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn push_gateway_error_line(lines: &mut Vec<String>, line: &str) {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if lines.len() >= 32 {
|
||||
lines.remove(0);
|
||||
}
|
||||
lines.push(trimmed.to_string());
|
||||
}
|
||||
|
||||
pub(crate) fn select_gateway_start_failure_detail<'a>(lines: &'a [String]) -> Option<&'a str> {
|
||||
lines
|
||||
.iter()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
let score = gateway_failure_line_score(trimmed);
|
||||
(score > 0).then_some((score, trimmed))
|
||||
})
|
||||
.max_by_key(|(score, _)| *score)
|
||||
.map(|(_, line)| line)
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_failure_line_score(line: &str) -> u8 {
|
||||
if line.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let normalized = line.to_ascii_lowercase();
|
||||
if normalized.starts_with("run: openclaw doctor")
|
||||
|| normalized == "config invalid"
|
||||
|| normalized.starts_with("file:")
|
||||
|| normalized == "problem:"
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if normalized.contains("invalid config") {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if normalized.contains("contextwindow") && normalized.contains("received null") {
|
||||
return 95;
|
||||
}
|
||||
|
||||
if normalized.contains("address already in use")
|
||||
|| normalized.contains("eaddrinuse")
|
||||
|| normalized.contains("resolved to non-loopback host")
|
||||
{
|
||||
return 90;
|
||||
}
|
||||
|
||||
if normalized.contains("missing config")
|
||||
|| normalized.contains("gateway.mode=local")
|
||||
|| normalized.contains("gateway.auth.mode")
|
||||
{
|
||||
return 85;
|
||||
}
|
||||
|
||||
if normalized.starts_with("- ") {
|
||||
return 60;
|
||||
}
|
||||
|
||||
20
|
||||
}
|
||||
|
||||
pub(crate) fn format_gateway_start_failure_message(detail: Option<&str>) -> String {
|
||||
let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return "Gateway 启动超时,请检查配置或端口占用。".to_string();
|
||||
};
|
||||
|
||||
let normalized = detail.to_ascii_lowercase();
|
||||
if normalized.contains("invalid config") || normalized.contains("config invalid") {
|
||||
if normalized.contains("contextwindow") && normalized.contains("received null") {
|
||||
return "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。"
|
||||
.to_string();
|
||||
}
|
||||
return "Gateway 启动失败:OpenClaw 配置文件无效,请重新同步模型配置后再试。".to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("missing config") || normalized.contains("gateway.mode=local") {
|
||||
return "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。"
|
||||
.to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("gateway.auth.mode") {
|
||||
return "Gateway 启动失败:缺少网关认证模式,已自动切换为 token 模式,请重试。".to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("address already in use") || normalized.contains("eaddrinuse") {
|
||||
return "Gateway 启动失败:目标端口已被占用,请更换端口或停止占用进程。".to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("resolved to non-loopback host") {
|
||||
return "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。".to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("allowedorigins") || normalized.contains("host-header origin fallback") {
|
||||
return "Gateway 启动失败:当前绑定方式需要配置 Control UI 允许来源,请检查 gateway.controlUi.allowedOrigins。".to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("doctor --fix") {
|
||||
return "Gateway 启动失败:OpenClaw 检测到本地环境或配置异常,请先在安装页执行“重新检测”或“修复环境”后再试。"
|
||||
.to_string();
|
||||
}
|
||||
|
||||
format!("Gateway 启动失败:{detail}")
|
||||
}
|
||||
|
||||
pub(crate) fn build_channel_info(
|
||||
channel_id: &str,
|
||||
entry: &Value,
|
||||
label: Option<&Value>,
|
||||
) -> ChannelInfo {
|
||||
ChannelInfo {
|
||||
id: channel_id.to_string(),
|
||||
name: entry
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| label.and_then(Value::as_str))
|
||||
.unwrap_or("未命名通道")
|
||||
.to_string(),
|
||||
channel_type: entry
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
status: entry
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn install(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
emit_install_progress(app, "开始准备 OpenClaw 环境。", "info");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let node_status = self.inspect_dependency_status(DependencyKind::Node).await?;
|
||||
let git_status = self.inspect_dependency_status(DependencyKind::Git).await?;
|
||||
if let Some(result) = windows_install_block_result(&node_status, &git_status) {
|
||||
emit_install_progress(app, &result.message, "warn");
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
let node_result = self
|
||||
.ensure_dependency_ready(app, DependencyKind::Node)
|
||||
.await?;
|
||||
if !node_result.success {
|
||||
return Ok(node_result);
|
||||
}
|
||||
|
||||
let git_result = self
|
||||
.ensure_dependency_ready(app, DependencyKind::Git)
|
||||
.await?;
|
||||
if !git_result.success {
|
||||
return Ok(git_result);
|
||||
}
|
||||
|
||||
let (_, npm_path, npm_prefix, cleanup_command, install_command) =
|
||||
self.resolve_install_commands(app).await?;
|
||||
|
||||
emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info");
|
||||
if let Some(prefix) = npm_prefix {
|
||||
emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info");
|
||||
}
|
||||
emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info");
|
||||
let cleanup_result = run_shell_command_with_progress(app, &cleanup_command).await?;
|
||||
if !cleanup_result.success {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"清理旧版 OpenClaw 失败,继续尝试安装:{}",
|
||||
cleanup_result.message
|
||||
),
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
|
||||
emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info");
|
||||
let result = run_shell_command_with_progress(app, &install_command).await?;
|
||||
if !result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let installed = self.check_installed().await?;
|
||||
if installed.installed {
|
||||
emit_install_progress(app, "已检测到 OpenClaw 可执行文件。", "info");
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: installed
|
||||
.path
|
||||
.map(|path| format!("OpenClaw 安装完成:{path}"))
|
||||
.unwrap_or_else(|| "OpenClaw 安装完成。".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message:
|
||||
"安装命令执行完成,但仍未检测到 OpenClaw 可执行文件,请检查 npm 全局目录或权限设置。"
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn install_dependency(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
kind: &str,
|
||||
) -> Result<ActionResult, String> {
|
||||
let dependency = match kind {
|
||||
"node" => DependencyKind::Node,
|
||||
"git" => DependencyKind::Git,
|
||||
_ => return Err(format!("不支持的依赖类型: {kind}")),
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let status = self.inspect_dependency_status(dependency).await?;
|
||||
if status.status == "ok" {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"{} 已就绪{}。",
|
||||
dependency.label(),
|
||||
status
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|version| format!(" · {version}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("{} 已满足要求。", dependency.label()),
|
||||
});
|
||||
}
|
||||
|
||||
let result = windows_dependency_action_result(dependency, &status);
|
||||
emit_install_progress(app, &result.message, "warn");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
self.ensure_dependency_ready(app, dependency).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cleanup_temp_artifacts(
|
||||
&mut self,
|
||||
app: Option<&AppHandle>,
|
||||
) -> Result<ActionResult, String> {
|
||||
let mut removed = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for target in collect_temp_artifact_paths(app) {
|
||||
if !target.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = if target.is_dir() {
|
||||
std::fs::remove_dir_all(&target)
|
||||
} else {
|
||||
std::fs::remove_file(&target)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("已清理临时文件:{}", target.display()),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
removed.push(target.display().to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("清理临时文件失败({}): {error}", target.display()),
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
failed.push(format!("{}: {error}", target.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failed.is_empty() {
|
||||
Ok(ActionResult {
|
||||
success: true,
|
||||
message: if removed.is_empty() {
|
||||
"未发现需要清理的 OpenClaw 临时文件。".to_string()
|
||||
} else {
|
||||
format!("已清理 {} 项临时文件。", removed.len())
|
||||
},
|
||||
})
|
||||
} else {
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message: format!("部分临时文件清理失败:{}", failed.join(";")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn uninstall(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
if self.gateway_status == GatewayStatus::Running || self.gateway_process.is_some() {
|
||||
let _ = self.stop_gateway(None).await;
|
||||
}
|
||||
|
||||
let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?;
|
||||
|
||||
emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info");
|
||||
if let Some(prefix) = npm_prefix {
|
||||
emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info");
|
||||
}
|
||||
emit_install_progress(app, &format!("执行卸载命令: {command}"), "info");
|
||||
run_shell_command_with_progress(app, &command).await
|
||||
}
|
||||
|
||||
async fn ensure_dependency_ready(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
dependency: DependencyKind,
|
||||
) -> Result<ActionResult, String> {
|
||||
let status = self.inspect_dependency_status(dependency).await?;
|
||||
if status.status == "ok" {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"{} 已就绪{}。",
|
||||
dependency.label(),
|
||||
status
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|version| format!(" · {version}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("{} 已满足要求。", dependency.label()),
|
||||
});
|
||||
}
|
||||
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("{},开始修复 {} 环境。", status.message, dependency.label()),
|
||||
"warn",
|
||||
);
|
||||
|
||||
match dependency {
|
||||
DependencyKind::Node => self.install_node_runtime(app).await,
|
||||
DependencyKind::Git => self.install_git_runtime(app).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn inspect_dependency_status(
|
||||
&self,
|
||||
dependency: DependencyKind,
|
||||
) -> Result<DependencyStatus, String> {
|
||||
match dependency {
|
||||
DependencyKind::Node => inspect_node_dependency_status().await,
|
||||
DependencyKind::Git => inspect_git_dependency_status().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_node_runtime(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let winget_path = find_command_in_shell("winget").await?;
|
||||
match resolve_windows_dependency_install_plan(
|
||||
DependencyKind::Node,
|
||||
winget_path.is_some(),
|
||||
) {
|
||||
WindowsDependencyInstallPlan::Winget { package_id } => {
|
||||
let winget_path = winget_path.expect("winget path should exist");
|
||||
emit_install_progress(
|
||||
app,
|
||||
"检测到 winget,准备通过 winget 安装 Node.js。",
|
||||
"info",
|
||||
);
|
||||
let command = build_winget_install_command(&winget_path, package_id);
|
||||
let result = run_shell_command_with_progress(app, &command).await?;
|
||||
if !result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
return self
|
||||
.verify_dependency_after_install(app, DependencyKind::Node)
|
||||
.await;
|
||||
}
|
||||
WindowsDependencyInstallPlan::OfficialInstaller => {
|
||||
emit_install_progress(
|
||||
app,
|
||||
"未检测到 winget,准备下载官方 Node.js 安装器。",
|
||||
"warn",
|
||||
);
|
||||
let asset = resolve_node_installer_asset().await?;
|
||||
let installer_path = download_installer_asset(app, &asset).await?;
|
||||
launch_installer(&installer_path)?;
|
||||
return self
|
||||
.wait_for_dependency_ready(app, DependencyKind::Node, 900)
|
||||
.await;
|
||||
}
|
||||
WindowsDependencyInstallPlan::ManualDownload => {
|
||||
unreachable!("Node.js 在 Windows 上不应返回手动下载计划")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(brew_path) = find_command_in_shell("brew").await? {
|
||||
emit_install_progress(
|
||||
app,
|
||||
"检测到 Homebrew,准备通过 Homebrew 安装 Node.js。",
|
||||
"info",
|
||||
);
|
||||
let brew_cmd = shell_command_escape(&brew_path);
|
||||
let path_env = shell_path_assignment(&brew_path);
|
||||
let command = format!(
|
||||
"{path_env}{brew_cmd} install node || {path_env}{brew_cmd} upgrade node"
|
||||
);
|
||||
let result = run_shell_command_with_progress(app, &command).await?;
|
||||
if !result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
return self
|
||||
.verify_dependency_after_install(app, DependencyKind::Node)
|
||||
.await;
|
||||
}
|
||||
|
||||
emit_install_progress(
|
||||
app,
|
||||
"未检测到 Homebrew,准备下载官方 Node.js 安装器。",
|
||||
"warn",
|
||||
);
|
||||
let asset = resolve_node_installer_asset().await?;
|
||||
let installer_path = download_installer_asset(app, &asset).await?;
|
||||
launch_installer(&installer_path)?;
|
||||
return self
|
||||
.wait_for_dependency_ready(app, DependencyKind::Node, 900)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let message = "当前平台暂不支持应用内自动安装 Node.js,请手动安装 Node.js 22+ 后重试。"
|
||||
.to_string();
|
||||
emit_install_progress(app, &message, "warn");
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_git_runtime(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let winget_path = find_command_in_shell("winget").await?;
|
||||
match resolve_windows_dependency_install_plan(
|
||||
DependencyKind::Git,
|
||||
winget_path.is_some(),
|
||||
) {
|
||||
WindowsDependencyInstallPlan::Winget { package_id } => {
|
||||
let winget_path = winget_path.expect("winget path should exist");
|
||||
emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info");
|
||||
let command = build_winget_install_command(&winget_path, package_id);
|
||||
let result = run_shell_command_with_progress(app, &command).await?;
|
||||
if !result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
return self
|
||||
.verify_dependency_after_install(app, DependencyKind::Git)
|
||||
.await;
|
||||
}
|
||||
WindowsDependencyInstallPlan::OfficialInstaller => {
|
||||
unreachable!("Git 在 Windows 上不应返回官方安装器计划")
|
||||
}
|
||||
WindowsDependencyInstallPlan::ManualDownload => {
|
||||
let message = windows_manual_install_message(DependencyKind::Git).to_string();
|
||||
emit_install_progress(app, &message, "warn");
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(brew_path) = find_command_in_shell("brew").await? {
|
||||
emit_install_progress(app, "检测到 Homebrew,准备通过 Homebrew 安装 Git。", "info");
|
||||
let brew_cmd = shell_command_escape(&brew_path);
|
||||
let path_env = shell_path_assignment(&brew_path);
|
||||
let command =
|
||||
format!("{path_env}{brew_cmd} install git || {path_env}{brew_cmd} upgrade git");
|
||||
let result = run_shell_command_with_progress(app, &command).await?;
|
||||
if !result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
return self
|
||||
.verify_dependency_after_install(app, DependencyKind::Git)
|
||||
.await;
|
||||
}
|
||||
|
||||
emit_install_progress(
|
||||
app,
|
||||
"未检测到 Homebrew,准备拉起 macOS Command Line Tools 安装器。",
|
||||
"warn",
|
||||
);
|
||||
let trigger_result = trigger_macos_command_line_tools_install().await?;
|
||||
emit_install_progress(app, &trigger_result, "info");
|
||||
return self
|
||||
.wait_for_dependency_ready(app, DependencyKind::Git, 1200)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let message = "当前平台暂不支持应用内自动安装 Git,请使用系统包管理器手动安装后重试。"
|
||||
.to_string();
|
||||
emit_install_progress(app, &message, "warn");
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_dependency_after_install(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
dependency: DependencyKind,
|
||||
) -> Result<ActionResult, String> {
|
||||
// 在 Windows 上刷新 PATH 环境变量
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Err(e) = refresh_windows_path_from_registry() {
|
||||
emit_install_progress(app, &format!("刷新环境变量失败: {}", e), "warn");
|
||||
} else {
|
||||
emit_install_progress(app, "已刷新系统环境变量。", "info");
|
||||
}
|
||||
}
|
||||
|
||||
let status = self.inspect_dependency_status(dependency).await?;
|
||||
if status.status == "ok" {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"{} 已准备完成{}。",
|
||||
dependency.label(),
|
||||
status
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|version| format!(" · {version}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("{} 已安装完成。", dependency.label()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message: format!(
|
||||
"{} 安装完成后仍未通过校验:{}",
|
||||
dependency.label(),
|
||||
status.message
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_dependency_ready(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
dependency: DependencyKind,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ActionResult, String> {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"已拉起 {} 安装器,正在等待安装完成(最长 {} 秒)。",
|
||||
dependency.label(),
|
||||
timeout_secs
|
||||
),
|
||||
"info",
|
||||
);
|
||||
|
||||
let start = tokio::time::Instant::now();
|
||||
let mut last_notice_at = 0_u64;
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut last_refresh_at = 0_u64;
|
||||
|
||||
while start.elapsed() < Duration::from_secs(timeout_secs) {
|
||||
let elapsed = start.elapsed().as_secs();
|
||||
|
||||
// 每 10 秒刷新一次 Windows PATH(因为用户可能在安装过程中)
|
||||
#[cfg(target_os = "windows")]
|
||||
if elapsed >= last_refresh_at + 10 {
|
||||
last_refresh_at = elapsed;
|
||||
let _ = refresh_windows_path_from_registry();
|
||||
}
|
||||
|
||||
if elapsed >= last_notice_at + 15 {
|
||||
last_notice_at = elapsed;
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("正在等待 {} 安装完成…", dependency.label()),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
let status = self.inspect_dependency_status(dependency).await?;
|
||||
if status.status == "ok" {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"{} 已检测通过{}。",
|
||||
dependency.label(),
|
||||
status
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|version| format!(" · {version}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return Ok(ActionResult {
|
||||
success: true,
|
||||
message: format!("{} 已安装完成。", dependency.label()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message: format!(
|
||||
"等待 {} 安装完成超时,请完成安装后重新点击重试。",
|
||||
dependency.label()
|
||||
),
|
||||
})
|
||||
}
|
||||
pub(crate) async fn resolve_install_commands(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
) -> Result<(String, String, Option<String>, String, String), String> {
|
||||
let npm_path = find_command_in_shell("npm")
|
||||
.await?
|
||||
.ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?;
|
||||
let npm_prefix = detect_npm_global_prefix(&npm_path).await;
|
||||
let use_china_package = should_use_china_package(app).await;
|
||||
let package = if use_china_package {
|
||||
OPENCLAW_CN_PACKAGE
|
||||
} else {
|
||||
OPENCLAW_DEFAULT_PACKAGE
|
||||
};
|
||||
let shell_platform = current_shell_platform();
|
||||
let cleanup_command =
|
||||
build_openclaw_cleanup_command(shell_platform, &npm_path, npm_prefix.as_deref());
|
||||
let install_command = build_openclaw_install_command(
|
||||
shell_platform,
|
||||
&npm_path,
|
||||
npm_prefix.as_deref(),
|
||||
package,
|
||||
use_china_package.then_some(NPM_MIRROR_CN),
|
||||
);
|
||||
Ok((
|
||||
package.to_string(),
|
||||
npm_path,
|
||||
npm_prefix,
|
||||
cleanup_command,
|
||||
install_command,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_uninstall_command(
|
||||
&self,
|
||||
) -> Result<(String, Option<String>, String), String> {
|
||||
let npm_path = find_command_in_shell("npm")
|
||||
.await?
|
||||
.ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?;
|
||||
let npm_prefix = detect_npm_global_prefix(&npm_path).await;
|
||||
let command = build_openclaw_cleanup_command(
|
||||
current_shell_platform(),
|
||||
&npm_path,
|
||||
npm_prefix.as_deref(),
|
||||
);
|
||||
Ok((npm_path, npm_prefix, command))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn openclaw_installer_download_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let _ = app;
|
||||
let app_data_dir = lime_core::app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let dir = app_data_dir.join("downloads").join("openclaw-installers");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub(crate) fn collect_temp_artifact_paths(app: Option<&AppHandle>) -> Vec<PathBuf> {
|
||||
let mut targets = Vec::new();
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
targets.push(PathBuf::from(OPENCLAW_TEMP_CARGO_CHECK_DIR));
|
||||
}
|
||||
|
||||
if let Some(app) = app {
|
||||
if let Ok(dir) = openclaw_installer_download_dir(app) {
|
||||
targets.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_node_installer_asset() -> Result<InstallerAsset, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get("https://nodejs.org/dist/index.json")
|
||||
.header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求 Node.js 版本列表失败: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"获取 Node.js 版本列表失败: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let releases: Vec<Value> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析 Node.js 版本列表失败: {e}"))?;
|
||||
|
||||
let select_version = |only_lts: bool| -> Option<String> {
|
||||
releases.iter().find_map(|release| {
|
||||
let version = release.get("version")?.as_str()?;
|
||||
let parsed = parse_semver(version)?;
|
||||
let is_lts = release
|
||||
.get("lts")
|
||||
.map(|value| match value {
|
||||
Value::Bool(flag) => *flag,
|
||||
Value::String(text) => !text.trim().is_empty() && text != "false",
|
||||
_ => false,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if parsed >= NODE_MIN_VERSION && (!only_lts || is_lts) {
|
||||
Some(version.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let version = select_version(true)
|
||||
.or_else(|| select_version(false))
|
||||
.ok_or_else(|| "未找到满足要求的 Node.js 官方安装包版本。".to_string())?;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let filename = {
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
format!("node-{version}-arm64.msi")
|
||||
}
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
{
|
||||
format!("node-{version}-x64.msi")
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let filename = format!("node-{version}.pkg");
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let filename = String::new();
|
||||
|
||||
if filename.is_empty() {
|
||||
return Err("当前平台暂不支持自动下载官方 Node.js 安装器。".to_string());
|
||||
}
|
||||
|
||||
Ok(InstallerAsset {
|
||||
download_url: format!("https://nodejs.org/dist/{version}/{filename}"),
|
||||
filename,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn download_installer_asset(
|
||||
app: &AppHandle,
|
||||
asset: &InstallerAsset,
|
||||
) -> Result<PathBuf, String> {
|
||||
let download_dir = openclaw_installer_download_dir(app)?;
|
||||
let installer_path = download_dir.join(&asset.filename);
|
||||
if installer_path.exists() {
|
||||
let _ = std::fs::remove_file(&installer_path);
|
||||
}
|
||||
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("开始下载安装器:{}", asset.download_url),
|
||||
"info",
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&asset.download_url)
|
||||
.header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("下载官方安装器失败: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("下载安装器失败: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("读取安装器文件失败: {e}"))?;
|
||||
std::fs::write(&installer_path, bytes)
|
||||
.map_err(|e| format!("保存安装器失败({}): {e}", installer_path.display()))?;
|
||||
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("安装器已保存到:{}", installer_path.display()),
|
||||
"info",
|
||||
);
|
||||
|
||||
Ok(installer_path)
|
||||
}
|
||||
|
||||
pub(crate) fn launch_installer(file_path: &Path) -> Result<(), String> {
|
||||
let extension = file_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match extension.as_str() {
|
||||
"exe" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new(file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动安装程序失败: {e}"))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
return Err("EXE 安装器只能在 Windows 上运行。".to_string());
|
||||
}
|
||||
}
|
||||
"msi" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("msiexec")
|
||||
.arg("/i")
|
||||
.arg(file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 MSI 安装程序失败: {e}"))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
return Err("MSI 安装器只能在 Windows 上运行。".to_string());
|
||||
}
|
||||
}
|
||||
"pkg" | "dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开 macOS 安装器失败: {e}"))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
return Err("该安装器只能在 macOS 上运行。".to_string());
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("不支持的安装器文件类型: {extension}")),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) async fn trigger_macos_command_line_tools_install() -> Result<String, String> {
|
||||
let output = Command::new("/usr/bin/xcode-select")
|
||||
.arg("--install")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("拉起 macOS 开发者工具安装器失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let combined = if !stderr.is_empty() { stderr } else { stdout };
|
||||
let lower = combined.to_ascii_lowercase();
|
||||
|
||||
if output.status.success()
|
||||
|| lower.contains("install requested")
|
||||
|| lower.contains("already been requested")
|
||||
{
|
||||
return Ok("已拉起 macOS 开发者工具安装器。".to_string());
|
||||
}
|
||||
|
||||
if lower.contains("already installed") {
|
||||
return Err(
|
||||
"系统提示 Command Line Tools 已安装,但当前仍未检测到 Git,请先执行系统更新或安装 Homebrew 后重试。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Err(format!("拉起 macOS 开发者工具安装器失败: {combined}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn trigger_macos_command_line_tools_install() -> Result<String, String> {
|
||||
Err("当前平台不支持拉起 macOS 开发者工具安装器。".to_string())
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use crate::app::AppState;
|
||||
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType};
|
||||
use dirs::{data_dir, home_dir};
|
||||
use lime_core::openclaw_install::{
|
||||
build_openclaw_cleanup_command as core_build_openclaw_cleanup_command,
|
||||
build_openclaw_install_command as core_build_openclaw_install_command,
|
||||
build_winget_install_command as core_build_winget_install_command,
|
||||
command_bin_dir_for as core_command_bin_dir_for,
|
||||
resolve_windows_dependency_install_plan as core_resolve_windows_dependency_install_plan,
|
||||
select_best_semver_candidate as core_select_best_semver_candidate,
|
||||
select_preferred_path_candidate as core_select_preferred_path_candidate,
|
||||
shell_command_escape_for as core_shell_command_escape_for,
|
||||
shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for,
|
||||
shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for,
|
||||
shell_path_assignment_for as core_shell_path_assignment_for,
|
||||
windows_manual_install_message as core_windows_manual_install_message,
|
||||
OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan,
|
||||
};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
use sysinfo::{Pid, Signal, System};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
#[cfg(target_os = "windows")]
|
||||
use winapi::shared::minwindef::{DWORD, HKEY};
|
||||
#[cfg(target_os = "windows")]
|
||||
use winapi::shared::winerror::ERROR_SUCCESS;
|
||||
#[cfg(target_os = "windows")]
|
||||
use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
|
||||
const DEFAULT_GATEWAY_PORT: u16 = 18790;
|
||||
const OPENCLAW_INSTALL_EVENT: &str = "openclaw:install-progress";
|
||||
const OPENCLAW_CONFIG_ENV: &str = "OPENCLAW_CONFIG_PATH";
|
||||
|
||||
fn shell_escape(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest";
|
||||
const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest";
|
||||
const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com";
|
||||
const NODE_MIN_VERSION: (u64, u64, u64) = (22, 12, 0);
|
||||
const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400;
|
||||
const OPENCLAW_INSTALLER_USER_AGENT: &str = "Lime-OpenClaw";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/lime-cargo-check";
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
static OPENCLAW_PREFERRED_RUNTIME_BIN_DIR: OnceLock<StdMutex<Option<PathBuf>>> = OnceLock::new();
|
||||
|
||||
mod config;
|
||||
mod diagnostics;
|
||||
mod gateway;
|
||||
mod install;
|
||||
mod platform;
|
||||
mod preview;
|
||||
mod process;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod types;
|
||||
mod update;
|
||||
|
||||
pub(crate) use self::config::*;
|
||||
pub(crate) use self::diagnostics::*;
|
||||
pub(crate) use self::gateway::*;
|
||||
pub(crate) use self::install::*;
|
||||
pub(crate) use self::platform::*;
|
||||
pub(crate) use self::process::*;
|
||||
pub(crate) use self::progress::*;
|
||||
pub(crate) use self::runtime::*;
|
||||
pub use self::types::{
|
||||
ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus, ChannelInfo, CommandPreview,
|
||||
DependencyStatus, EnvironmentDiagnostics, EnvironmentStatus, GatewayStatus, GatewayStatusInfo,
|
||||
HealthInfo, InstallProgressEvent, NodeCheckResult, OpenClawRuntimeCandidate, SyncModelEntry,
|
||||
UpdateInfo,
|
||||
};
|
||||
pub(crate) use self::types::{
|
||||
DependencyKind, InstallerAsset, OpenClawDirectUpgradePlan, OpenClawDirectUpgradeResult,
|
||||
OpenClawUpdateExecutionContext, ResolvedOpenClawCommand,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::update::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OpenClawService {
|
||||
gateway_process: Option<Child>,
|
||||
gateway_status: GatewayStatus,
|
||||
gateway_port: u16,
|
||||
gateway_auth_token: String,
|
||||
gateway_started_at: Option<SystemTime>,
|
||||
progress_logs: VecDeque<InstallProgressEvent>,
|
||||
}
|
||||
|
||||
impl Default for OpenClawService {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gateway_process: None,
|
||||
gateway_status: GatewayStatus::Stopped,
|
||||
gateway_port: DEFAULT_GATEWAY_PORT,
|
||||
gateway_auth_token: String::new(),
|
||||
gateway_started_at: None,
|
||||
progress_logs: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenClawServiceState(pub std::sync::Arc<Mutex<OpenClawService>>);
|
||||
|
||||
impl Default for OpenClawServiceState {
|
||||
fn default() -> Self {
|
||||
Self(std::sync::Arc::new(Mutex::new(OpenClawService::default())))
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenClawService {
|
||||
pub fn clear_progress_logs(&mut self) {
|
||||
self.progress_logs.clear();
|
||||
}
|
||||
|
||||
pub fn get_progress_logs(&self) -> Vec<InstallProgressEvent> {
|
||||
self.progress_logs.iter().cloned().collect()
|
||||
}
|
||||
|
||||
fn clear_gateway_runtime_state(&mut self) {
|
||||
self.gateway_process = None;
|
||||
self.gateway_started_at = None;
|
||||
self.gateway_status = GatewayStatus::Stopped;
|
||||
}
|
||||
fn push_progress_log(&mut self, message: String, level: String) {
|
||||
if self.progress_logs.len() >= OPENCLAW_PROGRESS_LOG_LIMIT {
|
||||
self.progress_logs.pop_front();
|
||||
}
|
||||
self.progress_logs
|
||||
.push_back(InstallProgressEvent { message, level });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openclaw_install_event_name() -> &'static str {
|
||||
OPENCLAW_INSTALL_EVENT
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn generate_auth_token() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(48)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn should_use_china_package(app: &AppHandle) -> bool {
|
||||
if let Some(app_state) = app.try_state::<AppState>() {
|
||||
let language = {
|
||||
let state = app_state.read().await;
|
||||
state.config.language.clone()
|
||||
};
|
||||
|
||||
if language.starts_with("zh") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let locale = std::env::var("LC_ALL")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("LANG").ok())
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let timezone = std::env::var("TZ").unwrap_or_default().to_lowercase();
|
||||
locale.contains("zh_cn") || locale.contains("zh-hans") || timezone.contains("shanghai")
|
||||
}
|
||||
|
||||
pub(crate) async fn detect_npm_global_prefix(npm_path: &str) -> Option<String> {
|
||||
let mut command = Command::new(npm_path);
|
||||
apply_binary_runtime_path(&mut command, npm_path);
|
||||
let output = command
|
||||
.arg("config")
|
||||
.arg("get")
|
||||
.arg("prefix")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if prefix.is_empty() || prefix.eq_ignore_ascii_case("undefined") {
|
||||
None
|
||||
} else {
|
||||
Some(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_shell_platform() -> ShellPlatform {
|
||||
if cfg!(target_os = "windows") {
|
||||
ShellPlatform::Windows
|
||||
} else {
|
||||
ShellPlatform::Unix
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option<String> {
|
||||
core_command_bin_dir_for(platform, binary_path)
|
||||
}
|
||||
|
||||
pub(crate) fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String {
|
||||
core_shell_command_escape_for(platform, value)
|
||||
}
|
||||
|
||||
pub(crate) fn shell_command_invocation_prefix_for(
|
||||
platform: ShellPlatform,
|
||||
binary_path: &str,
|
||||
) -> String {
|
||||
core_shell_command_invocation_prefix_for(platform, binary_path)
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
pub(crate) fn shell_command_escape(value: &str) -> String {
|
||||
shell_command_escape_for(current_shell_platform(), value)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String {
|
||||
core_shell_npm_prefix_assignment_for(platform, value)
|
||||
}
|
||||
|
||||
pub(crate) fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String {
|
||||
core_shell_path_assignment_for(platform, binary_path)
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
pub(crate) fn shell_path_assignment(binary_path: &str) -> String {
|
||||
shell_path_assignment_for(current_shell_platform(), binary_path)
|
||||
}
|
||||
|
||||
pub(crate) fn build_openclaw_cleanup_command(
|
||||
platform: ShellPlatform,
|
||||
npm_path: &str,
|
||||
npm_prefix: Option<&str>,
|
||||
) -> String {
|
||||
core_build_openclaw_cleanup_command(platform, npm_path, npm_prefix)
|
||||
}
|
||||
|
||||
pub(crate) fn build_openclaw_install_command(
|
||||
platform: ShellPlatform,
|
||||
npm_path: &str,
|
||||
npm_prefix: Option<&str>,
|
||||
package: &str,
|
||||
registry: Option<&str>,
|
||||
) -> String {
|
||||
core_build_openclaw_install_command(platform, npm_path, npm_prefix, package, registry)
|
||||
}
|
||||
|
||||
pub(crate) fn build_openclaw_pnpm_install_command(
|
||||
platform: ShellPlatform,
|
||||
pnpm_path: &str,
|
||||
package: &str,
|
||||
registry: Option<&str>,
|
||||
) -> String {
|
||||
let mut command = format!(
|
||||
"{}{}{} add -g {}",
|
||||
shell_path_assignment_for(platform, pnpm_path),
|
||||
shell_command_invocation_prefix_for(platform, pnpm_path),
|
||||
shell_command_escape_for(platform, pnpm_path),
|
||||
shell_command_escape_for(platform, package),
|
||||
);
|
||||
|
||||
if let Some(registry) = registry {
|
||||
command.push(' ');
|
||||
command.push_str("--registry=");
|
||||
command.push_str(&shell_command_escape_for(platform, registry));
|
||||
}
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn resolve_windows_dependency_install_plan(
|
||||
dependency: DependencyKind,
|
||||
has_winget: bool,
|
||||
) -> WindowsDependencyInstallPlan {
|
||||
core_resolve_windows_dependency_install_plan(
|
||||
match dependency {
|
||||
DependencyKind::Node => OpenClawInstallDependencyKind::Node,
|
||||
DependencyKind::Git => OpenClawInstallDependencyKind::Git,
|
||||
},
|
||||
has_winget,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn build_winget_install_command(winget_path: &str, package_id: &str) -> String {
|
||||
core_build_winget_install_command(winget_path, package_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn windows_manual_install_message(dependency: DependencyKind) -> &'static str {
|
||||
core_windows_manual_install_message(match dependency {
|
||||
DependencyKind::Node => OpenClawInstallDependencyKind::Node,
|
||||
DependencyKind::Git => OpenClawInstallDependencyKind::Git,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn prepend_path(dir: &Path) -> Option<OsString> {
|
||||
let mut paths = vec![dir.to_path_buf()];
|
||||
if let Some(current) = std::env::var_os("PATH") {
|
||||
paths.extend(std::env::split_paths(¤t));
|
||||
}
|
||||
std::env::join_paths(paths).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) {
|
||||
apply_windows_no_window(command);
|
||||
|
||||
let Some(bin_dir) = Path::new(binary_path).parent() else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = prepend_path(bin_dir) {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_windows_no_window(_command: &mut Command) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
_command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> {
|
||||
unsafe {
|
||||
let mut combined_path = String::new();
|
||||
|
||||
// 读取系统 PATH (HKEY_LOCAL_MACHINE)
|
||||
if let Ok(system_path) = read_registry_path(HKEY_LOCAL_MACHINE) {
|
||||
combined_path.push_str(&system_path);
|
||||
}
|
||||
|
||||
// 读取用户 PATH (HKEY_CURRENT_USER)
|
||||
if let Ok(user_path) = read_registry_path(HKEY_CURRENT_USER) {
|
||||
if !combined_path.is_empty() {
|
||||
combined_path.push(';');
|
||||
}
|
||||
combined_path.push_str(&user_path);
|
||||
}
|
||||
|
||||
if !combined_path.is_empty() {
|
||||
std::env::set_var("PATH", combined_path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) unsafe fn read_registry_path(root_key: HKEY) -> Result<String, String> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
|
||||
let subkey: Vec<u16> = OsStr::new("Environment")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let value_name: Vec<u16> = OsStr::new("Path")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let mut key: HKEY = ptr::null_mut();
|
||||
let result = RegOpenKeyExW(
|
||||
root_key,
|
||||
subkey.as_ptr(),
|
||||
0,
|
||||
winapi::um::winnt::KEY_READ,
|
||||
&mut key,
|
||||
);
|
||||
|
||||
if result != ERROR_SUCCESS as i32 {
|
||||
return Err(format!("无法打开注册表键: {}", result));
|
||||
}
|
||||
|
||||
let mut buffer_size: DWORD = 0;
|
||||
let result = RegQueryValueExW(
|
||||
key,
|
||||
value_name.as_ptr(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
&mut buffer_size,
|
||||
);
|
||||
|
||||
if result != ERROR_SUCCESS as i32 {
|
||||
winapi::um::winreg::RegCloseKey(key);
|
||||
return Err(format!("无法查询注册表值大小: {}", result));
|
||||
}
|
||||
|
||||
let mut buffer: Vec<u16> = vec![0; (buffer_size / 2) as usize + 1];
|
||||
let result = RegQueryValueExW(
|
||||
key,
|
||||
value_name.as_ptr(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
buffer.as_mut_ptr() as *mut u8,
|
||||
&mut buffer_size,
|
||||
);
|
||||
|
||||
winapi::um::winreg::RegCloseKey(key);
|
||||
|
||||
if result != ERROR_SUCCESS as i32 {
|
||||
return Err(format!("无法读取注册表值: {}", result));
|
||||
}
|
||||
|
||||
// 移除尾部的 null 字符
|
||||
if let Some(null_pos) = buffer.iter().position(|&c| c == 0) {
|
||||
buffer.truncate(null_pos);
|
||||
}
|
||||
|
||||
Ok(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn get_command_preview(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
operation: &str,
|
||||
port: Option<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
match operation {
|
||||
"install" => self.build_install_command_preview(app).await,
|
||||
"uninstall" => self.build_uninstall_command_preview().await,
|
||||
"restart" => self.build_restart_command_preview(port).await,
|
||||
"start" => self.build_start_command_preview(port).await,
|
||||
"stop" => self.build_stop_command_preview(port).await,
|
||||
_ => Err(format!("不支持的 OpenClaw 操作预览: {operation}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_install_command_preview(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
) -> Result<CommandPreview, String> {
|
||||
let (package, npm_path, npm_prefix, cleanup_command, install_command) =
|
||||
self.resolve_install_commands(app).await?;
|
||||
let prefix_note = npm_prefix
|
||||
.map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n"))
|
||||
.unwrap_or_else(|| format!("npm: {npm_path}\n"));
|
||||
Ok(CommandPreview {
|
||||
title: format!("安装 {package}"),
|
||||
command: format!("{prefix_note}{cleanup_command}\n{install_command}"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_uninstall_command_preview(&self) -> Result<CommandPreview, String> {
|
||||
let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?;
|
||||
let prefix_note = npm_prefix
|
||||
.map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n"))
|
||||
.unwrap_or_else(|| format!("npm: {npm_path}\n"));
|
||||
Ok(CommandPreview {
|
||||
title: "卸载 OpenClaw".to_string(),
|
||||
command: format!("{prefix_note}{command}"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_start_command_preview(
|
||||
&mut self,
|
||||
port: Option<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
if let Some(next_port) = port {
|
||||
self.gateway_port = next_port.max(1);
|
||||
}
|
||||
self.restore_auth_token_from_config();
|
||||
if self.gateway_auth_token.is_empty() {
|
||||
self.gateway_auth_token = generate_auth_token();
|
||||
}
|
||||
let openclaw_command = resolve_openclaw_command()
|
||||
.await?
|
||||
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
|
||||
let config_path = openclaw_lime_config_path();
|
||||
let command = gateway_start_args(self.gateway_port, &self.gateway_auth_token)
|
||||
.into_iter()
|
||||
.map(|arg| shell_escape(&arg))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Ok(CommandPreview {
|
||||
title: "启动 Gateway".to_string(),
|
||||
command: format!(
|
||||
"{}OPENCLAW_CONFIG_PATH={} {} {}",
|
||||
if cfg!(target_os = "windows") {
|
||||
"set "
|
||||
} else {
|
||||
""
|
||||
},
|
||||
shell_escape(config_path.to_string_lossy().as_ref()),
|
||||
openclaw_command.preview_invocation(),
|
||||
command
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_stop_command_preview(
|
||||
&mut self,
|
||||
port: Option<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
if let Some(next_port) = port {
|
||||
self.gateway_port = next_port.max(1);
|
||||
}
|
||||
self.restore_auth_token_from_config();
|
||||
let openclaw_command = resolve_openclaw_command()
|
||||
.await?
|
||||
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
|
||||
let config_path = openclaw_lime_config_path();
|
||||
Ok(CommandPreview {
|
||||
title: "停止 Gateway".to_string(),
|
||||
command: format!(
|
||||
"OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}",
|
||||
shell_escape(config_path.to_string_lossy().as_ref()),
|
||||
openclaw_command.preview_invocation(),
|
||||
self.gateway_ws_url(),
|
||||
shell_escape(&self.gateway_auth_token)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_restart_command_preview(
|
||||
&mut self,
|
||||
port: Option<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
let stop = self.build_stop_command_preview(port).await?;
|
||||
let start = self.build_start_command_preview(port).await?;
|
||||
Ok(CommandPreview {
|
||||
title: "重启 Gateway".to_string(),
|
||||
command: format!("{}\n{}", stop.command, start.command),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn command_output_lines(output: &[u8]) -> Vec<String> {
|
||||
String::from_utf8_lossy(output)
|
||||
.lines()
|
||||
.map(sanitize_progress_line)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_process_probe_text(value: &str) -> String {
|
||||
value.replace('\\', "/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
pub(crate) fn process_looks_like_openclaw_process(
|
||||
process_name: &str,
|
||||
exe_path: Option<&Path>,
|
||||
command_args: &[OsString],
|
||||
) -> bool {
|
||||
let process_name = normalize_process_probe_text(process_name);
|
||||
let exe_path = exe_path
|
||||
.map(|path| normalize_process_probe_text(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
let command_line = normalize_process_probe_text(
|
||||
&command_args
|
||||
.iter()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
);
|
||||
|
||||
process_name.contains("openclaw")
|
||||
|| exe_path.contains("openclaw")
|
||||
|| command_line.contains("openclaw")
|
||||
}
|
||||
|
||||
pub(crate) fn collect_openclaw_process_family_pids(
|
||||
system: &System,
|
||||
listener_pids: &[u32],
|
||||
) -> Vec<Pid> {
|
||||
let mut target_pids = HashSet::new();
|
||||
|
||||
for listener_pid in listener_pids {
|
||||
let pid = Pid::from_u32(*listener_pid);
|
||||
let Some(process) = system.process(pid) else {
|
||||
continue;
|
||||
};
|
||||
if !process_looks_like_openclaw_process(
|
||||
&process.name().to_string_lossy(),
|
||||
process.exe(),
|
||||
process.cmd(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target_pids.insert(pid);
|
||||
let mut parent_pid = process.parent();
|
||||
while let Some(next_parent) = parent_pid {
|
||||
let Some(parent_process) = system.process(next_parent) else {
|
||||
break;
|
||||
};
|
||||
if !process_looks_like_openclaw_process(
|
||||
&parent_process.name().to_string_lossy(),
|
||||
parent_process.exe(),
|
||||
parent_process.cmd(),
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
target_pids.insert(next_parent);
|
||||
parent_pid = parent_process.parent();
|
||||
}
|
||||
}
|
||||
|
||||
let mut target_pids = target_pids.into_iter().collect::<Vec<_>>();
|
||||
target_pids.sort_by_key(|pid| pid.as_u32());
|
||||
target_pids
|
||||
}
|
||||
|
||||
pub(crate) async fn terminate_sysinfo_processes(system: &mut System, target_pids: &[Pid]) {
|
||||
for pid in target_pids {
|
||||
if let Some(process) = system.process(*pid) {
|
||||
let terminated = process.kill_with(Signal::Term).unwrap_or(false);
|
||||
if !terminated {
|
||||
let _ = process.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(900)).await;
|
||||
system.refresh_all();
|
||||
|
||||
for pid in target_pids {
|
||||
if let Some(process) = system.process(*pid) {
|
||||
let _ = process.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
pub(crate) fn parse_lsof_listener_pids(output: &str) -> Vec<u32> {
|
||||
let mut pids = output
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse::<u32>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
pids.sort_unstable();
|
||||
pids.dedup();
|
||||
pids
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn parse_windows_netstat_listener_pids(output: &str, port: u16) -> Vec<u32> {
|
||||
let mut pids = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let columns = line.split_whitespace().collect::<Vec<_>>();
|
||||
if columns.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let local_address = columns.get(1).copied().unwrap_or_default();
|
||||
let state = columns.get(3).copied().unwrap_or_default();
|
||||
if !state.eq_ignore_ascii_case("LISTENING")
|
||||
|| !local_address.ends_with(&format!(":{port}"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
columns.last().and_then(|value| value.parse::<u32>().ok())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
pids.sort_unstable();
|
||||
pids.dedup();
|
||||
pids
|
||||
}
|
||||
|
||||
pub(crate) async fn collect_listening_port_pids(port: u16) -> Vec<u32> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
collect_listening_port_pids_windows(port).await
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
collect_listening_port_pids_unix(port).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) async fn collect_listening_port_pids_unix(port: u16) -> Vec<u32> {
|
||||
let mut command = Command::new("lsof");
|
||||
apply_windows_no_window(&mut command);
|
||||
let output = timeout(
|
||||
Duration::from_secs(3),
|
||||
command
|
||||
.arg("-nP")
|
||||
.arg(format!("-iTCP:{port}"))
|
||||
.arg("-sTCP:LISTEN")
|
||||
.arg("-t")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(Ok(result)) if result.status.success() => {
|
||||
parse_lsof_listener_pids(&String::from_utf8_lossy(&result.stdout))
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) async fn collect_listening_port_pids_windows(port: u16) -> Vec<u32> {
|
||||
let mut command = Command::new("netstat");
|
||||
apply_windows_no_window(&mut command);
|
||||
let output = timeout(
|
||||
Duration::from_secs(3),
|
||||
command
|
||||
.arg("-ano")
|
||||
.arg("-p")
|
||||
.arg("tcp")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(Ok(result)) if result.status.success() => {
|
||||
parse_windows_netstat_listener_pids(&String::from_utf8_lossy(&result.stdout), port)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) async fn run_shell_command_with_progress(
|
||||
app: &AppHandle,
|
||||
command_line: &str,
|
||||
) -> Result<ActionResult, String> {
|
||||
let mut child = spawn_shell_command(command_line)?;
|
||||
|
||||
let stdout_task = child.stdout.take().map(|stdout| {
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
stream_reader_to_progress(app, stdout, "info").await;
|
||||
})
|
||||
});
|
||||
|
||||
let stderr_task = child.stderr.take().map(|stderr| {
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
stream_reader_to_progress(app, stderr, "error").await;
|
||||
})
|
||||
});
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| format!("执行命令失败: {e}"))?;
|
||||
|
||||
if let Some(task) = stdout_task {
|
||||
let _ = task.await;
|
||||
}
|
||||
if let Some(task) = stderr_task {
|
||||
let _ = task.await;
|
||||
}
|
||||
|
||||
if status.success() {
|
||||
emit_install_progress(app, "命令执行成功。", "info");
|
||||
Ok(ActionResult {
|
||||
success: true,
|
||||
message: "操作成功完成。".to_string(),
|
||||
})
|
||||
} else {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("命令执行失败,退出码: {:?}", status.code()),
|
||||
"error",
|
||||
);
|
||||
Ok(ActionResult {
|
||||
success: false,
|
||||
message: format!("命令执行失败,退出码: {:?}", status.code()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_shell_command(command_line: &str) -> Result<Child, String> {
|
||||
let mut command = if cfg!(target_os = "windows") {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.arg("/C").arg(command_line);
|
||||
cmd
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
let mut cmd = Command::new("script");
|
||||
cmd.arg("-q")
|
||||
.arg("/dev/null")
|
||||
.arg(shell)
|
||||
.arg("-lc")
|
||||
.arg(command_line);
|
||||
cmd
|
||||
} else {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.arg("-lc").arg(command_line);
|
||||
cmd
|
||||
};
|
||||
|
||||
apply_windows_no_window(&mut command);
|
||||
|
||||
command
|
||||
.env("NO_COLOR", "1")
|
||||
.env("CLICOLOR", "0")
|
||||
.env("FORCE_COLOR", "0")
|
||||
.env("npm_config_color", "false")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command.spawn().map_err(|e| format!("启动命令失败: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) async fn stream_reader_to_progress<R>(
|
||||
app: AppHandle,
|
||||
mut reader: R,
|
||||
default_level: &'static str,
|
||||
) where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut buffer = [0_u8; 2048];
|
||||
let mut pending = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read(&mut buffer).await {
|
||||
Ok(0) => break,
|
||||
Ok(size) => {
|
||||
pending.push_str(&String::from_utf8_lossy(&buffer[..size]));
|
||||
flush_progress_chunks(&app, &mut pending, default_level);
|
||||
}
|
||||
Err(error) => {
|
||||
emit_install_progress(&app, &format!("读取命令输出失败: {error}"), "warn");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tail = pending.trim();
|
||||
if !tail.is_empty() {
|
||||
emit_install_progress(&app, tail, classify_progress_level(tail, default_level));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn flush_progress_chunks(
|
||||
app: &AppHandle,
|
||||
pending: &mut String,
|
||||
default_level: &'static str,
|
||||
) {
|
||||
loop {
|
||||
let next_break = pending.find(['\n', '\r']);
|
||||
let Some(index) = next_break else {
|
||||
break;
|
||||
};
|
||||
|
||||
let mut line = pending[..index].trim().to_string();
|
||||
let mut consume_len = index + 1;
|
||||
while pending
|
||||
.get(consume_len..consume_len + 1)
|
||||
.is_some_and(|ch| ch == "\n" || ch == "\r")
|
||||
{
|
||||
consume_len += 1;
|
||||
}
|
||||
|
||||
pending.drain(..consume_len);
|
||||
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
line = sanitize_progress_line(&line);
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
emit_install_progress(app, &line, classify_progress_level(&line, default_level));
|
||||
}
|
||||
|
||||
if pending.len() > 4096 {
|
||||
let line = sanitize_progress_line(pending.trim());
|
||||
if !line.is_empty() {
|
||||
emit_install_progress(app, &line, classify_progress_level(&line, default_level));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_progress_line(value: &str) -> String {
|
||||
value
|
||||
.replace('\u{1b}', "")
|
||||
.replace("[?25h", "")
|
||||
.replace("[?25l", "")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn classify_progress_level(message: &str, default_level: &'static str) -> &'static str {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("error") || lower.contains("fatal") {
|
||||
"error"
|
||||
} else if lower.contains("warn") || lower.contains("warning") {
|
||||
"warn"
|
||||
} else {
|
||||
default_level
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit_install_progress(app: &AppHandle, message: &str, level: &str) {
|
||||
if let Some(service_state) = app.try_state::<OpenClawServiceState>() {
|
||||
if let Ok(mut service) = service_state.0.try_lock() {
|
||||
service.push_progress_log(message.to_string(), level.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let payload = InstallProgressEvent {
|
||||
message: message.to_string(),
|
||||
level: level.to_string(),
|
||||
};
|
||||
let _ = app.emit(OPENCLAW_INSTALL_EVENT, payload);
|
||||
}
|
||||
|
||||
pub(crate) fn parse_semver(value: &str) -> Option<(u64, u64, u64)> {
|
||||
let sanitized = value.trim().trim_start_matches('v');
|
||||
let core = sanitized.split(['-', '+']).next()?;
|
||||
let mut parts = core.split('.');
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next().unwrap_or("0").parse().ok()?;
|
||||
let patch = parts.next().unwrap_or("0").parse().ok()?;
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_semver_from_text(value: &str) -> Option<(u64, u64, u64)> {
|
||||
parse_semver(value).or_else(|| {
|
||||
value
|
||||
.split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '(' || ch == ')')
|
||||
.find_map(parse_semver)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn format_semver(version: (u64, u64, u64)) -> String {
|
||||
format!("{}.{}.{}", version.0, version.1, version.2)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BinaryInstallStatus {
|
||||
pub installed: bool,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BinaryAvailabilityStatus {
|
||||
pub available: bool,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeCheckResult {
|
||||
pub status: String,
|
||||
pub version: Option<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyStatus {
|
||||
pub status: String,
|
||||
pub version: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub message: String,
|
||||
pub auto_install_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentStatus {
|
||||
pub node: DependencyStatus,
|
||||
pub git: DependencyStatus,
|
||||
pub openclaw: DependencyStatus,
|
||||
pub recommended_action: String,
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub diagnostics: EnvironmentDiagnostics,
|
||||
#[serde(default)]
|
||||
pub temp_artifacts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentDiagnostics {
|
||||
pub npm_path: Option<String>,
|
||||
pub npm_global_prefix: Option<String>,
|
||||
pub openclaw_package_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub where_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub supplemental_search_dirs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub supplemental_command_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_where_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_supplemental_search_dirs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_supplemental_command_candidates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandPreview {
|
||||
pub title: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GatewayStatusInfo {
|
||||
pub status: GatewayStatus,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GatewayStatus {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealthInfo {
|
||||
pub status: String,
|
||||
pub gateway_port: u16,
|
||||
pub uptime: Option<u64>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub has_update: bool,
|
||||
pub current_version: Option<String>,
|
||||
pub latest_version: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub install_kind: Option<String>,
|
||||
pub package_manager: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenClawRuntimeCandidate {
|
||||
pub id: String,
|
||||
pub source: String,
|
||||
pub bin_dir: String,
|
||||
pub node_path: String,
|
||||
pub node_version: Option<String>,
|
||||
pub npm_path: Option<String>,
|
||||
pub npm_global_prefix: Option<String>,
|
||||
pub openclaw_path: Option<String>,
|
||||
pub openclaw_version: Option<String>,
|
||||
pub openclaw_package_path: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub is_preferred: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct OpenClawUpdateExecutionContext {
|
||||
pub(crate) root: Option<PathBuf>,
|
||||
pub(crate) install_kind: Option<String>,
|
||||
pub(crate) package_manager: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OpenClawDirectUpgradePlan {
|
||||
pub(crate) runtime_source: String,
|
||||
pub(crate) runtime_bin_dir: PathBuf,
|
||||
pub(crate) package_manager: String,
|
||||
pub(crate) package_spec: String,
|
||||
pub(crate) command_line: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OpenClawDirectUpgradeResult {
|
||||
pub(crate) runtime_source: String,
|
||||
pub(crate) runtime_bin_dir: PathBuf,
|
||||
pub(crate) package_manager: String,
|
||||
pub(crate) package_spec: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ResolvedOpenClawCommand {
|
||||
Binary {
|
||||
binary_path: PathBuf,
|
||||
},
|
||||
NodeCli {
|
||||
node_path: PathBuf,
|
||||
cli_path: PathBuf,
|
||||
package_version: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResolvedOpenClawCommand {
|
||||
pub(crate) fn build_command_with_args<I, S>(&self, args: I) -> Command
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let command_path = self.command_path();
|
||||
let command_path_string = command_path.to_string_lossy().to_string();
|
||||
let mut command = Command::new(command_path);
|
||||
apply_binary_runtime_path(&mut command, &command_path_string);
|
||||
|
||||
if let Self::NodeCli { cli_path, .. } = self {
|
||||
command.arg(cli_path);
|
||||
}
|
||||
|
||||
command.args(args);
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
pub(crate) fn command_path(&self) -> &Path {
|
||||
match self {
|
||||
Self::Binary { binary_path } => binary_path.as_path(),
|
||||
Self::NodeCli { node_path, .. } => node_path.as_path(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn install_path_display(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => binary_path.display().to_string(),
|
||||
Self::NodeCli { cli_path, .. } => cli_path.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn invocation_display(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => binary_path.display().to_string(),
|
||||
Self::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
..
|
||||
} => {
|
||||
format!("{} {}", node_path.display(), cli_path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn preview_invocation(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()),
|
||||
Self::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
..
|
||||
} => format!(
|
||||
"{} {}",
|
||||
shell_escape(node_path.to_string_lossy().as_ref()),
|
||||
shell_escape(cli_path.to_string_lossy().as_ref())
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fallback_version(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Binary { .. } => None,
|
||||
Self::NodeCli {
|
||||
package_version, ..
|
||||
} => package_version.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dedupe_key(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => format!("binary:{}", binary_path.display()),
|
||||
Self::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
..
|
||||
} => format!("node:{}:{}", node_path.display(), cli_path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub channel_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallProgressEvent {
|
||||
pub message: String,
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncModelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub context_window: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DependencyKind {
|
||||
Node,
|
||||
Git,
|
||||
}
|
||||
|
||||
impl DependencyKind {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Node => "Node.js",
|
||||
Self::Git => "Git",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InstallerAsset {
|
||||
pub(crate) filename: String,
|
||||
pub(crate) download_url: String,
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn check_update(&self) -> Result<UpdateInfo, String> {
|
||||
let Some(openclaw_command) = resolve_openclaw_command().await? else {
|
||||
return Ok(UpdateInfo {
|
||||
has_update: false,
|
||||
current_version: None,
|
||||
latest_version: None,
|
||||
channel: None,
|
||||
install_kind: None,
|
||||
package_manager: None,
|
||||
message: Some("未检测到 OpenClaw 可执行文件,请先安装。".to_string()),
|
||||
});
|
||||
};
|
||||
|
||||
let current_version = self
|
||||
.read_openclaw_version()
|
||||
.await?
|
||||
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)));
|
||||
|
||||
let payload = match read_openclaw_update_status_payload(&openclaw_command).await {
|
||||
Ok(payload) => payload,
|
||||
Err(message) => {
|
||||
return Ok(UpdateInfo {
|
||||
has_update: false,
|
||||
current_version,
|
||||
latest_version: None,
|
||||
channel: None,
|
||||
install_kind: None,
|
||||
package_manager: None,
|
||||
message: Some(message),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(UpdateInfo {
|
||||
has_update: payload
|
||||
.pointer("/availability/available")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
current_version,
|
||||
latest_version: payload
|
||||
.pointer("/availability/latestVersion")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
channel: payload
|
||||
.pointer("/channel/label")
|
||||
.or_else(|| payload.pointer("/channel/value"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
install_kind: payload
|
||||
.pointer("/update/installKind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
package_manager: payload
|
||||
.pointer("/update/packageManager")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
message: payload
|
||||
.pointer("/update/registry/error")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn perform_update(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
emit_install_progress(app, "开始执行 OpenClaw 升级。", "info");
|
||||
|
||||
let Some(openclaw_command) = resolve_openclaw_command().await? else {
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(),
|
||||
});
|
||||
};
|
||||
let current_runtime_bin_dir = openclaw_command
|
||||
.command_path()
|
||||
.parent()
|
||||
.map(Path::to_path_buf);
|
||||
|
||||
self.refresh_process_state().await?;
|
||||
let gateway_was_running = self.gateway_status == GatewayStatus::Running;
|
||||
if self.gateway_status == GatewayStatus::Running {
|
||||
emit_install_progress(
|
||||
app,
|
||||
"升级前先停止 Gateway,避免占用正在运行的 OpenClaw。",
|
||||
"info",
|
||||
);
|
||||
let stop_result = self.stop_gateway(Some(app)).await?;
|
||||
if !stop_result.success {
|
||||
return Ok(stop_result);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(current_version) = self
|
||||
.read_openclaw_version()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)))
|
||||
{
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("当前版本 {current_version},开始执行升级命令。"),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
let update_status_payload =
|
||||
match read_openclaw_update_status_payload(&openclaw_command).await {
|
||||
Ok(payload) => payload,
|
||||
Err(message) => {
|
||||
emit_install_progress(app, &message, "warn");
|
||||
match attempt_direct_openclaw_package_upgrade(
|
||||
app,
|
||||
current_runtime_bin_dir.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"已自动切换后续执行环境到 {}。",
|
||||
result.runtime_bin_dir.display()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return self
|
||||
.finalize_successful_openclaw_update(
|
||||
app,
|
||||
gateway_was_running,
|
||||
Some(format!(
|
||||
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
|
||||
result.runtime_source,
|
||||
result.package_manager,
|
||||
result.package_spec
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(fallback_error) => {
|
||||
emit_install_progress(app, &fallback_error, "error");
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let update_context = extract_openclaw_update_execution_context(&update_status_payload);
|
||||
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("已切换到 OpenClaw 安装根目录执行升级:{}", root.display()),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
if let Some(install_kind) = update_context.install_kind.as_deref() {
|
||||
let package_manager = update_context
|
||||
.package_manager
|
||||
.as_deref()
|
||||
.unwrap_or("默认包管理器");
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("检测到安装方式:{install_kind}(包管理器:{package_manager})。"),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]);
|
||||
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
|
||||
command.current_dir(root);
|
||||
}
|
||||
let output = command
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 OpenClaw 升级失败: {e}"))?;
|
||||
|
||||
let stdout_lines = command_output_lines(&output.stdout);
|
||||
for line in &stdout_lines {
|
||||
emit_install_progress(app, &line, classify_progress_level(&line, "info"));
|
||||
}
|
||||
let stderr_lines = command_output_lines(&output.stderr);
|
||||
for line in &stderr_lines {
|
||||
emit_install_progress(app, line, classify_progress_level(line, "warn"));
|
||||
}
|
||||
let stdout_payload = serde_json::from_slice::<Value>(&output.stdout).ok();
|
||||
|
||||
if !output.status.success() {
|
||||
let failure_detail = select_openclaw_update_failure_detail(
|
||||
stdout_payload.as_ref(),
|
||||
&stderr_lines,
|
||||
&stdout_lines,
|
||||
);
|
||||
let message = format_openclaw_update_failure_message(failure_detail.as_deref());
|
||||
emit_install_progress(app, &message, "warn");
|
||||
|
||||
match attempt_direct_openclaw_package_upgrade(
|
||||
app,
|
||||
current_runtime_bin_dir.as_deref(),
|
||||
update_context.root.as_deref(),
|
||||
update_context.package_manager.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"已自动切换后续执行环境到 {}。",
|
||||
result.runtime_bin_dir.display()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return self
|
||||
.finalize_successful_openclaw_update(
|
||||
app,
|
||||
gateway_was_running,
|
||||
Some(format!(
|
||||
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
|
||||
result.runtime_source, result.package_manager, result.package_spec
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(fallback_error) => {
|
||||
emit_install_progress(app, &fallback_error, "error");
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.finalize_successful_openclaw_update(app, gateway_was_running, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn finalize_successful_openclaw_update(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
gateway_was_running: bool,
|
||||
success_message_override: Option<String>,
|
||||
) -> Result<ActionResult, String> {
|
||||
self.refresh_process_state().await?;
|
||||
let updated_version = self
|
||||
.read_openclaw_version()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)));
|
||||
|
||||
if gateway_was_running {
|
||||
emit_install_progress(app, "升级前 Gateway 处于运行态,开始自动恢复服务。", "info");
|
||||
let restart_result = self
|
||||
.start_gateway(Some(app), Some(self.gateway_port))
|
||||
.await?;
|
||||
if !restart_result.success {
|
||||
return Ok(restart_result);
|
||||
}
|
||||
}
|
||||
|
||||
let message = success_message_override.unwrap_or_else(|| {
|
||||
updated_version
|
||||
.as_ref()
|
||||
.map(|version| format!("OpenClaw 已升级完成,当前版本 {version}。"))
|
||||
.unwrap_or_else(|| "OpenClaw 已升级完成。".to_string())
|
||||
});
|
||||
emit_install_progress(app, &message, "info");
|
||||
Ok(ActionResult {
|
||||
success: true,
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_openclaw_release_version(value: &str) -> Option<String> {
|
||||
static VERSION_RE: OnceLock<Regex> = OnceLock::new();
|
||||
VERSION_RE
|
||||
.get_or_init(|| Regex::new(r"(?i)openclaw\s+([0-9]+(?:\.[0-9]+)+)").expect("valid regex"))
|
||||
.captures(value)
|
||||
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn read_openclaw_update_status_payload(
|
||||
command_spec: &ResolvedOpenClawCommand,
|
||||
) -> Result<Value, String> {
|
||||
let mut command = command_spec.build_command_with_args(["update", "status", "--json"]);
|
||||
let output = command
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("检查 OpenClaw 更新失败: {e}"))?;
|
||||
|
||||
let stdout_lines = command_output_lines(&output.stdout);
|
||||
let stderr_lines = command_output_lines(&output.stderr);
|
||||
let payload = serde_json::from_slice::<Value>(&output.stdout).map_err(|error| {
|
||||
let detail = select_openclaw_update_failure_detail(None, &stderr_lines, &stdout_lines)
|
||||
.unwrap_or_else(|| format!("解析更新状态失败: {error}"));
|
||||
format_openclaw_update_failure_message(Some(detail.as_str()))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let detail =
|
||||
select_openclaw_update_failure_detail(Some(&payload), &stderr_lines, &stdout_lines);
|
||||
return Err(format_openclaw_update_failure_message(detail.as_deref()));
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_openclaw_update_execution_context(
|
||||
payload: &Value,
|
||||
) -> OpenClawUpdateExecutionContext {
|
||||
OpenClawUpdateExecutionContext {
|
||||
root: payload
|
||||
.pointer("/update/root")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from),
|
||||
install_kind: payload
|
||||
.pointer("/update/installKind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
package_manager: payload
|
||||
.pointer("/update/packageManager")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_openclaw_update_failure_detail(
|
||||
payload: Option<&Value>,
|
||||
stderr_lines: &[String],
|
||||
stdout_lines: &[String],
|
||||
) -> Option<String> {
|
||||
if let Some(payload) = payload {
|
||||
if let Some(reason) = payload.get("reason").and_then(Value::as_str) {
|
||||
let root_suffix = payload
|
||||
.get("root")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|root| format!(" ({root})"))
|
||||
.unwrap_or_default();
|
||||
return Some(format!("{reason}{root_suffix}"));
|
||||
}
|
||||
|
||||
if let Some(message) = payload.get("message").and_then(Value::as_str) {
|
||||
let trimmed = message.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(message) = payload
|
||||
.pointer("/update/registry/error")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(message.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
stderr_lines
|
||||
.iter()
|
||||
.chain(stdout_lines.iter())
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
let score = openclaw_update_failure_line_score(trimmed);
|
||||
(score > 0).then_some((score, trimmed))
|
||||
})
|
||||
.max_by_key(|(score, _)| *score)
|
||||
.map(|(_, line)| line.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn openclaw_update_failure_line_score(line: &str) -> u8 {
|
||||
if line.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let normalized = line.to_ascii_lowercase();
|
||||
if normalized.starts_with("updating openclaw")
|
||||
|| normalized.starts_with("update result")
|
||||
|| normalized.starts_with("total time")
|
||||
|| normalized.starts_with("root:")
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if normalized.contains("not-openclaw-root") {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if normalized.contains("node.js") && normalized.contains("required") {
|
||||
return 95;
|
||||
}
|
||||
|
||||
if normalized.contains("fetch failed") || normalized.contains("network") {
|
||||
return 90;
|
||||
}
|
||||
|
||||
if normalized.contains("pnpm") && normalized.contains("not found")
|
||||
|| normalized.contains("npm") && normalized.contains("not found")
|
||||
{
|
||||
return 85;
|
||||
}
|
||||
|
||||
if normalized.contains("already up to date") || normalized.contains("not modified") {
|
||||
return 80;
|
||||
}
|
||||
|
||||
if normalized.starts_with("reason:") {
|
||||
return 70;
|
||||
}
|
||||
|
||||
20
|
||||
}
|
||||
|
||||
pub(crate) fn format_openclaw_update_failure_message(detail: Option<&str>) -> String {
|
||||
let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return "OpenClaw 升级失败,请查看日志输出。".to_string();
|
||||
};
|
||||
|
||||
let normalized = detail.to_ascii_lowercase();
|
||||
if normalized.contains("not-openclaw-root") {
|
||||
return "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。"
|
||||
.to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("node.js") && normalized.contains("required") {
|
||||
return format!(
|
||||
"OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 {}+。请切换到满足要求的 Node.js 后重试。",
|
||||
format_semver(NODE_MIN_VERSION)
|
||||
);
|
||||
}
|
||||
|
||||
if normalized.contains("fetch failed") || normalized.contains("network") {
|
||||
return "OpenClaw 升级失败:当前无法访问更新源,请检查网络或代理设置后重试。".to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("not modified") || normalized.contains("already up to date") {
|
||||
return "OpenClaw 当前已经是最新版本,无需升级。".to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("pnpm") && normalized.contains("not found") {
|
||||
return "OpenClaw 升级失败:当前安装方式依赖 pnpm,但系统未找到 pnpm。请先修复 Node.js / pnpm 环境后重试。"
|
||||
.to_string();
|
||||
}
|
||||
|
||||
if normalized.contains("npm") && normalized.contains("not found") {
|
||||
return "OpenClaw 升级失败:当前安装方式依赖 npm,但系统未找到 npm。请先修复 Node.js / npm 环境后重试。"
|
||||
.to_string();
|
||||
}
|
||||
|
||||
format!("OpenClaw 升级失败:{detail}")
|
||||
}
|
||||
|
||||
pub(crate) async fn attempt_direct_openclaw_package_upgrade(
|
||||
app: &AppHandle,
|
||||
runtime_bin_dir_hint: Option<&Path>,
|
||||
install_root_hint: Option<&Path>,
|
||||
package_manager_hint: Option<&str>,
|
||||
) -> Result<OpenClawDirectUpgradeResult, String> {
|
||||
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
|
||||
runtime_candidates.sort_by(|left, right| {
|
||||
let left_matches_root = install_root_hint
|
||||
.map(|root| runtime_candidate_matches_install_root(left, root))
|
||||
.unwrap_or(false);
|
||||
let right_matches_root = install_root_hint
|
||||
.map(|root| runtime_candidate_matches_install_root(right, root))
|
||||
.unwrap_or(false);
|
||||
let left_matches_bin = runtime_bin_dir_hint
|
||||
.map(|hint| Path::new(&left.bin_dir) == hint)
|
||||
.unwrap_or(false);
|
||||
let right_matches_bin = runtime_bin_dir_hint
|
||||
.map(|hint| Path::new(&right.bin_dir) == hint)
|
||||
.unwrap_or(false);
|
||||
|
||||
right_matches_root
|
||||
.cmp(&left_matches_root)
|
||||
.then_with(|| right_matches_bin.cmp(&left_matches_bin))
|
||||
.then_with(|| compare_openclaw_runtime_candidates(left, right))
|
||||
});
|
||||
let mut last_error = None;
|
||||
let mut attempted = 0usize;
|
||||
|
||||
if let Some(install_root_hint) = install_root_hint {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"官方 updater 报告的安装根目录为 {},将优先匹配该安装来源执行兜底升级。",
|
||||
install_root_hint.display()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
for candidate in runtime_candidates {
|
||||
let Some(plan) = resolve_direct_openclaw_upgrade_plan(
|
||||
app,
|
||||
&candidate,
|
||||
runtime_bin_dir_hint,
|
||||
install_root_hint,
|
||||
package_manager_hint,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
attempted += 1;
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"官方自更新未能完成,开始尝试全局包升级兜底:{} · {}。",
|
||||
plan.runtime_source, plan.package_manager
|
||||
),
|
||||
"warn",
|
||||
);
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!("目标执行环境:{}", plan.runtime_bin_dir.display()),
|
||||
"info",
|
||||
);
|
||||
emit_install_progress(app, &format!("升级包:{}", plan.package_spec), "info");
|
||||
|
||||
let result = run_shell_command_with_progress(app, &plan.command_line).await?;
|
||||
if result.success {
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"已通过 {} 的 {} 全局安装方式完成兜底升级。",
|
||||
plan.runtime_source, plan.package_manager
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return Ok(OpenClawDirectUpgradeResult {
|
||||
runtime_source: plan.runtime_source,
|
||||
runtime_bin_dir: plan.runtime_bin_dir,
|
||||
package_manager: plan.package_manager,
|
||||
package_spec: plan.package_spec,
|
||||
});
|
||||
}
|
||||
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"全局包升级兜底失败:{} · {}。",
|
||||
plan.runtime_source, result.message
|
||||
),
|
||||
"warn",
|
||||
);
|
||||
last_error = Some(result.message);
|
||||
}
|
||||
|
||||
if attempted == 0 {
|
||||
return Err("未检测到可用于全局升级的 OpenClaw 安装来源。".to_string());
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| "已自动尝试全局升级兜底,但仍未成功。".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_direct_openclaw_upgrade_plan(
|
||||
app: &AppHandle,
|
||||
candidate: &OpenClawRuntimeCandidate,
|
||||
runtime_bin_dir_hint: Option<&Path>,
|
||||
install_root_hint: Option<&Path>,
|
||||
package_manager_hint: Option<&str>,
|
||||
) -> Result<Option<OpenClawDirectUpgradePlan>, String> {
|
||||
let runtime_bin_dir = PathBuf::from(&candidate.bin_dir);
|
||||
let runtime_matches_hint = runtime_bin_dir_hint
|
||||
.map(|hint| hint == runtime_bin_dir.as_path())
|
||||
.unwrap_or(false);
|
||||
let runtime_matches_install_root = install_root_hint
|
||||
.map(|root| runtime_candidate_matches_install_root(candidate, root))
|
||||
.unwrap_or(false);
|
||||
|
||||
if runtime_bin_dir_hint.is_some()
|
||||
&& !runtime_matches_hint
|
||||
&& !runtime_matches_install_root
|
||||
&& candidate.openclaw_path.is_none()
|
||||
&& candidate.openclaw_package_path.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if candidate.openclaw_path.is_none() && candidate.openclaw_package_path.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let package_spec = resolve_openclaw_upgrade_package_spec(app, candidate).await?;
|
||||
let registry = package_registry_for_package_spec(&package_spec);
|
||||
let package_manager_hint = package_manager_hint
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty());
|
||||
let npm_path = candidate
|
||||
.npm_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| find_command_in_bin_dir("npm", &runtime_bin_dir));
|
||||
let pnpm_path = find_command_in_bin_dir("pnpm", &runtime_bin_dir);
|
||||
let shell_platform = current_shell_platform();
|
||||
|
||||
let (package_manager, command_line) = match package_manager_hint.as_deref() {
|
||||
Some("pnpm") => {
|
||||
if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) {
|
||||
(
|
||||
"pnpm".to_string(),
|
||||
build_openclaw_pnpm_install_command(
|
||||
shell_platform,
|
||||
pnpm_path,
|
||||
&package_spec,
|
||||
registry,
|
||||
),
|
||||
)
|
||||
} else if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) {
|
||||
(
|
||||
"npm".to_string(),
|
||||
build_openclaw_install_command(
|
||||
shell_platform,
|
||||
npm_path,
|
||||
candidate.npm_global_prefix.as_deref(),
|
||||
&package_spec,
|
||||
registry,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) {
|
||||
(
|
||||
"npm".to_string(),
|
||||
build_openclaw_install_command(
|
||||
shell_platform,
|
||||
npm_path,
|
||||
candidate.npm_global_prefix.as_deref(),
|
||||
&package_spec,
|
||||
registry,
|
||||
),
|
||||
)
|
||||
} else if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) {
|
||||
(
|
||||
"pnpm".to_string(),
|
||||
build_openclaw_pnpm_install_command(
|
||||
shell_platform,
|
||||
pnpm_path,
|
||||
&package_spec,
|
||||
registry,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(OpenClawDirectUpgradePlan {
|
||||
runtime_source: candidate.source.clone(),
|
||||
runtime_bin_dir,
|
||||
package_manager,
|
||||
package_spec,
|
||||
command_line,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_openclaw_upgrade_package_spec(
|
||||
app: &AppHandle,
|
||||
candidate: &OpenClawRuntimeCandidate,
|
||||
) -> Result<String, String> {
|
||||
if let Some(prefix) = candidate.npm_global_prefix.as_deref() {
|
||||
if let Some(package) = find_installed_openclaw_package_details(prefix) {
|
||||
return Ok(format!("{}@latest", package.name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(package_path) = candidate.openclaw_package_path.as_deref() {
|
||||
if let Some(package_name) = infer_openclaw_package_name_from_path(Path::new(package_path)) {
|
||||
return Ok(format!("{package_name}@latest"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if should_use_china_package(app).await {
|
||||
OPENCLAW_CN_PACKAGE.to_string()
|
||||
} else {
|
||||
OPENCLAW_DEFAULT_PACKAGE.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn infer_openclaw_package_name_from_path(path: &Path) -> Option<&'static str> {
|
||||
let normalized = path
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('\\', "/")
|
||||
.to_ascii_lowercase();
|
||||
if normalized.contains("@qingchencloud/openclaw-zh") {
|
||||
return Some("@qingchencloud/openclaw-zh");
|
||||
}
|
||||
if normalized.contains("/openclaw/package.json") {
|
||||
return Some("openclaw");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn package_registry_for_package_spec(package_spec: &str) -> Option<&'static str> {
|
||||
package_spec
|
||||
.starts_with("@qingchencloud/openclaw-zh@")
|
||||
.then_some(NPM_MIRROR_CN)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_candidate_matches_install_root(
|
||||
candidate: &OpenClawRuntimeCandidate,
|
||||
install_root_hint: &Path,
|
||||
) -> bool {
|
||||
[
|
||||
Some(candidate.bin_dir.as_str()),
|
||||
Some(candidate.node_path.as_str()),
|
||||
candidate.npm_path.as_deref(),
|
||||
candidate.npm_global_prefix.as_deref(),
|
||||
candidate.openclaw_path.as_deref(),
|
||||
candidate.openclaw_package_path.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(PathBuf::from)
|
||||
.any(|path| path.starts_with(install_root_hint))
|
||||
}
|
||||
@@ -61,7 +61,10 @@ pub fn ensure_workspace_local_agents_gitignore(
|
||||
working_dir: &Path,
|
||||
) -> Result<WorkspaceGitignoreEnsureResult, String> {
|
||||
let gitignore_path = working_dir.join(".gitignore");
|
||||
ensure_gitignore_entry(&gitignore_path, ".lime/AGENTS.local.md")
|
||||
ensure_gitignore_entry(
|
||||
&gitignore_path,
|
||||
app_paths::WORKSPACE_LOCAL_RUNTIME_AGENTS_GITIGNORE_ENTRY,
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_runtime_agents_template_path(
|
||||
@@ -73,12 +76,16 @@ fn resolve_runtime_agents_template_path(
|
||||
RuntimeAgentsTemplateTarget::Workspace => {
|
||||
let working_dir =
|
||||
working_dir.ok_or_else(|| "生成 Workspace 模板时缺少 working_dir".to_string())?;
|
||||
Ok(working_dir.join(".lime").join("AGENTS.md"))
|
||||
Ok(app_paths::resolve_workspace_runtime_agents_path(
|
||||
working_dir,
|
||||
))
|
||||
}
|
||||
RuntimeAgentsTemplateTarget::WorkspaceLocal => {
|
||||
let working_dir = working_dir
|
||||
.ok_or_else(|| "生成 Workspace 本机模板时缺少 working_dir".to_string())?;
|
||||
Ok(working_dir.join(".lime").join("AGENTS.local.md"))
|
||||
Ok(app_paths::resolve_workspace_local_runtime_agents_path(
|
||||
working_dir,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,7 +259,7 @@ mod tests {
|
||||
#[test]
|
||||
fn should_create_workspace_template_file() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let path = tmp.path().join(".lime").join("AGENTS.md");
|
||||
let path = app_paths::resolve_workspace_runtime_agents_path(tmp.path());
|
||||
|
||||
let result = scaffold_runtime_agents_template_at_path(
|
||||
RuntimeAgentsTemplateTarget::Workspace,
|
||||
@@ -270,7 +277,7 @@ mod tests {
|
||||
#[test]
|
||||
fn should_not_overwrite_existing_file_by_default() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let path = tmp.path().join(".lime").join("AGENTS.md");
|
||||
let path = app_paths::resolve_workspace_runtime_agents_path(tmp.path());
|
||||
fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
|
||||
fs::write(&path, "custom content").expect("write custom");
|
||||
|
||||
@@ -289,7 +296,7 @@ mod tests {
|
||||
#[test]
|
||||
fn should_overwrite_existing_file_when_requested() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let path = tmp.path().join(".lime").join("AGENTS.local.md");
|
||||
let path = app_paths::resolve_workspace_local_runtime_agents_path(tmp.path());
|
||||
fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
|
||||
fs::write(&path, "custom content").expect("write custom");
|
||||
|
||||
@@ -317,7 +324,13 @@ mod tests {
|
||||
|
||||
assert_eq!(result.status, WorkspaceGitignoreEnsureStatus::Created);
|
||||
let content = fs::read_to_string(tmp.path().join(".gitignore")).expect("read gitignore");
|
||||
assert_eq!(content, ".lime/AGENTS.local.md\n");
|
||||
assert_eq!(
|
||||
content,
|
||||
format!(
|
||||
"{}\n",
|
||||
app_paths::WORKSPACE_LOCAL_RUNTIME_AGENTS_GITIGNORE_ENTRY
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -334,6 +347,11 @@ mod tests {
|
||||
assert_eq!(first.status, WorkspaceGitignoreEnsureStatus::Added);
|
||||
assert_eq!(second.status, WorkspaceGitignoreEnsureStatus::Exists);
|
||||
let content = fs::read_to_string(gitignore_path).expect("read gitignore");
|
||||
assert_eq!(content.matches(".lime/AGENTS.local.md").count(), 1);
|
||||
assert_eq!(
|
||||
content
|
||||
.matches(app_paths::WORKSPACE_LOCAL_RUNTIME_AGENTS_GITIGNORE_ENTRY)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
use crate::commands::aster_agent_cmd::{
|
||||
build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeIncidentView,
|
||||
AgentRuntimeOutcomeView, AgentRuntimeRequestView,
|
||||
};
|
||||
use lime_agent::SessionDetail;
|
||||
use lime_core::database::dao::agent_thread_incident::{
|
||||
AgentThreadIncidentDao, AgentThreadIncidentRecord,
|
||||
};
|
||||
use lime_core::database::dao::agent_turn_outcome::{AgentTurnOutcomeDao, AgentTurnOutcomeRecord};
|
||||
use lime_core::database::{lock_db, DbConnection};
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadReliabilityProjection {
|
||||
pub pending_requests: Vec<AgentRuntimeRequestView>,
|
||||
pub last_outcome: Option<AgentRuntimeOutcomeView>,
|
||||
pub incidents: Vec<AgentRuntimeIncidentView>,
|
||||
}
|
||||
|
||||
pub fn sync_thread_reliability_projection(
|
||||
db: &DbConnection,
|
||||
detail: &SessionDetail,
|
||||
) -> Result<ThreadReliabilityProjection, String> {
|
||||
let conn = lock_db(db)?;
|
||||
sync_thread_reliability_projection_with_conn(&conn, detail)
|
||||
}
|
||||
|
||||
fn sync_thread_reliability_projection_with_conn(
|
||||
conn: &Connection,
|
||||
detail: &SessionDetail,
|
||||
) -> Result<ThreadReliabilityProjection, String> {
|
||||
let pending_requests = build_pending_requests(detail);
|
||||
let derived_outcome = build_last_outcome(detail);
|
||||
let derived_incidents = build_incidents(detail, &pending_requests);
|
||||
|
||||
if let Some(outcome) = derived_outcome.as_ref() {
|
||||
let record = outcome_record_from_view(outcome);
|
||||
AgentTurnOutcomeDao::upsert(conn, &record)
|
||||
.map_err(|error| format!("写入 turn outcome 失败: {error}"))?;
|
||||
}
|
||||
|
||||
let persisted_outcome = match (detail.turns.last(), derived_outcome.as_ref()) {
|
||||
(Some(turn), Some(_)) => AgentTurnOutcomeDao::get_by_turn(conn, &turn.id)
|
||||
.map_err(|error| format!("读取 turn outcome 失败: {error}"))?
|
||||
.map(outcome_view_from_record),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let active_incident_ids = derived_incidents
|
||||
.iter()
|
||||
.map(|incident| incident.id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for incident in &derived_incidents {
|
||||
let record = incident_record_from_view(incident);
|
||||
AgentThreadIncidentDao::upsert_active(conn, &record)
|
||||
.map_err(|error| format!("写入 thread incident 失败: {error}"))?;
|
||||
}
|
||||
|
||||
for existing in AgentThreadIncidentDao::list_active_by_thread(conn, &detail.thread_id)
|
||||
.map_err(|error| format!("读取 active incidents 失败: {error}"))?
|
||||
{
|
||||
if active_incident_ids.contains(&existing.id) {
|
||||
continue;
|
||||
}
|
||||
AgentThreadIncidentDao::clear(conn, &existing.id, &now, &now)
|
||||
.map_err(|error| format!("清理过期 incident 失败: {error}"))?;
|
||||
}
|
||||
|
||||
let persisted_incidents =
|
||||
AgentThreadIncidentDao::list_active_by_thread(conn, &detail.thread_id)
|
||||
.map_err(|error| format!("读取同步后的 active incidents 失败: {error}"))?
|
||||
.into_iter()
|
||||
.map(incident_view_from_record)
|
||||
.collect();
|
||||
|
||||
Ok(ThreadReliabilityProjection {
|
||||
pending_requests,
|
||||
last_outcome: persisted_outcome,
|
||||
incidents: persisted_incidents,
|
||||
})
|
||||
}
|
||||
|
||||
fn outcome_record_from_view(view: &AgentRuntimeOutcomeView) -> AgentTurnOutcomeRecord {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
AgentTurnOutcomeRecord {
|
||||
turn_id: view.turn_id.clone().unwrap_or_default(),
|
||||
thread_id: view.thread_id.clone(),
|
||||
outcome_type: view.outcome_type.clone(),
|
||||
summary: view.summary.clone().unwrap_or_default(),
|
||||
primary_cause: view.primary_cause.clone(),
|
||||
retryable: view.retryable,
|
||||
details_json: None,
|
||||
ended_at: view.ended_at.clone().unwrap_or_else(|| now.clone()),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome_view_from_record(record: AgentTurnOutcomeRecord) -> AgentRuntimeOutcomeView {
|
||||
AgentRuntimeOutcomeView {
|
||||
thread_id: record.thread_id,
|
||||
turn_id: Some(record.turn_id),
|
||||
outcome_type: record.outcome_type,
|
||||
summary: Some(record.summary),
|
||||
primary_cause: record.primary_cause,
|
||||
retryable: record.retryable,
|
||||
ended_at: Some(record.ended_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn incident_record_from_view(view: &AgentRuntimeIncidentView) -> AgentThreadIncidentRecord {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
AgentThreadIncidentRecord {
|
||||
id: view.id.clone(),
|
||||
thread_id: view.thread_id.clone(),
|
||||
turn_id: view.turn_id.clone(),
|
||||
item_id: view.item_id.clone(),
|
||||
incident_type: view.incident_type.clone(),
|
||||
severity: view.severity.clone(),
|
||||
status: view.status.clone(),
|
||||
title: view.title.clone(),
|
||||
details_json: view
|
||||
.details
|
||||
.as_ref()
|
||||
.map(|value: &serde_json::Value| value.to_string()),
|
||||
detected_at: view.detected_at.clone().unwrap_or_else(|| now.clone()),
|
||||
cleared_at: view.cleared_at.clone(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn incident_view_from_record(record: AgentThreadIncidentRecord) -> AgentRuntimeIncidentView {
|
||||
AgentRuntimeIncidentView {
|
||||
id: record.id,
|
||||
thread_id: record.thread_id,
|
||||
turn_id: record.turn_id,
|
||||
item_id: record.item_id,
|
||||
incident_type: record.incident_type,
|
||||
severity: record.severity,
|
||||
status: record.status,
|
||||
title: record.title,
|
||||
details: record
|
||||
.details_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str(value).ok()),
|
||||
detected_at: Some(record.detected_at),
|
||||
cleared_at: record.cleared_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lime_core::database::dao::agent_thread_incident::AgentThreadIncidentDao;
|
||||
use lime_core::database::dao::agent_timeline::{
|
||||
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
|
||||
AgentThreadTurnStatus,
|
||||
};
|
||||
use lime_core::database::dao::agent_turn_outcome::AgentTurnOutcomeDao;
|
||||
use lime_core::database::schema::create_tables;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn setup_db() -> DbConnection {
|
||||
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
|
||||
create_tables(&conn).expect("创建表结构失败");
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
|
||||
fn base_detail() -> SessionDetail {
|
||||
SessionDetail {
|
||||
id: "session-1".to_string(),
|
||||
name: "可靠性测试".to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
thread_id: "thread-1".to_string(),
|
||||
model: None,
|
||||
working_dir: None,
|
||||
workspace_id: None,
|
||||
messages: Vec::new(),
|
||||
execution_strategy: None,
|
||||
turns: Vec::new(),
|
||||
items: Vec::new(),
|
||||
todo_items: Vec::new(),
|
||||
child_subagent_sessions: Vec::new(),
|
||||
subagent_parent_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_persist_failed_outcome_and_clear_stale_incident() {
|
||||
let db = setup_db();
|
||||
|
||||
let mut failed_detail = base_detail();
|
||||
failed_detail.turns.push(AgentThreadTurn {
|
||||
id: "turn-failed".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "继续生成周报".to_string(),
|
||||
status: AgentThreadTurnStatus::Failed,
|
||||
started_at: "2026-03-23T09:55:00Z".to_string(),
|
||||
completed_at: Some("2026-03-23T09:56:00Z".to_string()),
|
||||
error_message: Some("provider rate limit".to_string()),
|
||||
created_at: "2026-03-23T09:55:00Z".to_string(),
|
||||
updated_at: "2026-03-23T09:56:00Z".to_string(),
|
||||
});
|
||||
failed_detail.items.push(AgentThreadItem {
|
||||
id: "item-tool-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-failed".to_string(),
|
||||
sequence: 1,
|
||||
status: AgentThreadItemStatus::Failed,
|
||||
started_at: "2026-03-23T09:55:10Z".to_string(),
|
||||
completed_at: Some("2026-03-23T09:55:20Z".to_string()),
|
||||
updated_at: "2026-03-23T09:55:20Z".to_string(),
|
||||
payload: AgentThreadItemPayload::ToolCall {
|
||||
tool_name: "web_search".to_string(),
|
||||
arguments: None,
|
||||
output: None,
|
||||
success: Some(false),
|
||||
error: Some("rate limit".to_string()),
|
||||
metadata: None,
|
||||
},
|
||||
});
|
||||
|
||||
let projection = sync_thread_reliability_projection(&db, &failed_detail)
|
||||
.expect("failed projection 应成功");
|
||||
assert_eq!(
|
||||
projection
|
||||
.last_outcome
|
||||
.as_ref()
|
||||
.map(|value| value.outcome_type.as_str()),
|
||||
Some("failed_tool")
|
||||
);
|
||||
assert_eq!(projection.incidents.len(), 1);
|
||||
|
||||
let conn = lock_db(&db).expect("获取数据库连接失败");
|
||||
assert!(AgentTurnOutcomeDao::get_by_turn(&conn, "turn-failed")
|
||||
.expect("读取 outcome 应成功")
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
AgentThreadIncidentDao::list_active_by_thread(&conn, "thread-1")
|
||||
.expect("读取 active incident 应成功")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let mut recovered_detail = base_detail();
|
||||
recovered_detail.turns.push(AgentThreadTurn {
|
||||
id: "turn-running".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "重新执行".to_string(),
|
||||
status: AgentThreadTurnStatus::Running,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
let recovered_projection = sync_thread_reliability_projection(&db, &recovered_detail)
|
||||
.expect("recovered projection 应成功");
|
||||
assert!(recovered_projection.last_outcome.is_none());
|
||||
assert!(recovered_projection.incidents.is_empty());
|
||||
|
||||
let conn = lock_db(&db).expect("获取数据库连接失败");
|
||||
assert!(
|
||||
AgentThreadIncidentDao::list_active_by_thread(&conn, "thread-1")
|
||||
.expect("读取 active incident 应成功")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user