新增工作流检查点日志模块
- 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:
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user