新增工作流检查点日志模块

- design_doc/checkpoint.rs:run / recognize / separate / write-back / outdated 五种行
- 路径推导 ui/.<文档名>-workflow.jsonl,与文档同级且不进 manifest
- 追加时先截断崩溃留下的半行再一次性写入并 sync,避免半行夹在日志中间
- 扫描只保留最后一轮,写回或 outdated 视为轮次结束;坏行失败关闭
- 载荷一律按 serde_json::Value 存回,模块不认识业务 DTO 与 State 类型
- 六个单测覆盖路径推导、轮次遮蔽、两种结束行、半行丢弃与坏行失败
This commit is contained in:
2026-09-23 19:24:07 +08:00
parent 8ac8f2ff5d
commit f9a2cbe26e
2 changed files with 391 additions and 0 deletions
@@ -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<Value>,
pub(crate) separate_dto: Option<Value>,
pub(crate) outcome: RoundOutcome,
}
pub(crate) struct WorkflowLog {
path: PathBuf,
}
impl WorkflowLog {
pub(crate) fn open(root: &Path, document_relative_path: &str) -> Result<Self, String> {
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<Option<WorkflowRound>, 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<PathBuf, String> {
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<Option<String>, 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<Option<WorkflowRound>, String> {
let chunks: Vec<&str> = content.split('\n').collect();
let last_index = chunks.len().saturating_sub(1);
let mut round: Option<WorkflowRound> = None;
for (index, raw) in chunks.iter().enumerate() {
if raw.is_empty() {
continue;
}
let line = match serde_json::from_str::<CheckpointLine>(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<WorkflowRound>, 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());
}
}
@@ -1,3 +1,4 @@
mod checkpoint;
mod creation;
pub(crate) use creation::{