mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.97.0
This commit is contained in:
@@ -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": "日报正文内容" }
|
||||
]
|
||||
}"#,
|
||||
¶ms,
|
||||
)
|
||||
.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("社媒初稿")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user