chore: release v0.97.0

This commit is contained in:
coso
2026-03-27 14:35:56 +08:00
parent e380c98649
commit 26e7c60fdd
234 changed files with 36543 additions and 3657 deletions
@@ -3,6 +3,9 @@
//! 负责在工作区内生成稳定路径、落盘 JSON 快照,并给前端 workbench
//! 提供可直接消费的 snapshot metadata。
use crate::commands::content_cmd::THEME_WORKBENCH_DOCUMENT_META_KEY;
use crate::content::{ContentManager, ContentUpdateRequest};
use crate::database::DbConnection;
use crate::services::artifact_document_validator::{
validate_or_fallback_artifact_document, validate_or_repair_artifact_document_value,
ArtifactDocumentValidationContext, ArtifactDocumentValidationOutcome,
@@ -26,6 +29,8 @@ pub struct PersistedArtifactDocument {
pub absolute_path: PathBuf,
pub serialized_document: String,
pub snapshot_metadata: Map<String, Value>,
pub theme_workbench_document_state: Map<String, Value>,
pub content_body: String,
pub title: String,
pub kind: String,
pub status: String,
@@ -268,6 +273,9 @@ pub fn persist_artifact_document_from_text(
&source_links,
version_diff.as_ref(),
);
let theme_workbench_document_state =
build_theme_workbench_document_state(&version_history, current_version.id.as_str());
let content_body = build_content_body_from_document(&enriched_document);
Ok(PersistedArtifactDocument {
artifact_id,
@@ -277,6 +285,8 @@ pub fn persist_artifact_document_from_text(
absolute_path,
serialized_document,
snapshot_metadata,
theme_workbench_document_state,
content_body,
title: outcome.title,
kind: outcome.kind,
status: outcome.status,
@@ -397,6 +407,198 @@ fn build_snapshot_metadata(
metadata
}
fn parse_rfc3339_to_timestamp_millis(value: &str) -> Option<i64> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|parsed| parsed.timestamp_millis())
}
fn resolve_topic_branch_status(status: &str) -> Option<&'static str> {
match status.trim() {
"ready" | "success" => Some("merged"),
"draft" | "streaming" | "pending" | "queued" | "running" => Some("pending"),
"failed" | "error" | "timeout" | "canceled" => Some("candidate"),
_ => None,
}
}
fn build_theme_workbench_document_state(
version_history: &[ArtifactVersionSummary],
current_version_id: &str,
) -> Map<String, Value> {
let mut state = Map::new();
state.insert(
"currentVersionId".to_string(),
Value::String(current_version_id.to_string()),
);
state.insert(
"versions".to_string(),
Value::Array(
version_history
.iter()
.rev()
.map(|version| {
let mut record = Map::new();
record.insert("id".to_string(), Value::String(version.id.clone()));
record.insert(
"createdAt".to_string(),
Value::from(
parse_rfc3339_to_timestamp_millis(version.created_at.as_str())
.unwrap_or_default(),
),
);
record.insert(
"description".to_string(),
Value::String(
version
.summary
.clone()
.unwrap_or_else(|| format!("版本 {}", version.version_no)),
),
);
Value::Object(record)
})
.collect(),
),
);
state.insert(
"versionStatusMap".to_string(),
Value::Object(
version_history
.iter()
.filter_map(|version| {
resolve_topic_branch_status(version.status.as_str())
.map(|status| (version.id.clone(), Value::String(status.to_string())))
})
.collect(),
),
);
state
}
fn normalize_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn extract_block_text(block: &Map<String, Value>) -> Option<String> {
normalize_text(block.get("markdown").and_then(Value::as_str))
.or_else(|| normalize_text(block.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("content").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("summary").and_then(Value::as_str)))
.or_else(|| {
block.get("items").and_then(Value::as_array).map(|items| {
items
.iter()
.filter_map(|item| {
if let Some(text) = item.as_str() {
return normalize_text(Some(text));
}
let item = item.as_object()?;
normalize_text(item.get("label").and_then(Value::as_str))
.or_else(|| normalize_text(item.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(item.get("title").and_then(Value::as_str)))
})
.collect::<Vec<_>>()
.join("\n")
})
})
.and_then(|value| normalize_text(Some(value.as_str())))
}
fn build_content_body_from_document(document: &Value) -> String {
let Some(record) = document.as_object() else {
return String::new();
};
let mut sections = Vec::new();
if let Some(title) = normalize_text(record.get("title").and_then(Value::as_str)) {
sections.push(format!("# {title}"));
}
if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) {
sections.push(summary);
}
if let Some(blocks) = record.get("blocks").and_then(Value::as_array) {
for block in blocks.iter().filter_map(Value::as_object) {
let mut parts = Vec::new();
if let Some(title) = normalize_text(block.get("title").and_then(Value::as_str)) {
parts.push(format!("## {title}"));
}
if let Some(body) = extract_block_text(block) {
parts.push(body);
}
if !parts.is_empty() {
sections.push(parts.join("\n\n"));
}
}
}
sections.join("\n\n").trim().to_string()
}
fn extract_content_id_from_request_metadata(request_metadata: Option<&Value>) -> Option<String> {
let root = request_metadata?.as_object()?;
let harness = root
.get("harness")
.and_then(Value::as_object)
.unwrap_or(root);
["content_id", "contentId"]
.iter()
.filter_map(|key| harness.get(*key))
.find_map(Value::as_str)
.and_then(|value| normalize_text(Some(value)))
}
fn should_sync_snapshot_metadata_key_to_content(key: &str) -> bool {
key.starts_with("artifact") || matches!(key, "previewText" | "lastUpdateSource")
}
pub fn sync_persisted_artifact_document_to_content(
db: &DbConnection,
request_metadata: Option<&Value>,
persisted: &PersistedArtifactDocument,
) -> Result<(), String> {
let Some(content_id) = extract_content_id_from_request_metadata(request_metadata) else {
return Ok(());
};
let manager = ContentManager::new(db.clone());
let Some(content) = manager.get(&content_id)? else {
return Err(format!("未找到要同步的内容: {content_id}"));
};
let mut next_metadata = content
.metadata
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
for (key, value) in persisted.snapshot_metadata.iter() {
if should_sync_snapshot_metadata_key_to_content(key.as_str()) {
next_metadata.insert(key.clone(), value.clone());
}
}
next_metadata.insert(
THEME_WORKBENCH_DOCUMENT_META_KEY.to_string(),
Value::Object(persisted.theme_workbench_document_state.clone()),
);
manager.update(
&content_id,
ContentUpdateRequest {
body: (!persisted.content_body.trim().is_empty())
.then(|| persisted.content_body.clone()),
metadata: Some(Value::Object(next_metadata)),
..Default::default()
},
)?;
Ok(())
}
fn build_version_id(artifact_id: &str, version_no: usize) -> String {
format!("{artifact_id}:v{version_no}")
}
@@ -1075,6 +1277,131 @@ mod tests {
assert!(persisted_second
.serialized_document
.contains("\"currentVersionDiff\""));
assert_eq!(
persisted_second
.theme_workbench_document_state
.get("currentVersionId")
.and_then(Value::as_str),
Some("artifact-document:artifact:analysis:demo:v2")
);
assert!(persisted_second.content_body.contains("# 结构化结论"));
}
#[test]
fn sync_persisted_artifact_document_to_content_should_update_body_and_metadata() {
use crate::content::{ContentCreateRequest, ContentManager};
use crate::database::init_database;
use crate::workspace::{WorkspaceManager, WorkspaceType};
let db = init_database().expect("db should init");
let workspace_root = tempdir().expect("tempdir").keep();
let workspace = WorkspaceManager::new(db.clone())
.create_with_type(
"自动化项目".to_string(),
workspace_root.clone(),
WorkspaceType::Document,
)
.expect("workspace should create");
let manager = ContentManager::new(db.clone());
let content = manager
.create(ContentCreateRequest {
project_id: workspace.id.clone(),
title: "自动化日报".to_string(),
content_type: None,
order: None,
body: Some(String::new()),
metadata: Some(serde_json::json!({
"source": "service_skill"
})),
})
.expect("content should create");
let params = ArtifactDocumentPersistParams {
workspace_root,
workspace_id: Some(workspace.id.clone()),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
request_metadata: Some(serde_json::json!({
"artifact": {
"artifact_mode": "draft",
"artifact_stage": "stage2",
"artifact_kind": "report",
"artifact_request_id": format!("artifact:{}", content.id.clone())
},
"harness": {
"content_id": content.id.clone()
}
})),
};
let persisted = persist_artifact_document_from_text(
r#"{
"type": "artifact_document_draft",
"schemaVersion": "artifact_document.v1",
"artifactId": "artifact-document:artifact:content",
"kind": "report",
"title": "自动化日报",
"status": "ready",
"summary": "最新日报已生成",
"blocks": [
{ "id": "body-1", "type": "rich_text", "markdown": "日报正文内容" }
]
}"#,
&params,
)
.expect("persist should succeed");
sync_persisted_artifact_document_to_content(
&db,
params.request_metadata.as_ref(),
&persisted,
)
.expect("sync should succeed");
let updated = manager
.get(&content.id)
.expect("get content should succeed")
.expect("updated content should exist");
assert!(updated.body.contains("日报正文内容"));
let metadata = updated.metadata.expect("metadata should exist");
assert_eq!(
metadata
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)
.and_then(Value::as_object)
.and_then(|value| value.get("currentVersionId"))
.and_then(Value::as_str),
Some(format!("artifact-document:artifact:{}:v1", content.id).as_str())
);
assert_eq!(
metadata.get("artifactKind").and_then(Value::as_str),
Some("report")
);
assert_eq!(
metadata.get("artifactRequestId").and_then(Value::as_str),
Some(format!("artifact:{}", content.id).as_str())
);
assert_eq!(
metadata
.get("artifact_paths")
.and_then(Value::as_array)
.and_then(|paths| paths.first())
.and_then(Value::as_str),
Some(
format!(
".lime/artifacts/thread-1/{}.artifact.json",
normalize_slug(format!("artifact:{}", content.id).as_str())
)
.as_str()
)
);
assert_eq!(
metadata
.get("artifactDocument")
.and_then(Value::as_object)
.and_then(|document| document.get("title"))
.and_then(Value::as_str),
Some("自动化日报")
);
}
#[test]
@@ -151,10 +151,66 @@ fn is_meaningful_artifact_value(value: Option<&Value>) -> bool {
.is_some_and(|value| !value.is_empty())
}
fn backfill_harness_string_if_missing(
request_metadata: Value,
keys: &[&str],
fallback: Option<&str>,
) -> Value {
let Some(fallback) = normalize_text(fallback) else {
return request_metadata;
};
if extract_harness_string(Some(&request_metadata), keys).is_some() {
return request_metadata;
}
let mut request_metadata = request_metadata;
let Some(root) = request_metadata.as_object_mut() else {
return request_metadata;
};
if let Some(harness) = root.get_mut("harness").and_then(Value::as_object_mut) {
harness.insert(keys[0].to_string(), Value::String(fallback));
return request_metadata;
}
root.insert(keys[0].to_string(), Value::String(fallback));
request_metadata
}
pub fn normalize_request_metadata_with_artifact_defaults(
request_metadata: Option<Value>,
theme_fallback: Option<&str>,
session_mode_fallback: Option<&str>,
gate_key_fallback: Option<&str>,
run_title_fallback: Option<&str>,
content_id_fallback: Option<&str>,
) -> Option<Value> {
let request_metadata = request_metadata?;
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["theme", "harness_theme", "harnessTheme"],
theme_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["session_mode", "sessionMode"],
session_mode_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["gate_key", "gateKey"],
gate_key_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["run_title", "runTitle", "title"],
run_title_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["content_id", "contentId"],
content_id_fallback,
);
let Some(root) = request_metadata.as_object() else {
return Some(request_metadata);
};
@@ -250,8 +306,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
@@ -302,8 +365,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert!(normalized.get("artifact").is_none());
}
@@ -320,8 +390,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
@@ -342,4 +419,107 @@ mod tests {
None
);
}
#[test]
fn should_backfill_content_id_before_infer_artifact_request_id() {
let metadata = json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
Some("content-from-session"),
)
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/harness/content_id")
.and_then(Value::as_str),
Some("content-from-session")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-from-session")
);
}
#[test]
fn should_backfill_theme_and_session_mode_before_infer_artifact_defaults() {
let metadata = json!({
"harness": {
"content_id": "content-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
Some("document"),
Some("theme_workbench"),
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized.pointer("/harness/theme").and_then(Value::as_str),
Some("document")
);
assert_eq!(
normalized
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-1")
);
}
#[test]
fn should_backfill_gate_key_and_run_title_when_missing() {
let metadata = json!({
"harness": {
"theme": "social-media",
"session_mode": "theme_workbench",
"content_id": "content-social-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
Some("write_mode"),
Some("社媒初稿"),
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/harness/gate_key")
.and_then(Value::as_str),
Some("write_mode")
);
assert_eq!(
normalized
.pointer("/harness/run_title")
.and_then(Value::as_str),
Some("社媒初稿")
);
}
}
+160 -26
View File
@@ -1085,34 +1085,79 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map<String, Value>
Value::String(parsed_payload.kind().to_string()),
);
if let AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
target_id,
open_window,
stream_mode,
} = parsed_payload
{
metadata.insert("profile_id".to_string(), Value::String(profile_id));
if let Some(profile_key) = profile_key {
metadata.insert("profile_key".to_string(), Value::String(profile_key));
match parsed_payload {
AutomationPayload::AgentTurn {
request_metadata,
content_id,
..
} => {
if let Some(content_id) = content_id.clone() {
metadata.insert("content_id".to_string(), Value::String(content_id));
}
let request_metadata = request_metadata.as_ref().and_then(Value::as_object);
if let Some(service_skill) = request_metadata.and_then(|value| {
value
.get("service_skill")
.or_else(|| value.get("serviceSkill"))
}) {
metadata.insert("service_skill".to_string(), service_skill.clone());
}
let harness = request_metadata
.and_then(|value| value.get("harness"))
.and_then(Value::as_object)
.map(|value| {
let mut next = value.clone();
if let Some(content_id) = content_id.as_ref() {
if !next.contains_key("content_id") && !next.contains_key("contentId") {
next.insert(
"content_id".to_string(),
Value::String(content_id.clone()),
);
}
}
Value::Object(next)
})
.or_else(|| {
content_id.map(|value| {
json!({
"content_id": value
})
})
});
if let Some(harness) = harness {
metadata.insert("harness".to_string(), harness);
}
}
if let Some(url) = url {
metadata.insert("url".to_string(), Value::String(url));
AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
target_id,
open_window,
stream_mode,
} => {
metadata.insert("profile_id".to_string(), Value::String(profile_id));
if let Some(profile_key) = profile_key {
metadata.insert("profile_key".to_string(), Value::String(profile_key));
}
if let Some(url) = url {
metadata.insert("url".to_string(), Value::String(url));
}
if let Some(environment_preset_id) = environment_preset_id {
metadata.insert(
"environment_preset_id".to_string(),
Value::String(environment_preset_id),
);
}
if let Some(target_id) = target_id {
metadata.insert("target_id".to_string(), Value::String(target_id));
}
metadata.insert("open_window".to_string(), Value::Bool(open_window));
metadata.insert("stream_mode".to_string(), json!(stream_mode));
}
if let Some(environment_preset_id) = environment_preset_id {
metadata.insert(
"environment_preset_id".to_string(),
Value::String(environment_preset_id),
);
}
if let Some(target_id) = target_id {
metadata.insert("target_id".to_string(), Value::String(target_id));
}
metadata.insert("open_window".to_string(), Value::Bool(open_window));
metadata.insert("stream_mode".to_string(), json!(stream_mode));
}
}
@@ -1335,6 +1380,95 @@ mod tests {
);
}
#[test]
fn build_tracker_finish_metadata_should_include_agent_turn_service_skill_context() {
let job = AutomationJob {
id: "job-1".to_string(),
name: "每日趋势摘要".to_string(),
description: Some("围绕指定平台输出趋势摘要".to_string()),
enabled: true,
workspace_id: "workspace-1".to_string(),
execution_mode: AutomationExecutionMode::Skill,
schedule: TaskSchedule::Cron {
expr: "0 9 * * *".to_string(),
tz: Some("Asia/Shanghai".to_string()),
},
payload: json!({
"kind": "agent_turn",
"prompt": "[服务型技能] 每日趋势摘要",
"web_search": false,
"content_id": "content-1",
"request_metadata": {
"service_skill": {
"id": "daily-trend-briefing",
"title": "每日趋势摘要",
"runner_type": "scheduled",
"execution_location": "client_default",
"source": "cloud_catalog",
"slot_values": [
{
"key": "platform",
"label": "监测平台",
"value": "小红书"
}
],
"user_input": "关注增长最快的话题"
},
"harness": {
"theme": "social-media"
}
}
}),
delivery: DeliveryConfig::default(),
timeout_secs: None,
max_retries: 3,
next_run_at: None,
last_status: None,
last_error: None,
last_run_at: None,
last_finished_at: None,
running_started_at: None,
consecutive_failures: 0,
last_retry_count: 0,
auto_disabled_until: None,
last_delivery: None,
created_at: "2026-03-15T00:00:00Z".to_string(),
updated_at: "2026-03-15T00:00:00Z".to_string(),
};
let metadata = build_tracker_finish_metadata(
&job,
Some("session-1"),
"success",
"success",
0,
1200,
None,
);
assert_eq!(metadata.get("content_id"), Some(&json!("content-1")));
assert_eq!(
metadata.pointer("/service_skill/title"),
Some(&json!("每日趋势摘要"))
);
assert_eq!(
metadata.pointer("/service_skill/slot_values/0/label"),
Some(&json!("监测平台"))
);
assert_eq!(
metadata.pointer("/service_skill/user_input"),
Some(&json!("关注增长最快的话题"))
);
assert_eq!(
metadata.pointer("/harness/theme"),
Some(&json!("social-media"))
);
assert_eq!(
metadata.pointer("/harness/content_id"),
Some(&json!("content-1"))
);
}
#[test]
fn build_delivery_context_should_build_stable_attempt_id_without_run_id() {
let job = AutomationJob {
@@ -8,6 +8,21 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
const DEFAULT_CDP_TIMEOUT_MS: u64 = 10_000;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID: &str = "browser-environment-us-desktop";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME: &str = "美区桌面";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION: &str = "美国住宅代理 + 桌面视口";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER: &str = "http://127.0.0.1:7890";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID: &str = "America/Los_Angeles";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE: &str = "en-US";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT: &str = "Mozilla/5.0";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM: &str = "MacIntel";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH: i64 = 1440;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT: i64 = 900;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR: f64 = 2.0;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT: f64 = 37.7749;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG: f64 = -122.4194;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M: f64 = 100.0;
#[derive(Debug, Clone)]
pub struct SaveBrowserEnvironmentPresetInput {
@@ -105,6 +120,37 @@ pub fn list_browser_environment_presets(
.map_err(|error| format!("读取浏览器环境预设失败: {error}"))
}
pub fn ensure_default_browser_environment_presets(conn: &Connection) -> Result<bool, String> {
let existing_presets = BrowserEnvironmentPresetDao::list(conn, true)
.map_err(|error| format!("读取浏览器环境预设失败: {error}"))?;
if !existing_presets.is_empty() {
return Ok(false);
}
save_browser_environment_preset(
conn,
SaveBrowserEnvironmentPresetInput {
id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID.to_string()),
name: DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME.to_string(),
description: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION.to_string()),
proxy_server: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER.to_string()),
timezone_id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID.to_string()),
locale: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE.to_string()),
accept_language: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE.to_string()),
geolocation_lat: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT),
geolocation_lng: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG),
geolocation_accuracy_m: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M),
user_agent: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT.to_string()),
platform: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM.to_string()),
viewport_width: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH),
viewport_height: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT),
device_scale_factor: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR),
},
)?;
Ok(true)
}
pub fn get_browser_environment_preset(
conn: &Connection,
id: &str,
@@ -405,6 +451,14 @@ fn extract_runtime_value(response: Value) -> Option<Value> {
#[cfg(test)]
mod tests {
use super::*;
use crate::database::schema::create_tables;
use rusqlite::Connection;
fn setup_db() -> Connection {
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
create_tables(&conn).expect("创建数据表失败");
conn
}
#[test]
fn should_require_complete_geolocation_pair() {
@@ -441,4 +495,54 @@ mod tests {
let error = normalize_viewport(Some(1440), None).unwrap_err();
assert!(error.contains("必须同时填写"));
}
#[test]
fn should_seed_default_environment_preset_for_empty_table() {
let conn = setup_db();
let seeded = ensure_default_browser_environment_presets(&conn).unwrap();
let presets = list_browser_environment_presets(&conn, false).unwrap();
assert!(seeded);
assert_eq!(presets.len(), 1);
assert_eq!(presets[0].id, DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID);
assert_eq!(presets[0].name, DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME);
assert_eq!(
presets[0].proxy_server.as_deref(),
Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER)
);
}
#[test]
fn should_not_seed_default_environment_preset_when_table_has_records() {
let conn = setup_db();
save_browser_environment_preset(
&conn,
SaveBrowserEnvironmentPresetInput {
id: None,
name: "日本桌面".to_string(),
description: None,
proxy_server: None,
timezone_id: Some("Asia/Tokyo".to_string()),
locale: Some("ja-JP".to_string()),
accept_language: Some("ja-JP,ja;q=0.9".to_string()),
geolocation_lat: None,
geolocation_lng: None,
geolocation_accuracy_m: None,
user_agent: None,
platform: Some("MacIntel".to_string()),
viewport_width: Some(1440),
viewport_height: Some(900),
device_scale_factor: Some(2.0),
},
)
.unwrap();
let seeded = ensure_default_browser_environment_presets(&conn).unwrap();
let presets = list_browser_environment_presets(&conn, false).unwrap();
assert!(!seeded);
assert_eq!(presets.len(), 1);
assert_eq!(presets[0].name, "日本桌面");
}
}
@@ -7,6 +7,12 @@ use lime_core::database::dao::browser_profile::{
use rusqlite::Connection;
use url::Url;
const DEFAULT_BROWSER_PROFILE_KEY: &str = "general_browser_assist";
const DEFAULT_BROWSER_PROFILE_NAME: &str = "通用浏览器资料";
const DEFAULT_BROWSER_PROFILE_DESCRIPTION: &str = "默认浏览器协助资料";
const DEFAULT_BROWSER_PROFILE_SITE_SCOPE: &str = "通用";
const DEFAULT_BROWSER_PROFILE_LAUNCH_URL: &str = "https://www.google.com/";
#[derive(Debug, Clone)]
pub struct SaveBrowserProfileInput {
pub id: Option<String>,
@@ -63,6 +69,29 @@ pub fn list_browser_profiles(
.map_err(|error| format!("读取浏览器资料失败: {error}"))
}
pub fn ensure_default_browser_profiles(conn: &Connection) -> Result<bool, String> {
let existing_profiles = BrowserProfileDao::list(conn, true)
.map_err(|error| format!("读取浏览器资料失败: {error}"))?;
if !existing_profiles.is_empty() {
return Ok(false);
}
save_browser_profile(
conn,
SaveBrowserProfileInput {
id: None,
profile_key: DEFAULT_BROWSER_PROFILE_KEY.to_string(),
name: DEFAULT_BROWSER_PROFILE_NAME.to_string(),
description: Some(DEFAULT_BROWSER_PROFILE_DESCRIPTION.to_string()),
site_scope: Some(DEFAULT_BROWSER_PROFILE_SITE_SCOPE.to_string()),
launch_url: Some(DEFAULT_BROWSER_PROFILE_LAUNCH_URL.to_string()),
transport_kind: BrowserProfileTransportKind::ManagedCdp,
},
)?;
Ok(true)
}
pub fn get_browser_profile(
conn: &Connection,
id: &str,
@@ -306,4 +335,46 @@ mod tests {
assert_eq!(saved.profile_dir, "");
assert_eq!(saved.managed_profile_dir, None);
}
#[test]
fn should_seed_default_profile_for_empty_table() {
let conn = setup_db();
let seeded = ensure_default_browser_profiles(&conn).unwrap();
let profiles = list_browser_profiles(&conn, false).unwrap();
assert!(seeded);
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].profile_key, DEFAULT_BROWSER_PROFILE_KEY);
assert_eq!(profiles[0].name, DEFAULT_BROWSER_PROFILE_NAME);
assert_eq!(
profiles[0].transport_kind,
BrowserProfileTransportKind::ManagedCdp
);
}
#[test]
fn should_not_seed_default_profile_when_table_has_records() {
let conn = setup_db();
save_browser_profile(
&conn,
SaveBrowserProfileInput {
id: None,
profile_key: "weibo_attach".to_string(),
name: "微博附着".to_string(),
description: Some("依赖当前 Chrome".to_string()),
site_scope: Some("weibo.com".to_string()),
launch_url: Some("https://weibo.com".to_string()),
transport_kind: BrowserProfileTransportKind::ExistingSession,
},
)
.unwrap();
let seeded = ensure_default_browser_profiles(&conn).unwrap();
let profiles = list_browser_profiles(&conn, false).unwrap();
assert!(!seeded);
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].profile_key, "weibo_attach");
}
}
+5
View File
@@ -30,6 +30,11 @@ pub mod memory_source_resolver_service;
pub mod novel_service;
pub mod openclaw_service;
pub mod runtime_agents_template_service;
pub mod runtime_analysis_handoff_service;
pub mod runtime_evidence_pack_service;
pub mod runtime_handoff_artifact_service;
pub mod runtime_replay_case_service;
pub mod runtime_review_decision_service;
pub mod site_adapter_registry;
pub mod site_capability_service;
pub mod sysinfo_service;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,740 @@
//! Runtime evidence pack 导出服务
//!
//! 将当前 Lime 会话的 runtime / timeline / artifact 事实,
//! 导出为最小可复盘的问题证据包。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use chrono::Utc;
use lime_core::database::dao::agent_timeline::AgentThreadItemPayload;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions";
const EVIDENCE_DIR_NAME: &str = "evidence";
const SUMMARY_FILE_NAME: &str = "summary.md";
const RUNTIME_FILE_NAME: &str = "runtime.json";
const TIMELINE_FILE_NAME: &str = "timeline.json";
const ARTIFACTS_FILE_NAME: &str = "artifacts.json";
const MAX_RECENT_ARTIFACTS: usize = 12;
const MAX_PREVIEW_CHARS: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeEvidenceArtifactKind {
Summary,
Runtime,
Timeline,
Artifacts,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvidenceArtifact {
pub kind: RuntimeEvidenceArtifactKind,
pub title: String,
pub relative_path: String,
pub absolute_path: String,
pub bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvidencePackExportResult {
pub session_id: String,
pub thread_id: String,
pub workspace_id: Option<String>,
pub workspace_root: String,
pub pack_relative_root: String,
pub pack_absolute_root: String,
pub exported_at: String,
pub thread_status: String,
pub latest_turn_status: Option<String>,
pub turn_count: usize,
pub item_count: usize,
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub recent_artifact_count: usize,
pub known_gaps: Vec<String>,
pub artifacts: Vec<RuntimeEvidenceArtifact>,
}
pub fn export_runtime_evidence_pack(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
) -> Result<RuntimeEvidencePackExportResult, String> {
let session_id = detail.id.trim();
if session_id.is_empty() {
return Err("session_id 不能为空,无法导出问题证据包".to_string());
}
let thread_id = detail.thread_id.trim();
if thread_id.is_empty() {
return Err("thread_id 不能为空,无法导出问题证据包".to_string());
}
let workspace_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let exported_at = Utc::now().to_rfc3339();
let pack_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}");
let pack_absolute_root =
workspace_root.join(pack_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(&pack_absolute_root).map_err(|error| {
format!(
"创建 evidence pack 目录失败 {}: {error}",
pack_absolute_root.display()
)
})?;
let recent_artifacts = collect_recent_artifact_paths(detail);
let latest_turn_summary = collect_latest_turn_summary(detail);
let known_gaps = build_known_gaps(&recent_artifacts);
let artifacts = vec![
write_evidence_file(
&pack_absolute_root,
session_id,
SUMMARY_FILE_NAME,
RuntimeEvidenceArtifactKind::Summary,
"问题摘要",
build_summary_markdown(
detail,
thread_read,
&recent_artifacts,
latest_turn_summary.as_deref(),
&known_gaps,
exported_at.as_str(),
),
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
RUNTIME_FILE_NAME,
RuntimeEvidenceArtifactKind::Runtime,
"运行时快照",
build_runtime_json(
detail,
thread_read,
workspace_root.as_path(),
&recent_artifacts,
&known_gaps,
exported_at.as_str(),
)?,
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
TIMELINE_FILE_NAME,
RuntimeEvidenceArtifactKind::Timeline,
"时间线快照",
build_timeline_json(detail, exported_at.as_str())?,
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
ARTIFACTS_FILE_NAME,
RuntimeEvidenceArtifactKind::Artifacts,
"产物与验证线索",
build_artifacts_json(
detail,
thread_read,
&recent_artifacts,
&known_gaps,
exported_at.as_str(),
)?,
)?,
];
Ok(RuntimeEvidencePackExportResult {
session_id: session_id.to_string(),
thread_id: thread_id.to_string(),
workspace_id: normalize_optional_text(detail.workspace_id.clone()),
workspace_root: workspace_root.to_string_lossy().to_string(),
pack_relative_root,
pack_absolute_root: pack_absolute_root.to_string_lossy().to_string(),
exported_at,
thread_status: thread_read.status.trim().to_string(),
latest_turn_status: thread_read
.diagnostics
.as_ref()
.and_then(|value| normalize_optional_text(value.latest_turn_status.clone())),
turn_count: detail.turns.len(),
item_count: detail.items.len(),
pending_request_count: thread_read.pending_requests.len(),
queued_turn_count: thread_read.queued_turns.len(),
recent_artifact_count: recent_artifacts.len(),
known_gaps,
artifacts,
})
}
fn write_evidence_file(
pack_root: &Path,
session_id: &str,
file_name: &str,
kind: RuntimeEvidenceArtifactKind,
title: &str,
content: String,
) -> Result<RuntimeEvidenceArtifact, String> {
let absolute_path = pack_root.join(file_name);
fs::write(&absolute_path, content.as_bytes()).map_err(|error| {
format!(
"写入 evidence pack 文件失败 {}: {error}",
absolute_path.display()
)
})?;
Ok(RuntimeEvidenceArtifact {
kind,
title: title.to_string(),
relative_path: format!(
"{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}/{file_name}"
),
absolute_path: absolute_path.to_string_lossy().to_string(),
bytes: content.len(),
})
}
fn build_summary_markdown(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
recent_artifacts: &[String],
latest_turn_summary: Option<&str>,
known_gaps: &[String],
exported_at: &str,
) -> String {
let mut markdown = String::new();
let _ = writeln!(markdown, "# 问题证据包");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"> 当前证据包继续沿用 Codex 的结构化交接思路,运行时事实承接 Aster 的 session / thread / diagnostics,最终制品由 Lime 落盘到工作区。"
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- 会话:`{}`", detail.id);
let _ = writeln!(markdown, "- 线程:`{}`", detail.thread_id);
let _ = writeln!(markdown, "- 导出时间:{exported_at}");
let _ = writeln!(markdown, "- 线程状态:{}", thread_read.status);
let _ = writeln!(
markdown,
"- Pending request:{} · 排队 turn:{}",
thread_read.pending_requests.len(),
thread_read.queued_turns.len()
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 最近摘要");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"{}",
latest_turn_summary
.unwrap_or("当前没有结构化 turn summary,请先读 runtime.json 与 timeline.json。")
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 证据概览");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- Turns:{}", detail.turns.len());
let _ = writeln!(markdown, "- Timeline items:{}", detail.items.len());
let _ = writeln!(markdown, "- 最近产物:{}", recent_artifacts.len());
if let Some(blocking_summary) = thread_read
.diagnostics
.as_ref()
.and_then(|value| value.primary_blocking_summary.clone())
{
let _ = writeln!(markdown, "- 当前主要阻塞:{blocking_summary}");
}
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 建议读取顺序");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "1. 先读 `summary.md`,确认会话状态和当前阻塞。");
let _ = writeln!(
markdown,
"2. 再读 `runtime.json`,查看 pending request / queued turn / diagnostics。"
);
let _ = writeln!(
markdown,
"3. 再读 `timeline.json`,回放最近 turns 与 items。"
);
let _ = writeln!(
markdown,
"4. 最后读 `artifacts.json`,确认最近产物与当前证据缺口。"
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 已知缺口");
let _ = writeln!(markdown);
for gap in known_gaps {
let _ = writeln!(markdown, "- {gap}");
}
markdown
}
fn build_runtime_json(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
recent_artifacts: &[String],
known_gaps: &[String],
exported_at: &str,
) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"source": {
"contractShape": "codex_trace_evidence_pack",
"runtimeSubstrate": "aster_session_thread_runtime",
"productSurface": "lime_workspace_evidence_pack"
},
"session": {
"sessionId": detail.id,
"threadId": detail.thread_id,
"name": detail.name,
"workspaceId": detail.workspace_id,
"workspaceRoot": workspace_root.to_string_lossy().to_string(),
"exportedAt": exported_at,
"updatedAt": detail.updated_at,
"executionStrategy": detail.execution_strategy,
"model": detail.model
},
"thread": {
"status": thread_read.status,
"activeTurnId": thread_read.active_turn_id,
"interruptState": thread_read.interrupt_state,
"latestTurnStatus": thread_read.diagnostics.as_ref().and_then(|value| value.latest_turn_status.clone()),
"pendingRequestCount": thread_read.pending_requests.len(),
"queuedTurnCount": thread_read.queued_turns.len(),
"diagnostics": {
"warningCount": thread_read.diagnostics.as_ref().map(|value| value.warning_count).unwrap_or(0),
"contextCompactionCount": thread_read.diagnostics.as_ref().map(|value| value.context_compaction_count).unwrap_or(0),
"failedToolCallCount": thread_read.diagnostics.as_ref().map(|value| value.failed_tool_call_count).unwrap_or(0),
"failedCommandCount": thread_read.diagnostics.as_ref().map(|value| value.failed_command_count).unwrap_or(0),
"primaryBlockingKind": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_kind.clone()),
"primaryBlockingSummary": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_summary.clone()),
"latestWarning": thread_read.diagnostics.as_ref().and_then(|value| value.latest_warning.as_ref().map(|warning| json!({
"code": warning.code,
"message": warning.message,
"updatedAt": warning.updated_at
}))),
"latestFailedTool": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_tool.as_ref().map(|tool| json!({
"toolName": tool.tool_name,
"error": tool.error,
"updatedAt": tool.updated_at
}))),
"latestFailedCommand": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_command.as_ref().map(|command| json!({
"command": command.command,
"exitCode": command.exit_code,
"error": command.error,
"updatedAt": command.updated_at
})))
}
},
"pendingRequests": thread_read.pending_requests.iter().map(|item| {
json!({
"id": item.id,
"type": item.request_type,
"status": item.status,
"title": item.title,
"turnId": item.turn_id
})
}).collect::<Vec<_>>(),
"queuedTurns": thread_read.queued_turns.iter().map(|item| {
json!({
"id": item.queued_turn_id,
"position": item.position,
"preview": item.message_preview,
"createdAt": item.created_at
})
}).collect::<Vec<_>>(),
"subagents": detail.child_subagent_sessions.iter().map(|session| {
json!({
"id": session.id,
"name": session.name,
"runtimeStatus": session.runtime_status,
"latestTurnStatus": session.latest_turn_status,
"taskSummary": session.task_summary,
"roleHint": session.role_hint,
"updatedAt": session.updated_at
})
}).collect::<Vec<_>>(),
"recentArtifacts": recent_artifacts,
"knownGaps": known_gaps
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 runtime.json 失败: {error}"))
}
fn build_timeline_json(detail: &SessionDetail, exported_at: &str) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"exportedAt": exported_at,
"turns": detail.turns.iter().map(|turn| {
json!({
"id": turn.id,
"status": serialize_enum_as_string(&turn.status, "unknown"),
"promptPreview": truncate_text(turn.prompt_text.as_str()),
"startedAt": turn.started_at,
"completedAt": turn.completed_at,
"updatedAt": turn.updated_at
})
}).collect::<Vec<_>>(),
"items": detail.items.iter().map(|item| {
let (payload_kind, payload_summary) = summarize_item_payload(&item.payload);
json!({
"id": item.id,
"turnId": item.turn_id,
"sequence": item.sequence,
"status": serialize_enum_as_string(&item.status, "unknown"),
"payloadKind": payload_kind,
"payloadSummary": payload_summary,
"updatedAt": item.updated_at
})
}).collect::<Vec<_>>()
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 timeline.json 失败: {error}"))
}
fn build_artifacts_json(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
recent_artifacts: &[String],
known_gaps: &[String],
exported_at: &str,
) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"exportedAt": exported_at,
"recentArtifacts": recent_artifacts,
"artifactCount": recent_artifacts.len(),
"verification": {
"artifactValidatorIssues": [],
"browserEvidence": [],
"guiSmoke": null
},
"requests": {
"pending": thread_read.pending_requests.iter().map(|item| {
json!({
"id": item.id,
"type": item.request_type,
"title": item.title,
"status": item.status
})
}).collect::<Vec<_>>(),
"knownGap": "provider request token / retry 摘要尚未接入当前 evidence pack"
},
"workspace": {
"workspaceId": detail.workspace_id,
"workingDir": detail.working_dir
},
"knownGaps": known_gaps
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 artifacts.json 失败: {error}"))
}
fn build_known_gaps(recent_artifacts: &[String]) -> Vec<String> {
let mut gaps = vec![
"当前 Evidence Pack 尚未纳入 provider 请求级 token / retry / duration 摘要。".to_string(),
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。".to_string(),
];
if recent_artifacts.is_empty() {
gaps.push("当前未检测到最近产物路径,Artifact 证据为空。".to_string());
}
gaps
}
fn collect_latest_turn_summary(detail: &SessionDetail) -> Option<String> {
detail
.items
.iter()
.rev()
.find_map(|item| match &item.payload {
AgentThreadItemPayload::TurnSummary { text } => {
normalize_optional_text(Some(text.clone()))
}
_ => None,
})
}
fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut paths = Vec::new();
for item in detail.items.iter().rev() {
let Some(path) = (match &item.payload {
AgentThreadItemPayload::FileArtifact { path, .. } => {
normalize_optional_text(Some(path.clone()))
}
_ => None,
}) else {
continue;
};
if seen.insert(path.clone()) {
paths.push(path);
}
if paths.len() >= MAX_RECENT_ARTIFACTS {
break;
}
}
paths
}
fn summarize_item_payload(payload: &AgentThreadItemPayload) -> (&'static str, Option<String>) {
match payload {
AgentThreadItemPayload::Plan { text } => {
("plan", normalize_optional_text(Some(truncate_text(text))))
}
AgentThreadItemPayload::TurnSummary { text } => (
"turn_summary",
normalize_optional_text(Some(truncate_text(text))),
),
AgentThreadItemPayload::FileArtifact { path, .. } => {
("file_artifact", normalize_optional_text(Some(path.clone())))
}
_ => ("other", None),
}
}
fn truncate_text(value: &str) -> String {
let normalized = value.trim();
if normalized.chars().count() <= MAX_PREVIEW_CHARS {
return normalized.to_string();
}
normalized
.chars()
.take(MAX_PREVIEW_CHARS)
.collect::<String>()
+ "..."
}
fn serialize_enum_as_string<T: Serialize>(value: &T, fallback: &str) -> String {
serde_json::to_value(value)
.ok()
.and_then(|item| item.as_str().map(str::to_string))
.unwrap_or_else(|| fallback.to_string())
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::QueuedTurnSnapshot;
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus,
};
use tempfile::TempDir;
fn build_detail() -> SessionDetail {
SessionDetail {
id: "session-1".to_string(),
name: "P2 evidence".to_string(),
created_at: 1,
updated_at: 2,
thread_id: "thread-1".to_string(),
model: Some("gpt-5.4".to_string()),
working_dir: Some("/tmp/workspace".to_string()),
workspace_id: Some("workspace-1".to_string()),
messages: Vec::new(),
execution_strategy: Some("react".to_string()),
execution_runtime: None,
turns: vec![AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "继续推进 evidence pack".to_string(),
status: AgentThreadTurnStatus::Completed,
started_at: "2026-03-27T10:00:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
error_message: None,
created_at: "2026-03-27T10:00:00Z".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
}],
items: vec![
AgentThreadItem {
id: "plan-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:05Z".to_string(),
completed_at: Some("2026-03-27T10:00:05Z".to_string()),
updated_at: "2026-03-27T10:00:05Z".to_string(),
payload: AgentThreadItemPayload::Plan {
text: "先导出 handoff,再导出 evidence pack".to_string(),
},
},
AgentThreadItem {
id: "artifact-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 2,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:20Z".to_string(),
completed_at: Some("2026-03-27T10:00:20Z".to_string()),
updated_at: "2026-03-27T10:00:20Z".to_string(),
payload: AgentThreadItemPayload::FileArtifact {
path: ".lime/artifacts/thread-1/report.md".to_string(),
source: "artifact_snapshot".to_string(),
content: None,
metadata: None,
},
},
AgentThreadItem {
id: "summary-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 3,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:30Z".to_string(),
completed_at: Some("2026-03-27T10:00:30Z".to_string()),
updated_at: "2026-03-27T10:00:30Z".to_string(),
payload: AgentThreadItemPayload::TurnSummary {
text: "已拿到 handoff 四件套,下一步补问题证据包。".to_string(),
},
},
],
todo_items: Vec::new(),
child_subagent_sessions: Vec::new(),
subagent_parent_context: None,
}
}
fn build_thread_read() -> AgentRuntimeThreadReadModel {
AgentRuntimeThreadReadModel {
thread_id: "thread-1".to_string(),
status: "running".to_string(),
active_turn_id: Some("turn-1".to_string()),
pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView {
id: "req-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
item_id: None,
request_type: "ask_user".to_string(),
status: "pending".to_string(),
title: Some("确认是否导出问题证据包".to_string()),
payload: None,
decision: None,
scope: None,
created_at: None,
resolved_at: None,
}],
last_outcome: None,
incidents: Vec::new(),
queued_turns: vec![QueuedTurnSnapshot {
queued_turn_id: "queued-1".to_string(),
message_preview: "继续补证据包 UI".to_string(),
message_text: "继续补证据包 UI".to_string(),
created_at: 3,
image_count: 0,
position: 1,
}],
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
diagnostics: Some(
crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("running".to_string()),
latest_turn_started_at: None,
latest_turn_completed_at: None,
latest_turn_updated_at: None,
latest_turn_elapsed_seconds: None,
latest_turn_stalled_seconds: None,
latest_turn_error_message: None,
interrupt_reason: None,
runtime_interrupt_source: None,
runtime_interrupt_requested_at: None,
runtime_interrupt_wait_seconds: None,
warning_count: 1,
context_compaction_count: 0,
failed_tool_call_count: 0,
failed_command_count: 0,
pending_request_count: 1,
oldest_pending_request_wait_seconds: None,
primary_blocking_kind: Some("pending_request".to_string()),
primary_blocking_summary: Some("等待用户确认是否导出问题证据包".to_string()),
latest_warning: Some(
crate::commands::aster_agent_cmd::AgentRuntimeDiagnosticWarningSample {
item_id: "warning-1".to_string(),
turn_id: Some("turn-1".to_string()),
code: Some("runtime.pending".to_string()),
message: "存在待处理请求".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
},
),
latest_context_compaction: None,
latest_failed_tool: None,
latest_failed_command: None,
latest_pending_request: None,
},
),
}
}
#[test]
fn should_export_runtime_evidence_pack_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
let result =
export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export");
assert_eq!(
result.pack_relative_root,
".lime/harness/sessions/session-1/evidence"
);
assert_eq!(result.artifacts.len(), 4);
assert_eq!(result.turn_count, 1);
assert_eq!(result.item_count, 3);
assert_eq!(result.pending_request_count, 1);
assert_eq!(result.queued_turn_count, 1);
assert_eq!(result.recent_artifact_count, 1);
assert!(!result.known_gaps.is_empty());
let summary_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/summary.md");
let runtime_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/runtime.json");
let timeline_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/timeline.json");
assert!(summary_path.exists());
assert!(runtime_path.exists());
assert!(timeline_path.exists());
let summary = fs::read_to_string(summary_path).expect("summary");
assert!(summary.contains("问题证据包"));
assert!(summary.contains("等待用户确认是否导出问题证据包"));
let runtime = fs::read_to_string(runtime_path).expect("runtime");
assert!(runtime.contains("\"sessionId\": \"session-1\""));
assert!(runtime.contains("\"pendingRequestCount\": 1"));
let timeline = fs::read_to_string(timeline_path).expect("timeline");
assert!(timeline.contains("\"payloadKind\": \"plan\""));
assert!(timeline.contains("\"status\": \"completed\""));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,625 @@
//! Runtime review decision 模板导出服务
//!
//! 将外部 Claude Code / Codex 的分析结论回挂为
//! Lime 工作区内可版本化的人工审核与决策记录模板。
//! 这条链只导出 review-decision 模板,不在 Lime 内自动批准或自动应用修复。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use crate::services::runtime_analysis_handoff_service::{
export_runtime_analysis_handoff, RuntimeAnalysisArtifact, RuntimeAnalysisHandoffExportResult,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions";
const REVIEW_DIR_NAME: &str = "review";
const REVIEW_DECISION_MARKDOWN_FILE_NAME: &str = "review-decision.md";
const REVIEW_DECISION_JSON_FILE_NAME: &str = "review-decision.json";
const DEFAULT_DECISION_STATUS: &str = "pending_review";
const DEFAULT_RISK_LEVEL: &str = "unknown";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeReviewDecisionArtifactKind {
ReviewDecisionMarkdown,
ReviewDecisionJson,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeReviewDecisionArtifact {
pub kind: RuntimeReviewDecisionArtifactKind,
pub title: String,
pub relative_path: String,
pub absolute_path: String,
pub bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeReviewDecisionTemplateExportResult {
pub session_id: String,
pub thread_id: String,
pub workspace_id: Option<String>,
pub workspace_root: String,
pub review_relative_root: String,
pub review_absolute_root: String,
pub analysis_relative_root: String,
pub analysis_absolute_root: String,
pub handoff_bundle_relative_root: String,
pub evidence_pack_relative_root: String,
pub replay_case_relative_root: String,
pub exported_at: String,
pub title: String,
pub thread_status: String,
pub latest_turn_status: Option<String>,
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub default_decision_status: String,
pub review_checklist: Vec<String>,
pub analysis_artifacts: Vec<RuntimeAnalysisArtifact>,
pub artifacts: Vec<RuntimeReviewDecisionArtifact>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionDocument {
schema_version: String,
contract_shape: String,
exported_at: String,
source: ReviewDecisionSource,
review_context: ReviewDecisionContext,
decision: ReviewDecisionContent,
decision_status_options: Vec<String>,
risk_level_options: Vec<String>,
review_checklist: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionSource {
derived_from: Vec<String>,
upstream_alignment: ReviewDecisionUpstreamAlignment,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionUpstreamAlignment {
execution_environment_reference: String,
runtime_fact_source: String,
product_surface: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContext {
session_id: String,
thread_id: String,
workspace_id: Option<String>,
title: String,
thread_status: String,
latest_turn_status: Option<String>,
pending_request_count: usize,
queued_turn_count: usize,
analysis_relative_root: String,
handoff_bundle_relative_root: String,
evidence_pack_relative_root: String,
replay_case_relative_root: String,
analysis_artifacts: Vec<ReviewDecisionArtifactReference>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionArtifactReference {
kind: String,
title: String,
relative_path: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContent {
decision_status: String,
decision_summary: String,
chosen_fix_strategy: String,
risk_level: String,
risk_tags: Vec<String>,
human_reviewer: String,
reviewed_at: Option<String>,
followup_actions: Vec<String>,
regression_requirements: Vec<String>,
notes: String,
}
pub fn export_runtime_review_decision_template(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
let session_id = detail.id.trim();
if session_id.is_empty() {
return Err("session_id 不能为空,无法导出 review decision 模板".to_string());
}
let thread_id = detail.thread_id.trim();
if thread_id.is_empty() {
return Err("thread_id 不能为空,无法导出 review decision 模板".to_string());
}
let workspace_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let exported_at = Utc::now().to_rfc3339();
let review_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{REVIEW_DIR_NAME}");
let review_absolute_root =
workspace_root.join(review_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR));
let analysis = export_runtime_analysis_handoff(detail, thread_read, workspace_root.as_path())?;
fs::create_dir_all(&review_absolute_root).map_err(|error| {
format!(
"创建 review decision 目录失败 {}: {error}",
review_absolute_root.display()
)
})?;
let review_checklist = build_review_checklist();
let document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
let markdown = build_review_decision_markdown(&document);
let json = serde_json::to_string_pretty(&document)
.map_err(|error| format!("序列化 review decision json 失败: {error}"))?;
let artifacts = vec![
write_review_decision_artifact(
RuntimeReviewDecisionArtifactKind::ReviewDecisionMarkdown,
"人工审核记录",
&review_absolute_root.join(REVIEW_DECISION_MARKDOWN_FILE_NAME),
&format!("{review_relative_root}/{REVIEW_DECISION_MARKDOWN_FILE_NAME}"),
markdown.as_bytes(),
)?,
write_review_decision_artifact(
RuntimeReviewDecisionArtifactKind::ReviewDecisionJson,
"人工审核记录 JSON",
&review_absolute_root.join(REVIEW_DECISION_JSON_FILE_NAME),
&format!("{review_relative_root}/{REVIEW_DECISION_JSON_FILE_NAME}"),
json.as_bytes(),
)?,
];
Ok(RuntimeReviewDecisionTemplateExportResult {
session_id: analysis.session_id.clone(),
thread_id: analysis.thread_id.clone(),
workspace_id: analysis.workspace_id.clone(),
workspace_root: analysis.workspace_root.clone(),
review_relative_root,
review_absolute_root: to_portable_path(&review_absolute_root.to_string_lossy()),
analysis_relative_root: analysis.analysis_relative_root.clone(),
analysis_absolute_root: analysis.analysis_absolute_root.clone(),
handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(),
evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(),
replay_case_relative_root: analysis.replay_case_relative_root.clone(),
exported_at,
title: analysis.title.clone(),
thread_status: analysis.thread_status.clone(),
latest_turn_status: analysis.latest_turn_status.clone(),
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
review_checklist,
analysis_artifacts: analysis.artifacts.clone(),
artifacts,
})
}
fn build_review_decision_document(
analysis: &RuntimeAnalysisHandoffExportResult,
exported_at: &str,
review_checklist: &[String],
) -> ReviewDecisionDocument {
ReviewDecisionDocument {
schema_version: "v1".to_string(),
contract_shape: "lime_review_decision_template".to_string(),
exported_at: exported_at.to_string(),
source: ReviewDecisionSource {
derived_from: vec![
"lime_external_analysis_handoff".to_string(),
"runtime_handoff_bundle".to_string(),
"runtime_evidence_pack".to_string(),
"runtime_replay_case".to_string(),
],
upstream_alignment: ReviewDecisionUpstreamAlignment {
execution_environment_reference: "codex".to_string(),
runtime_fact_source: "aster-rust".to_string(),
product_surface: "lime".to_string(),
},
},
review_context: ReviewDecisionContext {
session_id: analysis.session_id.clone(),
thread_id: analysis.thread_id.clone(),
workspace_id: analysis.workspace_id.clone(),
title: analysis.title.clone(),
thread_status: analysis.thread_status.clone(),
latest_turn_status: analysis.latest_turn_status.clone(),
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
analysis_relative_root: analysis.analysis_relative_root.clone(),
handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(),
evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(),
replay_case_relative_root: analysis.replay_case_relative_root.clone(),
analysis_artifacts: analysis
.artifacts
.iter()
.map(|artifact| ReviewDecisionArtifactReference {
kind: review_analysis_artifact_kind_key(&artifact.kind).to_string(),
title: artifact.title.clone(),
relative_path: artifact.relative_path.clone(),
})
.collect(),
},
decision: ReviewDecisionContent {
decision_status: DEFAULT_DECISION_STATUS.to_string(),
decision_summary: String::new(),
chosen_fix_strategy: String::new(),
risk_level: DEFAULT_RISK_LEVEL.to_string(),
risk_tags: Vec::new(),
human_reviewer: String::new(),
reviewed_at: None,
followup_actions: Vec::new(),
regression_requirements: Vec::new(),
notes: String::new(),
},
decision_status_options: vec![
"accepted".to_string(),
"deferred".to_string(),
"rejected".to_string(),
"needs_more_evidence".to_string(),
DEFAULT_DECISION_STATUS.to_string(),
],
risk_level_options: vec![
"low".to_string(),
"medium".to_string(),
"high".to_string(),
DEFAULT_RISK_LEVEL.to_string(),
],
review_checklist: review_checklist.to_vec(),
}
}
fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
let checklist = document
.review_checklist
.iter()
.map(|item| format!("- [ ] {item}"))
.collect::<Vec<_>>()
.join("\n");
let analysis_files = document
.review_context
.analysis_artifacts
.iter()
.map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path))
.collect::<Vec<_>>()
.join("\n");
format!(
"# Lime 人工审核与决策记录\n\n\
> 状态:`{decision_status}`\n\
> 导出时间:`{exported_at}`\n\
> 说明:这份模板用于把外部 Claude Code / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\
## 1. 审核上下文\n\
- 标题:{title}\n\
- session_id:`{session_id}`\n\
- thread_id:`{thread_id}`\n\
- 线程状态:`{thread_status}`\n\
- 最新 Turn:`{latest_turn_status}`\n\
- 待处理请求:`{pending_request_count}`\n\
- 排队任务:`{queued_turn_count}`\n\
- analysis 目录:`{analysis_relative_root}`\n\
- handoff 目录:`{handoff_bundle_relative_root}`\n\
- evidence 目录:`{evidence_pack_relative_root}`\n\
- replay 目录:`{replay_case_relative_root}`\n\n\
### 关联分析文件\n\
{analysis_files}\n\n\
## 2. 上游对齐\n\
- 执行环境参照:`codex`\n\
- 运行时事实源:`aster-rust`\n\
- 产品承接面:`lime`\n\n\
## 3. 审核清单\n\
{checklist}\n\n\
## 4. 决策状态\n\
- 当前值:`{decision_status}`\n\
- 可选值:`accepted` / `deferred` / `rejected` / `needs_more_evidence`\n\n\
## 5. 决策摘要\n\
待填写。\n\n\
## 6. 采用的修复策略\n\
待填写。\n\n\
## 7. 风险等级与标签\n\
- 风险等级:`{risk_level}`\n\
- 风险标签:待填写\n\n\
## 8. 回归要求\n\
- 待填写\n\n\
## 9. 后续动作\n\
- 待填写\n\n\
## 10. 审核备注\n\
- 审核人:待填写\n\
- 审核时间:待填写\n\
- 备注:待填写\n",
decision_status = document.decision.decision_status,
exported_at = document.exported_at,
title = empty_fallback(&document.review_context.title, "未命名"),
session_id = document.review_context.session_id,
thread_id = document.review_context.thread_id,
thread_status = empty_fallback(&document.review_context.thread_status, "unknown"),
latest_turn_status = document
.review_context
.latest_turn_status
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("unknown"),
pending_request_count = document.review_context.pending_request_count,
queued_turn_count = document.review_context.queued_turn_count,
analysis_relative_root = document.review_context.analysis_relative_root,
handoff_bundle_relative_root = document.review_context.handoff_bundle_relative_root,
evidence_pack_relative_root = document.review_context.evidence_pack_relative_root,
replay_case_relative_root = document.review_context.replay_case_relative_root,
analysis_files = if analysis_files.is_empty() {
"- 待补充".to_string()
} else {
analysis_files
},
checklist = if checklist.is_empty() {
"- [ ] 待补充审核清单".to_string()
} else {
checklist
},
risk_level = document.decision.risk_level,
)
}
fn build_review_checklist() -> Vec<String> {
vec![
"先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(),
"确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(),
"确认修复范围仍落在 current 主链,没有把 compat / deprecated 路径重新接回主线。"
.to_string(),
"明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(),
"把最终决定记录为 accepted / deferred / rejected / needs_more_evidence 之一。".to_string(),
]
}
fn review_analysis_artifact_kind_key(
kind: &crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind,
) -> &'static str {
match kind {
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisBrief => {
"analysis_brief"
}
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisContext => {
"analysis_context"
}
}
}
fn write_review_decision_artifact(
kind: RuntimeReviewDecisionArtifactKind,
title: &str,
absolute_path: &Path,
relative_path: &str,
contents: &[u8],
) -> Result<RuntimeReviewDecisionArtifact, String> {
fs::write(absolute_path, contents).map_err(|error| {
format!(
"写入 review decision 文件失败 {}: {error}",
absolute_path.display()
)
})?;
Ok(RuntimeReviewDecisionArtifact {
kind,
title: title.to_string(),
relative_path: relative_path.to_string(),
absolute_path: to_portable_path(&absolute_path.to_string_lossy()),
bytes: contents.len(),
})
}
fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str {
if value.trim().is_empty() {
fallback
} else {
value
}
}
fn to_portable_path(value: &str) -> String {
value.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::QueuedTurnSnapshot;
use crate::commands::aster_agent_cmd::{
AgentRuntimeDiagnosticPendingRequestSample, AgentRuntimeRequestView,
AgentRuntimeThreadDiagnostics,
};
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus,
};
use serde_json::json;
use tempfile::TempDir;
fn build_detail() -> SessionDetail {
SessionDetail {
id: "session-1".to_string(),
thread_id: "thread-1".to_string(),
workspace_id: Some("workspace-1".to_string()),
name: "Harness Review Demo".to_string(),
model: Some("gpt-5.4".to_string()),
working_dir: Some("/tmp/workspace".to_string()),
created_at: 1,
updated_at: 2,
execution_strategy: Some("react".to_string()),
messages: Vec::new(),
execution_runtime: None,
turns: vec![AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "请导出 review decision 模板。".to_string(),
status: AgentThreadTurnStatus::Completed,
started_at: "2026-03-27T10:00:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
error_message: None,
created_at: "2026-03-27T10:00:00Z".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
}],
items: vec![
AgentThreadItem {
id: "item-plan-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:10Z".to_string(),
completed_at: Some("2026-03-27T10:00:10Z".to_string()),
updated_at: "2026-03-27T10:00:10Z".to_string(),
payload: AgentThreadItemPayload::Plan {
text: "补 review decision 模板导出".to_string(),
},
},
AgentThreadItem {
id: "item-summary-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 2,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:01:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
updated_at: "2026-03-27T10:01:00Z".to_string(),
payload: AgentThreadItemPayload::TurnSummary {
text: "外部分析已可导出,下一步需要固定人工审核记录。".to_string(),
},
},
],
todo_items: vec![lime_agent::SessionTodoItem {
content: "导出人工审核记录".to_string(),
status: serde_json::from_value(json!("in_progress")).expect("status"),
active_form: None,
}],
child_subagent_sessions: vec![],
subagent_parent_context: None,
}
}
fn build_thread_read() -> AgentRuntimeThreadReadModel {
AgentRuntimeThreadReadModel {
thread_id: "thread-1".to_string(),
status: "waiting_request".to_string(),
active_turn_id: Some("turn-1".to_string()),
pending_requests: vec![AgentRuntimeRequestView {
id: "req-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
item_id: Some("request-1".to_string()),
request_type: "approval_request".to_string(),
status: "pending".to_string(),
title: Some("是否接受最小修复".to_string()),
payload: None,
decision: None,
scope: None,
created_at: None,
resolved_at: None,
}],
last_outcome: None,
incidents: Vec::new(),
queued_turns: vec![QueuedTurnSnapshot {
queued_turn_id: "queued-1".to_string(),
message_preview: "继续补 review decision".to_string(),
message_text: "继续补 review decision".to_string(),
created_at: 3,
image_count: 0,
position: 1,
}],
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
diagnostics: Some(AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("action_required".to_string()),
latest_turn_started_at: None,
latest_turn_completed_at: None,
latest_turn_updated_at: None,
latest_turn_elapsed_seconds: None,
latest_turn_stalled_seconds: None,
latest_turn_error_message: None,
interrupt_reason: None,
runtime_interrupt_source: None,
runtime_interrupt_requested_at: None,
runtime_interrupt_wait_seconds: None,
warning_count: 0,
context_compaction_count: 0,
failed_tool_call_count: 0,
failed_command_count: 0,
pending_request_count: 1,
oldest_pending_request_wait_seconds: None,
primary_blocking_kind: Some("pending_request".to_string()),
primary_blocking_summary: Some("等待人工审核修复方案".to_string()),
latest_warning: None,
latest_context_compaction: None,
latest_failed_tool: None,
latest_failed_command: None,
latest_pending_request: Some(AgentRuntimeDiagnosticPendingRequestSample {
request_id: "req-1".to_string(),
turn_id: Some("turn-1".to_string()),
request_type: "approval_request".to_string(),
title: Some("是否接受最小修复".to_string()),
waited_seconds: Some(10),
created_at: None,
}),
}),
}
}
#[test]
fn should_export_runtime_review_decision_template_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
let result =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
assert_eq!(
result.review_relative_root,
".lime/harness/sessions/session-1/review"
);
assert_eq!(result.default_decision_status, "pending_review");
assert_eq!(result.artifacts.len(), 2);
assert_eq!(result.analysis_artifacts.len(), 2);
assert!(!result.review_checklist.is_empty());
let markdown_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.md");
let json_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.json");
assert!(markdown_path.exists());
assert!(json_path.exists());
let markdown = fs::read_to_string(markdown_path).expect("markdown");
assert!(markdown.contains("人工审核与决策记录"));
assert!(markdown.contains("analysis-brief.md"));
assert!(markdown.contains("aster-rust"));
assert!(markdown.contains("pending_review"));
let json = fs::read_to_string(json_path).expect("json");
assert!(json.contains("\"contractShape\": \"lime_review_decision_template\""));
assert!(json.contains("\"decisionStatus\": \"pending_review\""));
assert!(json.contains("\"executionEnvironmentReference\": \"codex\""));
assert!(json.contains("\"runtimeFactSource\": \"aster-rust\""));
}
}
@@ -386,7 +386,7 @@ fn load_site_adapters_from_str(
document
.adapters
.into_iter()
.map(|entry| manifest_entry_to_spec(entry, dir, source_kind))
.map(|entry| manifest_entry_to_spec(normalize_manifest_entry(entry), dir, source_kind))
.collect()
}
@@ -429,6 +429,40 @@ fn manifest_entry_to_spec(
})
}
fn normalize_manifest_entry(mut entry: SiteAdapterManifestEntry) -> SiteAdapterManifestEntry {
if should_upgrade_legacy_github_search_entry(&entry) {
entry.entry = SiteAdapterEntryManifest::UrlTemplate {
template: "https://github.com/search?q={{query|urlencode}}&type=repositories"
.to_string(),
};
}
entry
}
fn should_upgrade_legacy_github_search_entry(entry: &SiteAdapterManifestEntry) -> bool {
if normalize_site_adapter_name(&entry.name) != "github/search" {
return false;
}
let has_query_arg = entry.args.iter().any(|arg| {
arg.name == "query" && matches!(arg.arg_type, SiteAdapterArgTypeManifest::String)
});
if !has_query_arg {
return false;
}
matches!(
&entry.entry,
SiteAdapterEntryManifest::FixedUrl { url }
if normalize_fixed_url(url) == "https://github.com/search"
)
}
fn normalize_fixed_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_ascii_lowercase()
}
fn extract_site_adapter_catalog_from_bootstrap_payload<'a>(
payload: &'a Value,
) -> Option<&'a Value> {
@@ -502,7 +536,7 @@ fn write_server_synced_catalog_to_dir(
format!("写入站点适配器脚本失败 {}: {error}", script_path.display())
})?;
adapters.push(SiteAdapterManifestEntry {
adapters.push(normalize_manifest_entry(SiteAdapterManifestEntry {
name: normalize_required_text(&entry.name, "name")?,
domain: normalize_required_text(&entry.domain, "domain")?,
description: normalize_required_text(&entry.description, "description")?,
@@ -514,7 +548,7 @@ fn write_server_synced_catalog_to_dir(
entry: entry.entry,
script_file,
source_version: normalize_optional_text(entry.source_version),
});
}));
}
let document = SiteAdapterRegistryDocument {
@@ -842,6 +876,64 @@ mod tests {
assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1"));
}
#[test]
fn should_upgrade_legacy_server_synced_github_search_fixed_url_to_template() {
let temp_dir = tempdir().expect("temp dir should exist");
let dir = temp_dir.path();
fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist");
fs::write(
dir.join("index.json"),
r#"
{
"adapters": [
{
"name": "github/search",
"domain": "github.com",
"description": "server synced",
"read_only": true,
"capabilities": ["search"],
"args": [
{
"name": "query",
"description": "搜索关键词",
"required": true,
"arg_type": "string",
"example": "mcp"
}
],
"example": "github/search {\"query\":\"mcp\"}",
"entry": {
"kind": "fixed_url",
"url": "https://github.com/search"
},
"script_file": "scripts/github-search.js",
"source_version": "sync-legacy"
}
]
}
"#,
)
.expect("index should write");
fs::write(
dir.join("scripts/github-search.js"),
"async () => ({ ok: true })",
)
.expect("script should write");
let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::ServerSynced)
.expect("server synced adapters should load");
let github = adapters
.iter()
.find(|adapter| adapter.name == "github/search")
.expect("github/search should exist");
let mut args = Map::new();
args.insert("query".to_string(), Value::String("mcp".to_string()));
let url = build_entry_url(github, &args).expect("entry url should build");
assert_eq!(url, "https://github.com/search?q=mcp&type=repositories");
}
#[test]
fn should_extract_site_adapter_catalog_from_nested_bootstrap_payload() {
let payload = serde_json::json!({
File diff suppressed because it is too large Load Diff