chore: release v0.95.0

This commit is contained in:
coso
2026-03-24 00:56:05 +08:00
parent 1303f93a59
commit 818c8fef7b
192 changed files with 40182 additions and 18661 deletions
+4
View File
@@ -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,
))
}
+940 -1
View File
@@ -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())
);
}
}
+27 -23
View File
@@ -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(&current_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);
+1
View File
@@ -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(&current));
}
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()
);
}
}