release: v1.12.0

This commit is contained in:
coso
2026-04-16 23:32:03 +08:00
parent dfca03a5e5
commit 558e80e5a7
1775 changed files with 64611 additions and 15755 deletions
File diff suppressed because it is too large Load Diff
@@ -1128,6 +1128,11 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map<String, Value>
}) {
metadata.insert("service_skill".to_string(), service_skill.clone());
}
if let Some(sceneapp) = request_metadata
.and_then(|value| value.get("sceneapp").or_else(|| value.get("sceneApp")))
{
metadata.insert("sceneapp".to_string(), sceneapp.clone());
}
let harness = request_metadata
.and_then(|value| value.get("harness"))
@@ -64,6 +64,10 @@ impl ExecutionTracker {
}
}
pub fn db(&self) -> &DbConnection {
&self.db
}
pub fn start(
&self,
source: RunSource,
@@ -2,9 +2,12 @@
//!
//! 将配置中的记忆来源(AGENTS、规则、自动记忆等)统一解析为可观察结果与可注入提示词片段。
use crate::services::auto_memory_service::{get_auto_memory_index, resolve_auto_memory_root};
use crate::services::auto_memory_service::{
get_auto_memory_index, infer_memdir_memory_type, resolve_auto_memory_root, MemdirMemoryType,
};
use crate::services::memory_import_parser_service::{parse_memory_file, MemoryImportParseOptions};
use crate::services::memory_rules_loader_service::load_rules;
use chrono::{DateTime, Utc};
use lime_agent::{
resolve_durable_memory_root, to_virtual_memory_path, DURABLE_MEMORY_VIRTUAL_ROOT,
};
@@ -19,12 +22,26 @@ use std::path::{Path, PathBuf};
const DURABLE_MEMORY_MAX_DEPTH: usize = 4;
const DURABLE_MEMORY_MAX_FILES: usize = 64;
const AUTO_MEMORY_LINKED_ITEM_LIMIT: usize = 8;
const AUTO_MEMORY_LINKED_ITEM_LINE_LIMIT: usize = 40;
/// 单个来源解析结果
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EffectiveMemorySource {
/// 来源类型:managed_policy/project/user/local/rule/auto_memory/additional
pub kind: String,
/// 归属来源桶:managed/user/project/local/rules/auto/durable/additional
#[serde(skip_serializing_if = "Option::is_none")]
pub source_bucket: Option<String>,
/// 当前命中的 provider 标识;memdir 主链会显式写入 `memdir`
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// memdir 条目类型
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_type: Option<MemdirMemoryType>,
/// 最近更新时间
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
/// 来源路径
pub path: String,
/// 文件或目录是否存在
@@ -322,6 +339,67 @@ pub fn build_memory_sources_prompt_with_options(
}
}
fn build_effective_memory_source(
kind: &str,
path: String,
exists: bool,
loaded: bool,
line_count: u32,
import_count: u32,
warnings: Vec<String>,
preview: Option<String>,
updated_at: Option<i64>,
memory_type: Option<MemdirMemoryType>,
) -> EffectiveMemorySource {
EffectiveMemorySource {
kind: kind.to_string(),
source_bucket: source_bucket_for_kind(kind).map(str::to_string),
provider: provider_for_kind(kind).map(str::to_string),
memory_type,
updated_at,
path,
exists,
loaded,
line_count,
import_count,
warnings,
preview,
}
}
fn source_bucket_for_kind(kind: &str) -> Option<&'static str> {
match kind {
"managed_policy" => Some("managed"),
"user_memory" => Some("user"),
"project_memory" | "workspace_agents" => Some("project"),
"project_local" => Some("local"),
"project_rule" | "project_rules" => Some("rules"),
"auto_memory" | "auto_memory_item" => Some("auto"),
"durable_memory" => Some("durable"),
"additional_memory" => Some("additional"),
_ => None,
}
}
fn provider_for_kind(kind: &str) -> Option<&'static str> {
match kind {
"auto_memory" | "auto_memory_item" => Some("memdir"),
_ => None,
}
}
fn memory_type_for_kind(kind: &str, path: &str) -> Option<MemdirMemoryType> {
match kind {
"auto_memory" | "auto_memory_item" => infer_memdir_memory_type(path),
_ => None,
}
}
fn read_path_updated_at(path: &Path) -> Option<i64> {
let modified = fs::metadata(path).ok()?.modified().ok()?;
Some(DateTime::<Utc>::from(modified).timestamp_millis())
}
fn runtime_agent_overlap_paths(working_dir: &Path) -> HashSet<PathBuf> {
let mut paths = HashSet::new();
paths.insert(normalize_path(&app_paths::best_effort_user_memory_path()));
@@ -387,16 +465,18 @@ fn resolve_file_source_with_display_path(
if !include_missing {
return;
}
output.push(EffectiveMemorySource {
kind: kind.to_string(),
path: display_path,
exists: false,
loaded: false,
line_count: 0,
import_count: 0,
warnings: Vec::new(),
preview: None,
});
output.push(build_effective_memory_source(
kind,
display_path,
false,
false,
0,
0,
Vec::new(),
None,
None,
None,
));
return;
}
@@ -416,16 +496,18 @@ fn resolve_file_source_with_display_path(
0
};
output.push(EffectiveMemorySource {
kind: kind.to_string(),
path: display_path.clone(),
exists: true,
output.push(build_effective_memory_source(
kind,
display_path.clone(),
true,
loaded,
line_count,
import_count: parsed.imported_files.len() as u32,
warnings: parsed.warnings.clone(),
parsed.imported_files.len() as u32,
parsed.warnings.clone(),
preview,
});
read_path_updated_at(&normalized),
memory_type_for_kind(kind, display_path.as_str()),
));
if loaded {
prompt_segments.push(format!("### {} ({})\n{}", kind, display_path, content));
@@ -437,16 +519,18 @@ fn resolve_file_source_with_display_path(
}
}
Err(err) => {
output.push(EffectiveMemorySource {
kind: kind.to_string(),
path: display_path,
exists: true,
loaded: false,
line_count: 0,
import_count: 0,
warnings: vec![err],
preview: None,
});
output.push(build_effective_memory_source(
kind,
display_path,
true,
false,
0,
0,
vec![err],
None,
read_path_updated_at(&normalized),
memory_type_for_kind(kind, normalized.to_string_lossy().as_ref()),
));
}
}
}
@@ -462,16 +546,18 @@ fn resolve_durable_memory_sources(
let root = match resolve_durable_memory_root() {
Ok(path) => path,
Err(err) => {
output.push(EffectiveMemorySource {
kind: "durable_memory".to_string(),
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
exists: false,
loaded: false,
line_count: 0,
import_count: 0,
warnings: vec![format!("解析 durable memory 根目录失败: {err}")],
preview: None,
});
output.push(build_effective_memory_source(
"durable_memory",
DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
false,
false,
0,
0,
vec![format!("解析 durable memory 根目录失败: {err}")],
None,
None,
None,
));
return;
}
};
@@ -483,16 +569,18 @@ fn resolve_durable_memory_sources(
) {
Ok(files) => files,
Err(err) => {
output.push(EffectiveMemorySource {
kind: "durable_memory".to_string(),
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
exists: root.exists(),
loaded: false,
line_count: 0,
import_count: 0,
warnings: vec![format!("扫描 durable memory 文件失败: {err}")],
preview: None,
});
output.push(build_effective_memory_source(
"durable_memory",
DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
root.exists(),
false,
0,
0,
vec![format!("扫描 durable memory 文件失败: {err}")],
None,
read_path_updated_at(&root),
None,
));
return;
}
};
@@ -503,16 +591,18 @@ fn resolve_durable_memory_sources(
} else {
vec!["记忆功能已关闭".to_string()]
};
output.push(EffectiveMemorySource {
kind: "durable_memory".to_string(),
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
exists: root.exists(),
loaded: false,
line_count: 0,
import_count: 0,
output.push(build_effective_memory_source(
"durable_memory",
DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
root.exists(),
false,
0,
0,
warnings,
preview: None,
});
None,
read_path_updated_at(&root),
None,
));
return;
}
@@ -554,16 +644,18 @@ fn resolve_rule_sources(
if !include_missing {
return;
}
output.push(EffectiveMemorySource {
kind: "project_rules".to_string(),
path: normalized.to_string_lossy().to_string(),
exists: false,
loaded: false,
line_count: 0,
import_count: 0,
warnings: Vec::new(),
preview: None,
});
output.push(build_effective_memory_source(
"project_rules",
normalized.to_string_lossy().to_string(),
false,
false,
0,
0,
Vec::new(),
None,
None,
None,
));
return;
}
@@ -572,16 +664,18 @@ fn resolve_rule_sources(
if !include_missing {
return;
}
output.push(EffectiveMemorySource {
kind: "project_rules".to_string(),
path: normalized.to_string_lossy().to_string(),
exists: true,
loaded: false,
line_count: 0,
import_count: 0,
warnings: vec!["规则目录存在,但未发现可用规则".to_string()],
preview: None,
});
output.push(build_effective_memory_source(
"project_rules",
normalized.to_string_lossy().to_string(),
true,
false,
0,
0,
vec!["规则目录存在,但未发现可用规则".to_string()],
None,
read_path_updated_at(&normalized),
None,
));
return;
}
@@ -599,24 +693,26 @@ fn resolve_rule_sources(
rule.path_patterns.join(", ")
));
}
output.push(EffectiveMemorySource {
kind: "project_rule".to_string(),
path: normalized_rule.to_string_lossy().to_string(),
exists: true,
output.push(build_effective_memory_source(
"project_rule",
normalized_rule.to_string_lossy().to_string(),
true,
loaded,
line_count: if loaded {
if loaded {
rule.content.lines().count() as u32
} else {
0
},
import_count: 0,
0,
warnings,
preview: if loaded {
if loaded {
Some(clip_text(&rule.content, 300))
} else {
None
},
});
read_path_updated_at(&normalized_rule),
None,
));
if loaded {
prompt_segments.push(format!(
@@ -649,7 +745,8 @@ fn resolve_auto_memory_source(
} else {
entry_name
};
let entry_path = normalize_path(&auto_root.join(entry_name));
let entry_display_path = auto_root.join(entry_name);
let entry_path = normalize_path(&entry_display_path);
if !seen.insert(entry_path.clone()) {
return;
}
@@ -658,53 +755,155 @@ fn resolve_auto_memory_source(
match index {
Ok(idx) => {
let loaded = idx.entry_exists && !idx.preview_lines.is_empty();
output.push(EffectiveMemorySource {
kind: "auto_memory".to_string(),
path: entry_path.to_string_lossy().to_string(),
exists: idx.entry_exists,
output.push(build_effective_memory_source(
"auto_memory",
entry_display_path.to_string_lossy().to_string(),
idx.entry_exists,
loaded,
line_count: idx.total_lines,
import_count: idx.items.len() as u32,
warnings: if !memory_config.auto.enabled {
idx.total_lines,
idx.items.len() as u32,
if !memory_config.auto.enabled {
vec!["自动记忆已关闭".to_string()]
} else {
Vec::new()
},
preview: if loaded {
if loaded {
Some(clip_text(&idx.preview_lines.join("\n"), 300))
} else {
None
},
});
read_path_updated_at(&entry_path),
None,
));
if loaded {
prompt_segments.push(format!(
"### auto_memory ({})\n{}",
entry_path.display(),
entry_display_path.display(),
idx.preview_lines.join("\n")
));
prompt_sources.push(MemoryPromptSegment {
title: "auto_memory".to_string(),
path: entry_path.to_string_lossy().to_string(),
path: entry_display_path.to_string_lossy().to_string(),
content: idx.preview_lines.join("\n"),
});
let sorted_items = sort_auto_memory_prompt_items(&idx.items);
for item in sorted_items.iter().take(AUTO_MEMORY_LINKED_ITEM_LIMIT) {
let item_display_path = auto_root.join(&item.relative_path);
let item_path = normalize_path(&item_display_path);
if !seen.insert(item_path.clone()) {
continue;
}
let Ok(content) = fs::read_to_string(&item_path) else {
output.push(build_effective_memory_source(
"auto_memory_item",
item_display_path.to_string_lossy().to_string(),
item.exists,
false,
0,
0,
vec![format!(
"读取 memdir 条目失败: {}",
item_display_path.display()
)],
None,
item.updated_at,
item.memory_type,
));
continue;
};
let preview_lines = content
.lines()
.take(AUTO_MEMORY_LINKED_ITEM_LINE_LIMIT)
.map(|line| line.to_string())
.collect::<Vec<_>>();
let item_loaded = !preview_lines.is_empty();
output.push(build_effective_memory_source(
"auto_memory_item",
item_display_path.to_string_lossy().to_string(),
item.exists,
item_loaded,
content.lines().count() as u32,
0,
Vec::new(),
if item_loaded {
Some(clip_text(&preview_lines.join("\n"), 300))
} else {
None
},
item.updated_at.or_else(|| read_path_updated_at(&item_path)),
item.memory_type,
));
if item_loaded {
prompt_segments.push(format!(
"### auto_memory_item ({})\n{}",
item_display_path.display(),
preview_lines.join("\n")
));
prompt_sources.push(MemoryPromptSegment {
title: format!("auto_memory_item: {}", item.title),
path: item_display_path.to_string_lossy().to_string(),
content: preview_lines.join("\n"),
});
}
}
}
}
Err(err) => {
output.push(EffectiveMemorySource {
kind: "auto_memory".to_string(),
path: entry_path.to_string_lossy().to_string(),
exists: entry_path.exists(),
loaded: false,
line_count: 0,
import_count: 0,
warnings: vec![err],
preview: None,
});
output.push(build_effective_memory_source(
"auto_memory",
entry_display_path.to_string_lossy().to_string(),
entry_path.exists(),
false,
0,
0,
vec![err],
None,
read_path_updated_at(&entry_path),
None,
));
}
}
}
fn sort_auto_memory_prompt_items(
items: &[crate::services::auto_memory_service::AutoMemoryIndexItem],
) -> Vec<crate::services::auto_memory_service::AutoMemoryIndexItem> {
let mut sorted = items.to_vec();
sorted.sort_by(|left, right| {
auto_memory_prompt_item_rank(left)
.cmp(&auto_memory_prompt_item_rank(right))
.then_with(|| {
right
.updated_at
.unwrap_or_default()
.cmp(&left.updated_at.unwrap_or_default())
})
.then_with(|| left.relative_path.cmp(&right.relative_path))
});
sorted
}
fn auto_memory_prompt_item_rank(
item: &crate::services::auto_memory_service::AutoMemoryIndexItem,
) -> usize {
let is_readme = item.relative_path.ends_with("/README.md")
|| item.relative_path.ends_with("/README.markdown")
|| item.relative_path.ends_with("/README.mdx")
|| item.relative_path.eq_ignore_ascii_case("README.md");
match (item.memory_type.is_some(), is_readme) {
(true, false) => 0,
(true, true) => 1,
(false, false) => 2,
(false, true) => 3,
}
}
fn collect_durable_memory_files(
root: &Path,
max_depth: usize,
@@ -1059,6 +1258,106 @@ mod tests {
.any(|segment| segment.contains("/memories/MEMORY.md")));
}
#[test]
fn should_resolve_memdir_topic_items_with_provider_metadata() {
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
let tmp = TempDir::new().expect("create temp dir");
let empty_durable_root = tmp.path().join("durable-empty");
fs::create_dir_all(&empty_durable_root).expect("create empty durable root");
let _durable_guard = DurableMemoryEnvGuard::set(&empty_durable_root);
let mut cfg = Config::default();
cfg.memory.enabled = true;
cfg.memory.auto.enabled = true;
cfg.memory.auto.root_dir = Some(tmp.path().join("memdir").to_string_lossy().to_string());
cfg.memory.auto.entrypoint = "MEMORY.md".to_string();
cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string());
cfg.memory.sources.project_memory_paths = Vec::new();
cfg.memory.sources.project_rule_dirs = Vec::new();
crate::services::auto_memory_service::scaffold_memdir(&cfg.memory, tmp.path(), false)
.expect("scaffold memdir");
crate::services::auto_memory_service::update_auto_memory_note(
&cfg.memory,
tmp.path(),
"Why:\n- 团队多次确认 pnpm only 可以避免锁文件漂移。\n\nHow to apply:\n- 涉及依赖安装时默认使用 pnpm,并保留锁文件。",
Some("workflow"),
Some(MemdirMemoryType::Feedback),
)
.expect("write feedback note");
let resolved = resolve_effective_sources(&cfg, tmp.path(), None);
let auto_root = tmp.path().join("memdir");
let entry_path = auto_root.join("MEMORY.md").to_string_lossy().to_string();
let topic_path = auto_root
.join("feedback/workflow.md")
.to_string_lossy()
.to_string();
assert!(resolved.response.sources.iter().any(|source| {
source.kind == "auto_memory"
&& source.source_bucket.as_deref() == Some("auto")
&& source.provider.as_deref() == Some("memdir")
&& source.updated_at.is_some()
&& source.path == entry_path
}));
assert!(resolved.response.sources.iter().any(|source| {
source.kind == "auto_memory_item"
&& source.path == topic_path
&& source.loaded
&& source.source_bucket.as_deref() == Some("auto")
&& source.provider.as_deref() == Some("memdir")
&& source.memory_type == Some(MemdirMemoryType::Feedback)
&& source.updated_at.is_some()
}));
assert!(resolved
.prompt_segments
.iter()
.any(|segment| segment.contains("pnpm only")));
}
#[test]
fn should_prioritize_specific_memdir_topic_before_type_readme_in_prompt_sources() {
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
let tmp = TempDir::new().expect("create temp dir");
let empty_durable_root = tmp.path().join("durable-empty");
fs::create_dir_all(&empty_durable_root).expect("create empty durable root");
let _durable_guard = DurableMemoryEnvGuard::set(&empty_durable_root);
let mut cfg = Config::default();
cfg.memory.enabled = true;
cfg.memory.auto.enabled = true;
cfg.memory.auto.root_dir = Some(tmp.path().join("memdir").to_string_lossy().to_string());
cfg.memory.auto.entrypoint = "MEMORY.md".to_string();
cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string());
cfg.memory.sources.project_memory_paths = Vec::new();
cfg.memory.sources.project_rule_dirs = Vec::new();
crate::services::auto_memory_service::scaffold_memdir(&cfg.memory, tmp.path(), false)
.expect("scaffold memdir");
crate::services::auto_memory_service::update_auto_memory_note(
&cfg.memory,
tmp.path(),
"Why:\n- 团队多次确认 pnpm only 可以避免锁文件漂移。\n\nHow to apply:\n- 涉及依赖安装时默认使用 pnpm,并保留锁文件。",
Some("workflow"),
Some(MemdirMemoryType::Feedback),
)
.expect("write feedback note");
let resolved = resolve_effective_sources(&cfg, tmp.path(), None);
let first_item = resolved
.prompt_sources
.iter()
.find(|source| source.title.starts_with("auto_memory_item:"))
.expect("find first auto memory item");
assert!(first_item.path.ends_with("feedback/workflow.md"));
}
#[test]
fn workspace_local_instruction_path_should_not_walk_ancestors() {
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
+1
View File
@@ -32,6 +32,7 @@ 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_file_checkpoint_service;
pub mod runtime_handoff_artifact_service;
pub mod runtime_replay_case_service;
pub mod runtime_review_decision_service;
@@ -1,7 +1,7 @@
//! Runtime analysis handoff 导出服务
//!
//! 将 handoff bundle / evidence pack / replay case 重新包装成
//! 外部 Claude Code / Codex 更容易直接消费的 analysis handoff。
//! 外部代码助手 / Codex 更容易直接消费的 analysis handoff。
//! 这条链仍然只负责导出证据与现成提示词,不在 Lime 内自动分析或自动修复。
use crate::agent::SessionDetail;
@@ -742,7 +742,7 @@ fn build_reading_order() -> Vec<String> {
fn build_external_analysis_contract() -> AnalysisExternalContract {
AnalysisExternalContract {
audience: "Claude Code / Codex".to_string(),
audience: "外部代码助手 / Codex".to_string(),
task: "基于 Lime 导出的结构化证据做问题分析与修复建议,不直接代替团队做最终决策。"
.to_string(),
required_sections: vec![
@@ -1309,6 +1309,7 @@ mod tests {
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
latest_compaction_boundary: None,
file_checkpoint_summary: None,
diagnostics: Some(AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("action_required".to_string()),
latest_turn_started_at: None,
@@ -5,7 +5,11 @@
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use crate::database::DbConnection;
use crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION;
use crate::services::runtime_file_checkpoint_service::list_file_checkpoints;
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
use crate::workspace::WorkspaceManager;
use chrono::Utc;
use lime_core::database::dao::agent_timeline::AgentThreadItemPayload;
use lime_infra::telemetry::RequestLog;
@@ -68,6 +72,19 @@ pub struct RuntimeEvidencePackExportResult {
pub artifacts: Vec<RuntimeEvidenceArtifact>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct RuntimeEvidenceSceneAppSnapshot {
pub recent_artifact_paths: Vec<String>,
pub workspace_root: Option<String>,
pub known_gaps: Vec<String>,
pub verification_failure_outcomes: Vec<String>,
pub request_telemetry_available: bool,
pub request_telemetry_matched_count: usize,
pub artifact_validator_applicable: bool,
pub artifact_validator_issue_count: usize,
pub artifact_validator_recovered_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeVerificationOutcome {
Success,
@@ -160,10 +177,11 @@ pub fn export_runtime_evidence_pack(
.iter()
.map(|artifact| artifact.path.clone())
.collect::<Vec<_>>();
let file_checkpoints = list_file_checkpoints(detail);
let latest_turn_summary = collect_latest_turn_summary(detail);
let request_telemetry = collect_request_telemetry(detail, workspace_root.as_path());
let verification =
collect_runtime_verification(detail, workspace_root.as_path(), &recent_artifacts);
collect_runtime_verification(detail, Some(workspace_root.as_path()), &recent_artifacts);
let signal_coverage = build_signal_coverage(
thread_read,
&recent_artifacts,
@@ -209,6 +227,7 @@ pub fn export_runtime_evidence_pack(
thread_read,
workspace_root.as_path(),
&recent_artifact_paths,
&file_checkpoints.checkpoints,
&observability_summary,
&known_gaps,
exported_at.as_str(),
@@ -232,6 +251,7 @@ pub fn export_runtime_evidence_pack(
detail,
thread_read,
&recent_artifact_paths,
&file_checkpoints.checkpoints,
&observability_summary,
&request_telemetry,
&verification,
@@ -265,6 +285,93 @@ pub fn export_runtime_evidence_pack(
})
}
pub(crate) fn resolve_runtime_export_workspace_root(
db: &DbConnection,
detail: &SessionDetail,
) -> Result<PathBuf, String> {
if let Some(workspace_id) = detail
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let manager = WorkspaceManager::new(db.clone());
let workspace_id = workspace_id.to_string();
let workspace = manager
.get(&workspace_id)
.map_err(|error| format!("读取 workspace 失败: {error}"))?
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
return Ok(ensured.root_path);
}
if let Some(working_dir) = detail
.working_dir
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(PathBuf::from(working_dir));
}
Err("当前会话缺少 workspace / working_dir,无法导出运行时制品".to_string())
}
pub(crate) fn build_runtime_evidence_sceneapp_snapshot(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: Option<&Path>,
) -> RuntimeEvidenceSceneAppSnapshot {
let recent_artifacts = collect_recent_artifacts(detail);
let request_telemetry = workspace_root
.map(|root| collect_request_telemetry(detail, root))
.unwrap_or_default();
let verification = collect_runtime_verification(detail, workspace_root, &recent_artifacts);
let signal_coverage = build_signal_coverage(
thread_read,
&recent_artifacts,
&request_telemetry,
&verification,
);
let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage);
RuntimeEvidenceSceneAppSnapshot {
recent_artifact_paths: recent_artifacts
.into_iter()
.map(|artifact| artifact.path)
.collect(),
workspace_root: workspace_root.map(|path| path.to_string_lossy().to_string()),
known_gaps,
verification_failure_outcomes: extract_verification_failure_outcomes(&verification),
request_telemetry_available: !request_telemetry.searched_roots.is_empty(),
request_telemetry_matched_count: request_telemetry.matched_request_count,
artifact_validator_applicable: verification.artifact_validator.applicable,
artifact_validator_issue_count: verification
.artifact_validator
.records
.iter()
.map(|record| {
record
.get("issues")
.and_then(Value::as_array)
.map(|issues| issues.len())
.unwrap_or(0)
})
.sum(),
artifact_validator_recovered_count: verification
.artifact_validator
.records
.iter()
.filter(|record| {
record
.get("repaired")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.count(),
}
}
fn write_evidence_file(
pack_root: &Path,
session_id: &str,
@@ -402,6 +509,7 @@ fn build_runtime_json(
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
recent_artifacts: &[String],
file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary],
observability_summary: &Value,
known_gaps: &[String],
exported_at: &str,
@@ -486,6 +594,8 @@ fn build_runtime_json(
}).collect::<Vec<_>>(),
"observabilitySummary": observability_summary,
"recentArtifacts": recent_artifacts,
"fileCheckpointCount": file_checkpoints.len(),
"fileCheckpoints": file_checkpoints,
"knownGaps": known_gaps
});
@@ -529,6 +639,7 @@ fn build_artifacts_json(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
recent_artifacts: &[String],
file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary],
observability_summary: &Value,
request_telemetry: &RuntimeRequestTelemetrySummary,
verification: &RuntimeEvidenceVerificationSummary,
@@ -540,6 +651,8 @@ fn build_artifacts_json(
"exportedAt": exported_at,
"recentArtifacts": recent_artifacts,
"artifactCount": recent_artifacts.len(),
"fileCheckpointCount": file_checkpoints.len(),
"fileCheckpoints": file_checkpoints,
"observabilitySummary": observability_summary,
"requests": {
"pending": thread_read.pending_requests.iter().map(|item| {
@@ -891,7 +1004,7 @@ fn build_runtime_observability_summary_json(
fn collect_runtime_verification(
detail: &SessionDetail,
workspace_root: &Path,
workspace_root: Option<&Path>,
recent_artifacts: &[RuntimeRecentArtifact],
) -> RuntimeEvidenceVerificationSummary {
RuntimeEvidenceVerificationSummary {
@@ -902,7 +1015,7 @@ fn collect_runtime_verification(
}
fn collect_artifact_validator_summary(
workspace_root: &Path,
workspace_root: Option<&Path>,
recent_artifacts: &[RuntimeRecentArtifact],
) -> RuntimeArtifactValidatorSummary {
let mut summary = RuntimeArtifactValidatorSummary::default();
@@ -920,10 +1033,12 @@ fn collect_artifact_validator_summary(
if artifact.path.ends_with(".artifact.json") {
applicable = true;
let absolute_path = resolve_workspace_path(workspace_root, artifact.path.as_str());
if let Ok(raw) = fs::read_to_string(&absolute_path) {
if let Ok(document) = serde_json::from_str::<Value>(raw.as_str()) {
candidates.push(document);
if let Some(workspace_root) = workspace_root {
let absolute_path = resolve_workspace_path(workspace_root, artifact.path.as_str());
if let Ok(raw) = fs::read_to_string(&absolute_path) {
if let Ok(document) = serde_json::from_str::<Value>(raw.as_str()) {
candidates.push(document);
}
}
}
}
@@ -943,6 +1058,25 @@ fn collect_artifact_validator_summary(
summary
}
fn extract_verification_failure_outcomes(
verification: &RuntimeEvidenceVerificationSummary,
) -> Vec<String> {
build_observability_verification_summary_json(verification)
.and_then(|summary| {
summary
.get("focusVerificationFailureOutcomes")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect::<Vec<_>>()
})
})
.unwrap_or_default()
}
fn collect_browser_evidence(detail: &SessionDetail) -> Vec<Value> {
let mut evidence = Vec::new();
@@ -1723,6 +1857,7 @@ mod tests {
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
latest_compaction_boundary: None,
file_checkpoint_summary: None,
diagnostics: Some(
crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("running".to_string()),
@@ -1875,6 +2010,10 @@ mod tests {
assert!(runtime.contains("\"sessionId\": \"session-1\""));
assert!(runtime.contains("\"pendingRequestCount\": 1"));
assert!(runtime.contains("\"observabilitySummary\""));
assert!(runtime.contains("\"fileCheckpointCount\": 1"));
assert!(runtime.contains("\"fileCheckpoints\""));
assert!(runtime.contains("\"checkpoint_id\": \"artifact-1\""));
assert!(runtime.contains("\"path\": \".lime/artifacts/thread-1/report.md\""));
assert!(runtime.contains("\"requestTelemetry\""));
assert!(runtime.contains("\"matchedRequestCount\": 1"));
assert!(!runtime.contains("\"verificationSummary\""));
@@ -1889,6 +2028,9 @@ mod tests {
let artifacts = fs::read_to_string(artifacts_path).expect("artifacts");
assert!(artifacts.contains("\"observabilitySummary\""));
assert!(artifacts.contains("\"telemetry\""));
assert!(artifacts.contains("\"fileCheckpointCount\": 1"));
assert!(artifacts.contains("\"fileCheckpoints\""));
assert!(artifacts.contains("\"checkpoint_id\": \"artifact-1\""));
assert!(artifacts.contains("\"matchedRequestCount\": 1"));
assert!(!artifacts.contains("\"verification\""));
}
@@ -0,0 +1,559 @@
//! Runtime file checkpoint 服务
//!
//! 基于当前 SessionDetail -> FileArtifact -> artifact sidecar 主链,
//! 提供文件快照摘要、详情与 diff 读取能力。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::{
AgentRuntimeFileCheckpointDetail, AgentRuntimeFileCheckpointDiffResult,
AgentRuntimeFileCheckpointListResult, AgentRuntimeFileCheckpointSummary,
AgentRuntimeFileCheckpointThreadSummary,
};
use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadItemPayload};
use serde_json::{Map, Value};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
struct RuntimeFileCheckpointRecord {
summary: AgentRuntimeFileCheckpointSummary,
content: Option<String>,
metadata: Option<Value>,
}
pub fn build_thread_file_checkpoint_summary(
detail: &SessionDetail,
) -> Option<AgentRuntimeFileCheckpointThreadSummary> {
let checkpoints = collect_file_checkpoint_records(detail);
if checkpoints.is_empty() {
return None;
}
Some(AgentRuntimeFileCheckpointThreadSummary {
count: checkpoints.len(),
latest_checkpoint: checkpoints.first().map(|record| record.summary.clone()),
})
}
pub fn list_file_checkpoints(detail: &SessionDetail) -> AgentRuntimeFileCheckpointListResult {
let checkpoints = collect_file_checkpoint_records(detail)
.into_iter()
.map(|record| record.summary)
.collect::<Vec<_>>();
AgentRuntimeFileCheckpointListResult {
session_id: detail.id.clone(),
thread_id: detail.thread_id.clone(),
checkpoint_count: checkpoints.len(),
checkpoints,
}
}
pub fn get_file_checkpoint(
detail: &SessionDetail,
workspace_root: &Path,
checkpoint_id: &str,
) -> Result<AgentRuntimeFileCheckpointDetail, String> {
let record = find_file_checkpoint_record(detail, checkpoint_id)?;
let metadata = record.metadata.as_ref();
let snapshot_path = extract_snapshot_path(metadata, record.summary.path.as_str())
.unwrap_or_else(|| record.summary.path.clone());
let checkpoint_document = read_json_relative(workspace_root, snapshot_path.as_str())
.or_else(|| extract_artifact_document(metadata));
let live_document =
read_json_relative(workspace_root, record.summary.path.as_str()).or_else(|| {
if snapshot_path == record.summary.path {
checkpoint_document.clone()
} else {
None
}
});
Ok(AgentRuntimeFileCheckpointDetail {
session_id: detail.id.clone(),
thread_id: detail.thread_id.clone(),
checkpoint: record.summary.clone(),
live_path: record.summary.path.clone(),
snapshot_path,
checkpoint_document,
live_document,
version_history: extract_version_history(metadata),
validation_issues: extract_validation_issues(metadata),
metadata: record.metadata.clone(),
content: record.content.clone(),
})
}
pub fn diff_file_checkpoint(
detail: &SessionDetail,
checkpoint_id: &str,
) -> Result<AgentRuntimeFileCheckpointDiffResult, String> {
let record = find_file_checkpoint_record(detail, checkpoint_id)?;
let metadata = record.metadata.as_ref();
let current_version_id = record.summary.version_id.clone();
let current_version_no = record.summary.version_no;
Ok(AgentRuntimeFileCheckpointDiffResult {
session_id: detail.id.clone(),
thread_id: detail.thread_id.clone(),
checkpoint: record.summary.clone(),
current_version_id,
previous_version_id: extract_previous_version_id(metadata, current_version_no),
diff: extract_version_diff(metadata),
})
}
fn find_file_checkpoint_record(
detail: &SessionDetail,
checkpoint_id: &str,
) -> Result<RuntimeFileCheckpointRecord, String> {
let normalized_checkpoint_id = checkpoint_id.trim();
if normalized_checkpoint_id.is_empty() {
return Err("checkpoint_id 不能为空".to_string());
}
collect_file_checkpoint_records(detail)
.into_iter()
.find(|record| record.summary.checkpoint_id == normalized_checkpoint_id)
.ok_or_else(|| format!("未找到文件快照: {normalized_checkpoint_id}"))
}
fn collect_file_checkpoint_records(detail: &SessionDetail) -> Vec<RuntimeFileCheckpointRecord> {
let mut seen = HashSet::new();
let mut checkpoints = Vec::new();
for item in detail.items.iter().rev() {
let Some(record) = checkpoint_record_from_item(item) else {
continue;
};
if seen.insert(record.summary.checkpoint_id.clone()) {
checkpoints.push(record);
}
}
checkpoints.sort_by(|left, right| {
right
.summary
.updated_at
.cmp(&left.summary.updated_at)
.then_with(|| left.summary.path.cmp(&right.summary.path))
});
checkpoints
}
fn checkpoint_record_from_item(item: &AgentThreadItem) -> Option<RuntimeFileCheckpointRecord> {
let AgentThreadItemPayload::FileArtifact {
path,
source,
content,
metadata,
} = &item.payload
else {
return None;
};
let normalized_path = normalize_optional_text(path.clone())?;
let normalized_source =
normalize_optional_text(source.clone()).unwrap_or_else(|| "runtime".to_string());
let metadata_ref = metadata.as_ref();
let preview_text = extract_preview_text(metadata_ref, content.as_deref());
let version_no = extract_version_no(metadata_ref);
Some(RuntimeFileCheckpointRecord {
summary: AgentRuntimeFileCheckpointSummary {
checkpoint_id: item.id.clone(),
turn_id: item.turn_id.clone(),
path: normalized_path.clone(),
source: normalized_source,
updated_at: item.updated_at.clone(),
version_no,
version_id: extract_version_id(metadata_ref),
request_id: extract_request_id(metadata_ref),
title: extract_title(metadata_ref),
kind: extract_kind(metadata_ref),
status: extract_status(metadata_ref),
preview_text,
snapshot_path: extract_snapshot_path(metadata_ref, normalized_path.as_str()),
validation_issue_count: extract_validation_issues(metadata_ref).len(),
},
content: content.clone(),
metadata: metadata.clone(),
})
}
fn normalize_optional_text(value: String) -> Option<String> {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn metadata_object(metadata: Option<&Value>) -> Option<&Map<String, Value>> {
metadata?.as_object()
}
fn metadata_string(metadata: Option<&Value>, key: &str) -> Option<String> {
metadata_object(metadata)?
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn nested_metadata_string(metadata: Option<&Value>, parent_key: &str, key: &str) -> Option<String> {
metadata_object(metadata)?
.get(parent_key)
.and_then(Value::as_object)?
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn metadata_u32(metadata: Option<&Value>, key: &str) -> Option<u32> {
metadata_object(metadata)?
.get(key)
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
}
fn nested_metadata_u32(metadata: Option<&Value>, parent_key: &str, key: &str) -> Option<u32> {
metadata_object(metadata)?
.get(parent_key)
.and_then(Value::as_object)?
.get(key)
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
}
fn extract_version_no(metadata: Option<&Value>) -> Option<u32> {
metadata_u32(metadata, "artifactVersionNo")
.or_else(|| nested_metadata_u32(metadata, "artifactVersion", "versionNo"))
}
fn extract_version_id(metadata: Option<&Value>) -> Option<String> {
metadata_string(metadata, "artifactVersionId")
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "id"))
}
fn extract_request_id(metadata: Option<&Value>) -> Option<String> {
metadata_string(metadata, "artifactRequestId")
}
fn extract_title(metadata: Option<&Value>) -> Option<String> {
metadata_string(metadata, "artifactTitle")
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "title"))
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "title"))
}
fn extract_kind(metadata: Option<&Value>) -> Option<String> {
metadata_string(metadata, "artifactKind")
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "kind"))
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "kind"))
}
fn extract_status(metadata: Option<&Value>) -> Option<String> {
metadata_string(metadata, "artifactStatus")
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "status"))
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "status"))
}
fn extract_snapshot_path(metadata: Option<&Value>, fallback_path: &str) -> Option<String> {
nested_metadata_string(metadata, "artifactVersion", "snapshotPath")
.or_else(|| metadata_string(metadata, "artifact_path"))
.or_else(|| normalize_optional_text(fallback_path.to_string()))
}
fn extract_preview_text(metadata: Option<&Value>, content: Option<&str>) -> Option<String> {
metadata_string(metadata, "previewText")
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "summary"))
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "title"))
.or_else(|| {
content
.map(str::trim)
.filter(|value| !value.is_empty())
.map(truncate_text)
})
}
fn truncate_text(value: &str) -> String {
const MAX_PREVIEW_CHARS: usize = 240;
let normalized = value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
let mut chars = normalized.chars();
let prefix: String = chars.by_ref().take(MAX_PREVIEW_CHARS).collect();
if chars.next().is_some() {
format!("{prefix}...")
} else {
prefix
}
}
fn extract_validation_issues(metadata: Option<&Value>) -> Vec<String> {
metadata_object(metadata)
.and_then(|record| record.get("artifactValidationIssues"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn extract_artifact_document(metadata: Option<&Value>) -> Option<Value> {
metadata_object(metadata)?.get("artifactDocument").cloned()
}
fn extract_version_history(metadata: Option<&Value>) -> Vec<Value> {
metadata_object(metadata)
.and_then(|record| record.get("artifactVersions"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
fn extract_previous_version_id(
metadata: Option<&Value>,
current_version_no: Option<u32>,
) -> Option<String> {
let current_version_no = current_version_no?;
let previous_version_no = current_version_no.checked_sub(1)?;
extract_version_history(metadata)
.into_iter()
.find_map(|value| {
let record = value.as_object()?;
let version_no = record.get("versionNo").and_then(Value::as_u64)?;
if version_no != u64::from(previous_version_no) {
return None;
}
record
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
}
fn extract_version_diff(metadata: Option<&Value>) -> Option<Value> {
metadata_object(metadata)?
.get("artifactVersionDiff")
.cloned()
}
fn read_json_relative(workspace_root: &Path, relative_path: &str) -> Option<Value> {
let normalized = relative_path.trim();
if normalized.is_empty() {
return None;
}
let path = workspace_root.join(normalized.replace('/', std::path::MAIN_SEPARATOR_STR));
let raw = fs::read_to_string(path).ok()?;
serde_json::from_str::<Value>(&raw).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use lime_core::database::dao::agent_timeline::{AgentThreadItemStatus, AgentThreadTurn};
use tempfile::tempdir;
fn build_detail(item: AgentThreadItem) -> SessionDetail {
SessionDetail {
id: "session-1".to_string(),
name: "测试会话".to_string(),
created_at: 0,
updated_at: 0,
thread_id: "thread-1".to_string(),
model: None,
working_dir: None,
workspace_id: Some("workspace-1".to_string()),
messages: Vec::new(),
execution_strategy: None,
execution_runtime: None,
turns: vec![AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "生成 artifact".to_string(),
status: lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Completed,
started_at: "2026-04-15T00:00:00Z".to_string(),
completed_at: Some("2026-04-15T00:00:01Z".to_string()),
error_message: None,
created_at: "2026-04-15T00:00:00Z".to_string(),
updated_at: "2026-04-15T00:00:01Z".to_string(),
}],
items: vec![item],
todo_items: Vec::new(),
child_subagent_sessions: Vec::new(),
subagent_parent_context: None,
}
}
fn build_item(metadata: Option<Value>) -> AgentThreadItem {
AgentThreadItem {
id: "artifact-document:req-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-04-15T00:00:00Z".to_string(),
completed_at: Some("2026-04-15T00:00:01Z".to_string()),
updated_at: "2026-04-15T00:00:01Z".to_string(),
payload: AgentThreadItemPayload::FileArtifact {
path: ".lime/artifacts/thread-1/demo.artifact.json".to_string(),
source: "artifact_document_service".to_string(),
content: Some("# Demo".to_string()),
metadata,
},
}
}
#[test]
fn build_thread_summary_should_extract_latest_checkpoint_metadata() {
let detail = build_detail(build_item(Some(serde_json::json!({
"artifactVersionNo": 3,
"artifactVersionId": "artifact-document:req-1:v3",
"artifactRequestId": "req-1",
"artifactTitle": "Demo",
"artifactKind": "analysis",
"artifactStatus": "ready",
"previewText": "最新版本摘要",
"artifactVersion": {
"snapshotPath": ".lime/artifacts/thread-1/versions/demo/v0003.artifact.json"
},
"artifactValidationIssues": ["missing-source"]
}))));
let summary = build_thread_file_checkpoint_summary(&detail).expect("summary should exist");
assert_eq!(summary.count, 1);
let latest = summary.latest_checkpoint.expect("latest checkpoint");
assert_eq!(latest.checkpoint_id, "artifact-document:req-1");
assert_eq!(latest.version_no, Some(3));
assert_eq!(
latest.version_id.as_deref(),
Some("artifact-document:req-1:v3")
);
assert_eq!(latest.request_id.as_deref(), Some("req-1"));
assert_eq!(latest.preview_text.as_deref(), Some("最新版本摘要"));
assert_eq!(latest.validation_issue_count, 1);
}
#[test]
fn get_file_checkpoint_should_read_snapshot_and_live_documents() {
let temp_dir = tempdir().expect("temp dir");
let workspace_root = temp_dir.path();
let live_path = workspace_root.join(".lime/artifacts/thread-1/demo.artifact.json");
let snapshot_path =
workspace_root.join(".lime/artifacts/thread-1/versions/demo/v0002.artifact.json");
fs::create_dir_all(live_path.parent().expect("live parent")).expect("live dir");
fs::create_dir_all(snapshot_path.parent().expect("snapshot parent")).expect("snapshot dir");
fs::write(
&live_path,
serde_json::json!({
"title": "当前版本",
"summary": "current"
})
.to_string(),
)
.expect("write live");
fs::write(
&snapshot_path,
serde_json::json!({
"title": "版本 2",
"summary": "snapshot"
})
.to_string(),
)
.expect("write snapshot");
let detail = build_detail(build_item(Some(serde_json::json!({
"artifactVersionNo": 2,
"artifactVersionId": "artifact-document:req-1:v2",
"artifactVersion": {
"id": "artifact-document:req-1:v2",
"versionNo": 2,
"snapshotPath": ".lime/artifacts/thread-1/versions/demo/v0002.artifact.json"
},
"artifactVersions": [
{ "id": "artifact-document:req-1:v1", "versionNo": 1 },
{ "id": "artifact-document:req-1:v2", "versionNo": 2 }
],
"artifactValidationIssues": ["warning-a"]
}))));
let detail_result = get_file_checkpoint(&detail, workspace_root, "artifact-document:req-1")
.expect("detail");
assert_eq!(
detail_result.snapshot_path,
".lime/artifacts/thread-1/versions/demo/v0002.artifact.json"
);
assert_eq!(detail_result.version_history.len(), 2);
assert_eq!(
detail_result.validation_issues,
vec!["warning-a".to_string()]
);
assert_eq!(
detail_result
.checkpoint_document
.as_ref()
.and_then(|value| value.get("title"))
.and_then(Value::as_str),
Some("版本 2")
);
assert_eq!(
detail_result
.live_document
.as_ref()
.and_then(|value| value.get("title"))
.and_then(Value::as_str),
Some("当前版本")
);
}
#[test]
fn diff_file_checkpoint_should_return_metadata_diff_and_previous_version() {
let detail = build_detail(build_item(Some(serde_json::json!({
"artifactVersionNo": 4,
"artifactVersionId": "artifact-document:req-1:v4",
"artifactVersions": [
{ "id": "artifact-document:req-1:v3", "versionNo": 3 },
{ "id": "artifact-document:req-1:v4", "versionNo": 4 }
],
"artifactVersionDiff": {
"summary": "更新结论段与证据链接"
}
}))));
let diff =
diff_file_checkpoint(&detail, "artifact-document:req-1").expect("diff should exist");
assert_eq!(
diff.previous_version_id.as_deref(),
Some("artifact-document:req-1:v3")
);
assert_eq!(
diff.diff
.as_ref()
.and_then(|value| value.get("summary"))
.and_then(Value::as_str),
Some("更新结论段与证据链接")
);
}
}
@@ -967,6 +967,7 @@ mod tests {
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
latest_compaction_boundary: None,
file_checkpoint_summary: None,
diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("completed".to_string()),
latest_turn_started_at: None,
@@ -9,6 +9,7 @@ use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use crate::services::runtime_evidence_pack_service::{
export_runtime_evidence_pack, RuntimeEvidencePackExportResult,
};
use crate::services::runtime_file_checkpoint_service::list_file_checkpoints;
use crate::services::runtime_handoff_artifact_service::{
export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult,
};
@@ -142,6 +143,7 @@ pub fn export_runtime_replay_case(
.and_then(|turn| normalize_optional_text(Some(turn.prompt_text.clone())))
});
let recent_artifacts = collect_recent_artifact_paths(detail);
let file_checkpoints = list_file_checkpoints(detail);
let pending_requests = collect_pending_request_inputs(detail, thread_read);
let recent_timeline = collect_recent_timeline_items(detail);
let observability_summary = read_observability_summary_from_evidence_pack(&evidence_pack)?;
@@ -171,6 +173,7 @@ pub fn export_runtime_replay_case(
latest_plan.as_deref(),
latest_turn_summary.as_deref(),
&recent_artifacts,
&file_checkpoints.checkpoints,
&recent_timeline,
&pending_requests,
&observability_summary,
@@ -310,6 +313,7 @@ fn build_input_json(
latest_plan: Option<&str>,
latest_turn_summary: Option<&str>,
recent_artifacts: &[String],
file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary],
recent_timeline: &[ReplayTimelineItem],
pending_requests: &[ReplayPendingRequestInput],
observability_summary: &Value,
@@ -382,6 +386,8 @@ fn build_input_json(
})
}).collect::<Vec<_>>(),
"recentArtifacts": recent_artifacts,
"fileCheckpointCount": file_checkpoints.len(),
"fileCheckpoints": file_checkpoints,
"recentTimeline": recent_timeline,
"lastOutcome": &thread_read.last_outcome,
"incidents": &thread_read.incidents,
@@ -1182,6 +1188,7 @@ mod tests {
interrupt_state: None,
updated_at: Some("2026-03-27T10:02:00Z".to_string()),
latest_compaction_boundary: None,
file_checkpoint_summary: None,
diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("completed".to_string()),
latest_turn_started_at: None,
@@ -1272,6 +1279,10 @@ mod tests {
));
assert!(input.contains("\"requestId\": \"req-1\""));
assert!(input.contains("\"recentArtifacts\""));
assert!(input.contains("\"fileCheckpointCount\": 1"));
assert!(input.contains("\"fileCheckpoints\""));
assert!(input.contains("\"checkpoint_id\": \"artifact-1\""));
assert!(input.contains("\"path\": \".lime/artifacts/thread-1/report.md\""));
assert!(input.contains("\"classification\""));
assert!(input.contains("\"suiteTags\""));
assert!(input.contains("\"failureModes\""));
@@ -1,6 +1,6 @@
//! Runtime review decision 模板导出与保存服务
//!
//! 将外部 Claude Code / Codex 的分析结论回挂为
//! 将外部代码助手 / Codex 的分析结论回挂为
//! Lime 工作区内可版本化的人工审核与决策记录模板。
//! 这条链只导出与保存 review-decision,不在 Lime 内自动批准或自动应用修复。
@@ -369,7 +369,7 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
"# Lime 人工审核与决策记录\n\n\
> 状态:`{decision_status}`\n\
> 导出时间:`{exported_at}`\n\
> 说明:这份模板用于把外部 Claude Code / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\
> 说明:这份模板用于把外部代码助手 / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\
## 1. 审核上下文\n\
- 标题:{title}\n\
- session_id:`{session_id}`\n\
@@ -1170,6 +1170,7 @@ mod tests {
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
latest_compaction_boundary: None,
file_checkpoint_summary: None,
diagnostics: Some(AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("action_required".to_string()),
latest_turn_started_at: None,