diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/checkpoint.rs new file mode 100644 index 000000000..656aa8a0c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/checkpoint.rs @@ -0,0 +1,390 @@ +//! 工作流检查点日志:`ui/.<文档名>-workflow.jsonl`。 +//! +//! 这个模块只管日志机制——行格式、原子追加、半行截断、轮次扫描——不认识任何 +//! 业务 DTO 与 State 类型,步骤载荷一律按 `serde_json::Value` 原样存回。 +//! 方案见 `docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md`。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs::OpenOptions; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +const WORKFLOW_LOG_SUFFIX: &str = "-workflow.jsonl"; +const TORN_TAIL_SCAN_BYTES: u64 = 64 * 1024; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub(crate) enum CheckpointLine { + /// 一轮的起点:原始 State 快照与起始 revision。 + Run { + at: u64, + doc: CheckpointDocument, + revision: u64, + state: Value, + }, + Recognize { + at: u64, + dto: Value, + }, + Separate { + at: u64, + dto: Value, + }, + /// 一轮的结束:文档已写回这个 revision。 + WriteBack { + at: u64, + revision: u64, + }, + /// 一轮的结束:本轮作废,不再尝试恢复。 + Outdated { + at: u64, + reason: String, + }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CheckpointDocument { + pub(crate) asset_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RoundOutcome { + /// 还没有结束行:这一轮需要恢复。 + Open, + WrittenBack { + revision: u64, + }, + Outdated { + reason: String, + }, +} + +/// 最后一轮的可恢复内容。`base_state` 是 `run` 行的原始 State 快照。 +#[derive(Clone, Debug)] +pub(crate) struct WorkflowRound { + pub(crate) at: u64, + pub(crate) document_asset_id: String, + pub(crate) base_revision: u64, + pub(crate) base_state: Value, + pub(crate) recognize_dto: Option, + pub(crate) separate_dto: Option, + pub(crate) outcome: RoundOutcome, +} + +pub(crate) struct WorkflowLog { + path: PathBuf, +} + +impl WorkflowLog { + pub(crate) fn open(root: &Path, document_relative_path: &str) -> Result { + Ok(Self { + path: workflow_log_path(root, document_relative_path)?, + }) + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } + + /// 读取最后一轮。文件不存在或没有完整 `run` 行时返回 `None`。 + pub(crate) fn last_round(&self) -> Result, String> { + let Some(content) = read_log_content(&self.path)? else { + return Ok(None); + }; + parse_last_round(&self.path, &content) + } + + /// 一次性追加一行。崩溃可能留下写了一半的最后一行,追加前先把日志截断到最后 + /// 一个换行,避免半行夹在日志中间让后续扫描失败。 + pub(crate) fn append(&self, line: &CheckpointLine) -> Result<(), String> { + let mut bytes = + serde_json::to_vec(line).map_err(|error| format!("序列化工作流检查点失败:{error}"))?; + bytes.push(b'\n'); + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!("创建工作流检查点目录失败:{}:{error}", parent.display()) + })?; + } + let mut file = OpenOptions::new() + .create(true) + .read(true) + .append(true) + .open(&self.path) + .map_err(|error| { + format!("打开工作流检查点日志失败:{}:{error}", self.path.display()) + })?; + truncate_torn_tail(&mut file, &self.path)?; + file.write_all(&bytes) + .map_err(|error| format!("追加工作流检查点失败:{}:{error}", self.path.display()))?; + file.sync_all() + .map_err(|error| format!("同步工作流检查点失败:{}:{error}", self.path.display()))?; + Ok(()) + } +} + +/// 文档旁的同级隐藏日志:`ui/UI 设计 1.json` → `ui/.UI 设计 1-workflow.jsonl`。 +pub(crate) fn workflow_log_path( + root: &Path, + document_relative_path: &str, +) -> Result { + let document_relative_path = crate::normalize_relative_path(document_relative_path.trim())?; + let document_path = Path::new(&document_relative_path); + let stem = document_path + .file_stem() + .and_then(|value| value.to_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "UI 设计文档路径缺少文件名".to_string())?; + let directory = document_path.parent().unwrap_or_else(|| Path::new("")); + Ok(root + .join(directory) + .join(format!(".{stem}{WORKFLOW_LOG_SUFFIX}"))) +} + +fn read_log_content(path: &Path) -> Result, String> { + match std::fs::read(path) { + Ok(bytes) => String::from_utf8(bytes) + .map(Some) + .map_err(|error| format!("工作流检查点日志不是 UTF-8:{}:{error}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "读取工作流检查点日志失败:{}:{error}", + path.display() + )), + } +} + +fn truncate_torn_tail(file: &mut std::fs::File, path: &Path) -> Result<(), String> { + let length = file + .metadata() + .map_err(|error| format!("读取工作流检查点长度失败:{}:{error}", path.display()))? + .len(); + if length == 0 { + return Ok(()); + } + let start = length.saturating_sub(TORN_TAIL_SCAN_BYTES); + let mut tail = Vec::new(); + file.seek(SeekFrom::Start(start)) + .and_then(|_| file.take(length - start).read_to_end(&mut tail)) + .map_err(|error| format!("读取工作流检查点末尾失败:{}:{error}", path.display()))?; + if tail.last() == Some(&b'\n') { + return Ok(()); + } + if let Some(index) = tail.iter().rposition(|byte| *byte == b'\n') { + return file + .set_len(start + index as u64 + 1) + .map_err(|error| format!("截断工作流检查点半行失败:{}:{error}", path.display())); + } + // 末尾 64 KiB 一个换行都没有:退回到整文件扫描,避免把完整行截掉。 + let mut whole = Vec::new(); + file.seek(SeekFrom::Start(0)) + .and_then(|_| file.read_to_end(&mut whole)) + .map_err(|error| format!("读取工作流检查点失败:{}:{error}", path.display()))?; + let keep = whole + .iter() + .rposition(|byte| *byte == b'\n') + .map(|index| index as u64 + 1) + .unwrap_or(0); + file.set_len(keep) + .map_err(|error| format!("截断工作流检查点半行失败:{}:{error}", path.display())) +} + +fn parse_last_round(path: &Path, content: &str) -> Result, String> { + let chunks: Vec<&str> = content.split('\n').collect(); + let last_index = chunks.len().saturating_sub(1); + let mut round: Option = None; + for (index, raw) in chunks.iter().enumerate() { + if raw.is_empty() { + continue; + } + let line = match serde_json::from_str::(raw) { + Ok(line) => line, + // 没有换行收尾的最后一段是崩溃留下的半行,整段丢弃且不算完成。 + Err(_) if index == last_index && !content.ends_with('\n') => break, + Err(error) => { + return Err(format!( + "工作流检查点日志第 {} 行无法解析:{}:{error}", + index + 1, + path.display() + )) + } + }; + apply_line(&mut round, line); + } + Ok(round) +} + +fn apply_line(round: &mut Option, line: CheckpointLine) { + match line { + CheckpointLine::Run { + at, + doc, + revision, + state, + } => { + *round = Some(WorkflowRound { + at, + document_asset_id: doc.asset_id, + base_revision: revision, + base_state: state, + recognize_dto: None, + separate_dto: None, + outcome: RoundOutcome::Open, + }); + } + CheckpointLine::Recognize { dto, .. } => { + if let Some(round) = round.as_mut() { + round.recognize_dto = Some(dto); + } + } + CheckpointLine::Separate { dto, .. } => { + if let Some(round) = round.as_mut() { + round.separate_dto = Some(dto); + } + } + CheckpointLine::WriteBack { revision, .. } => { + if let Some(round) = round.as_mut() { + if round.outcome == RoundOutcome::Open { + round.outcome = RoundOutcome::WrittenBack { revision }; + } + } + } + CheckpointLine::Outdated { reason, .. } => { + if let Some(round) = round.as_mut() { + if round.outcome == RoundOutcome::Open { + round.outcome = RoundOutcome::Outdated { reason }; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn log(directory: &tempfile::TempDir) -> WorkflowLog { + WorkflowLog::open(directory.path(), "ui/UI 设计 1.json").expect("open workflow log") + } + + fn run_line(at: u64, revision: u64) -> CheckpointLine { + CheckpointLine::Run { + at, + doc: CheckpointDocument { + asset_id: "generated-1-1".to_string(), + }, + revision, + state: json!({ "ui_trees": [] }), + } + } + + #[test] + fn log_path_sits_next_to_the_document() { + let directory = tempfile::tempdir().expect("temp dir"); + assert_eq!( + workflow_log_path(directory.path(), "ui/UI 设计 1.json").expect("path"), + directory + .path() + .join("ui") + .join(".UI 设计 1-workflow.jsonl") + ); + } + + #[test] + fn open_round_reports_recorded_steps() { + let directory = tempfile::tempdir().expect("temp dir"); + let log = log(&directory); + assert!(log.last_round().expect("empty log").is_none()); + log.append(&run_line(10, 3)).expect("append run"); + log.append(&CheckpointLine::Recognize { + at: 11, + dto: json!({ "ui_trees": [{ "src_ui_design": "page" }] }), + }) + .expect("append recognize"); + let round = log.last_round().expect("scan").expect("round"); + assert_eq!(round.base_revision, 3); + assert_eq!(round.document_asset_id, "generated-1-1"); + assert!(round.recognize_dto.is_some()); + assert!(round.separate_dto.is_none()); + assert_eq!(round.outcome, RoundOutcome::Open); + } + + #[test] + fn later_rounds_shadow_earlier_ones() { + let directory = tempfile::tempdir().expect("temp dir"); + let log = log(&directory); + log.append(&run_line(10, 1)).expect("append run"); + log.append(&CheckpointLine::WriteBack { + at: 11, + revision: 2, + }) + .expect("append write back"); + log.append(&run_line(20, 2)).expect("append second run"); + log.append(&CheckpointLine::Outdated { + at: 21, + reason: "doc-revision-drift".to_string(), + }) + .expect("append outdated"); + let round = log.last_round().expect("scan").expect("round"); + assert_eq!(round.base_revision, 2); + assert_eq!( + round.outcome, + RoundOutcome::Outdated { + reason: "doc-revision-drift".to_string(), + } + ); + } + + #[test] + fn written_back_round_is_closed() { + let directory = tempfile::tempdir().expect("temp dir"); + let log = log(&directory); + log.append(&run_line(10, 1)).expect("append run"); + log.append(&CheckpointLine::WriteBack { + at: 11, + revision: 2, + }) + .expect("append write back"); + let round = log.last_round().expect("scan").expect("round"); + assert_eq!(round.outcome, RoundOutcome::WrittenBack { revision: 2 }); + } + + #[test] + fn half_written_line_is_dropped_on_append() { + let directory = tempfile::tempdir().expect("temp dir"); + let log = log(&directory); + log.append(&run_line(10, 1)).expect("append run"); + { + let mut file = OpenOptions::new() + .append(true) + .open(log.path()) + .expect("open raw log"); + file.write_all(b"{\"type\":\"separate\",\"at\":12,\"dt") + .expect("write torn tail"); + } + let round = log.last_round().expect("scan").expect("round"); + assert!(round.separate_dto.is_none()); + assert_eq!(round.outcome, RoundOutcome::Open); + log.append(&CheckpointLine::WriteBack { + at: 13, + revision: 2, + }) + .expect("append after torn tail"); + let round = log.last_round().expect("scan").expect("round"); + assert_eq!(round.outcome, RoundOutcome::WrittenBack { revision: 2 }); + let content = std::fs::read_to_string(log.path()).expect("read log"); + assert!(!content.contains("\"dt")); + assert!(content.ends_with('\n')); + } + + #[test] + fn malformed_complete_line_fails_closed() { + let directory = tempfile::tempdir().expect("temp dir"); + let log = log(&directory); + std::fs::create_dir_all(log.path().parent().expect("parent")).expect("create dir"); + std::fs::write(log.path(), "{\"type\":\"nonsense\"}\n").expect("write bad line"); + assert!(log.last_round().is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs index c47123540..55350a4a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs @@ -1,3 +1,4 @@ +mod checkpoint; mod creation; pub(crate) use creation::{