恢复工作流时只读快照,不再重放已完成步骤的改动
- 检查点每步除记录结果外,再落一份「该步应用完之后」的状态快照,切分那步另带回填出错说明,旧日志无快照仍可解析 - 主流程只应用本次新完成那一步的改动,已完成步骤整步跳过,不再重复登记切图或重复累加回填说明 - 崩溃恢复改为只读检查点里的状态快照并逐级取回,删掉按记录结果重算目标状态的旧实现 - 只有结果、没有状态快照的旧检查点行按未完成处理,由主流程重跑该步 - 漂移与写回幂等判据改为对照快照:状态既不是本轮起点、也不是切分后那份才算漂移;文档已等于切分后快照即视为写回完成 - 同步 ADR、技术方案、代码地图与共享记忆里的检查点口径
This commit is contained in:
@@ -27,21 +27,22 @@ pub(crate) enum CheckpointLine {
|
||||
Recognize {
|
||||
at: u64,
|
||||
dto: Value,
|
||||
/// 这一步在内存里应用完之后的 State 快照。恢复时直接读回来,
|
||||
/// 不再按 dto 重放这一步的 delta。旧版日志没有这一栏,缺失即视为该步不可恢复。
|
||||
state: Option<Value>,
|
||||
},
|
||||
Separate {
|
||||
at: u64,
|
||||
dto: Value,
|
||||
state: Option<Value>,
|
||||
/// 这一步产出的回填说明:恢复不再重跑该步,所以和状态快照一起存。
|
||||
#[serde(default)]
|
||||
backfill_errors: Vec<String>,
|
||||
},
|
||||
/// 一轮的结束:文档已写回这个 revision。
|
||||
WriteBack {
|
||||
at: u64,
|
||||
revision: u64,
|
||||
},
|
||||
WriteBack { at: u64, revision: u64 },
|
||||
/// 一轮的结束:本轮作废,不再尝试恢复。
|
||||
Outdated {
|
||||
at: u64,
|
||||
reason: String,
|
||||
},
|
||||
Outdated { at: u64, reason: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
@@ -62,7 +63,8 @@ pub(crate) enum RoundOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
/// 最后一轮的可恢复内容。`base_state` 是 `run` 行的原始 State 快照。
|
||||
/// 最后一轮的可恢复内容。`base_state` 是 `run` 行的原始 State 快照,
|
||||
/// 各步的 `*_state` 是那一步做完之后的 State 快照。
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct WorkflowRound {
|
||||
pub(crate) at: u64,
|
||||
@@ -70,7 +72,10 @@ pub(crate) struct WorkflowRound {
|
||||
pub(crate) base_revision: u64,
|
||||
pub(crate) base_state: Value,
|
||||
pub(crate) recognize_dto: Option<Value>,
|
||||
pub(crate) recognize_state: Option<Value>,
|
||||
pub(crate) separate_dto: Option<Value>,
|
||||
pub(crate) separate_state: Option<Value>,
|
||||
pub(crate) backfill_errors: Vec<String>,
|
||||
pub(crate) outcome: RoundOutcome,
|
||||
}
|
||||
|
||||
@@ -234,18 +239,29 @@ fn apply_line(round: &mut Option<WorkflowRound>, line: CheckpointLine) {
|
||||
base_revision: revision,
|
||||
base_state: state,
|
||||
recognize_dto: None,
|
||||
recognize_state: None,
|
||||
separate_dto: None,
|
||||
separate_state: None,
|
||||
backfill_errors: Vec::new(),
|
||||
outcome: RoundOutcome::Open,
|
||||
});
|
||||
}
|
||||
CheckpointLine::Recognize { dto, .. } => {
|
||||
CheckpointLine::Recognize { dto, state, .. } => {
|
||||
if let Some(round) = round.as_mut() {
|
||||
round.recognize_dto = Some(dto);
|
||||
round.recognize_state = state;
|
||||
}
|
||||
}
|
||||
CheckpointLine::Separate { dto, .. } => {
|
||||
CheckpointLine::Separate {
|
||||
dto,
|
||||
state,
|
||||
backfill_errors,
|
||||
..
|
||||
} => {
|
||||
if let Some(round) = round.as_mut() {
|
||||
round.separate_dto = Some(dto);
|
||||
round.separate_state = state;
|
||||
round.backfill_errors = backfill_errors;
|
||||
}
|
||||
}
|
||||
CheckpointLine::WriteBack { revision, .. } => {
|
||||
@@ -310,16 +326,80 @@ mod tests {
|
||||
log.append(&CheckpointLine::Recognize {
|
||||
at: 11,
|
||||
dto: json!({ "ui_trees": [{ "src_ui_design": "page" }] }),
|
||||
state: None,
|
||||
})
|
||||
.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.recognize_state.is_none());
|
||||
assert!(round.separate_dto.is_none());
|
||||
assert_eq!(round.outcome, RoundOutcome::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_snapshots_and_backfill_errors_round_trip() {
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
let log = log(&directory);
|
||||
log.append(&run_line(10, 1)).expect("append run");
|
||||
log.append(&CheckpointLine::Recognize {
|
||||
at: 11,
|
||||
dto: json!({ "ui_trees": [] }),
|
||||
state: Some(json!({ "ui_trees": [], "recognized": true })),
|
||||
})
|
||||
.expect("append recognize");
|
||||
log.append(&CheckpointLine::Separate {
|
||||
at: 12,
|
||||
dto: json!({ "bound_nodes": [], "problematic_nodes": [] }),
|
||||
state: Some(json!({ "ui_trees": [], "recognized": true, "separated": true })),
|
||||
backfill_errors: vec!["素材缺失:assets/cut-1.png".to_string()],
|
||||
})
|
||||
.expect("append separate");
|
||||
|
||||
let round = log.last_round().expect("scan").expect("round");
|
||||
assert_eq!(
|
||||
round.recognize_state,
|
||||
Some(json!({ "ui_trees": [], "recognized": true }))
|
||||
);
|
||||
assert_eq!(
|
||||
round.separate_state,
|
||||
Some(json!({ "ui_trees": [], "recognized": true, "separated": true }))
|
||||
);
|
||||
assert_eq!(
|
||||
round.backfill_errors,
|
||||
vec!["素材缺失:assets/cut-1.png".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_lines_without_step_snapshots_still_parse() {
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
std::fs::create_dir_all(log_path(&directory).parent().expect("parent"))
|
||||
.expect("create dir");
|
||||
// 旧版日志只有 dto、没有状态快照:必须仍然可读,缺失的步骤由调用方按未完成处理。
|
||||
std::fs::write(
|
||||
log_path(&directory),
|
||||
concat!(
|
||||
"{\"type\":\"run\",\"at\":1,\"doc\":{\"assetId\":\"generated-1-1\"},\"revision\":3,",
|
||||
"\"state\":{\"ui_trees\":[]}}\n",
|
||||
"{\"type\":\"recognize\",\"at\":2,\"dto\":{\"ui_trees\":[]}}\n",
|
||||
"{\"type\":\"separate\",\"at\":3,\"dto\":{\"bound_nodes\":[],\"problematic_nodes\":[]}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write legacy log");
|
||||
|
||||
let round = log(&directory)
|
||||
.last_round()
|
||||
.expect("legacy log parses")
|
||||
.expect("round");
|
||||
assert!(round.recognize_dto.is_some());
|
||||
assert!(round.recognize_state.is_none());
|
||||
assert!(round.separate_dto.is_some());
|
||||
assert!(round.separate_state.is_none());
|
||||
assert!(round.backfill_errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_rounds_shadow_earlier_ones() {
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -9,11 +9,11 @@ use super::checkpoint::{
|
||||
checkpoint_timestamp, CheckpointDocument, CheckpointLine, RoundOutcome, WorkflowLog,
|
||||
WorkflowRound,
|
||||
};
|
||||
use super::steps::recognize::apply_recognition;
|
||||
use super::steps::separate::{add_sprite_assets, apply_separation, register_cut_image_sprites};
|
||||
use super::steps::write_back::{
|
||||
checkpoint_drift_message, record_write_back, WriteBackTarget, DRIFT_REASON,
|
||||
};
|
||||
use super::steps::{rebuild_target_state, recognize::apply_recognition};
|
||||
use crate::enforce_project_permission_policy;
|
||||
use crate::ui_editor::commands::separation::finalize_separation;
|
||||
use crate::ui_editor::commands::{
|
||||
@@ -71,7 +71,7 @@ pub(crate) async fn run_ui_design_doc_workflow(
|
||||
.filter(|round| round.outcome == RoundOutcome::Open);
|
||||
let recovered_from_checkpoint = resume.is_some();
|
||||
let mut progress = match &resume {
|
||||
Some(round) => resume_progress(root, &log, &snapshot, round, &input.asset_id)?,
|
||||
Some(round) => resume_progress(&log, &snapshot, round, &input.asset_id)?,
|
||||
None => {
|
||||
let state = snapshot.state.clone();
|
||||
log.append(&CheckpointLine::Run {
|
||||
@@ -93,6 +93,7 @@ pub(crate) async fn run_ui_design_doc_workflow(
|
||||
};
|
||||
|
||||
let recognize_dto = match progress.recorded_recognize.take() {
|
||||
// 这一步上一轮已经做完:State 随检查点一起恢复,整步跳过,不再重放它的 delta。
|
||||
Some(dto) => dto,
|
||||
None => {
|
||||
let dto = recognize_ui_impl_with_provider(
|
||||
@@ -101,14 +102,15 @@ pub(crate) async fn run_ui_design_doc_workflow(
|
||||
provider_identity,
|
||||
)
|
||||
.await?;
|
||||
apply_recognition(&mut progress.state, &dto)?;
|
||||
log.append(&CheckpointLine::Recognize {
|
||||
at: checkpoint_timestamp(),
|
||||
dto: to_checkpoint_value(&dto)?,
|
||||
state: Some(to_checkpoint_value(&progress.state)?),
|
||||
})?;
|
||||
dto
|
||||
}
|
||||
};
|
||||
apply_recognition(&mut progress.state, &recognize_dto)?;
|
||||
|
||||
let separate_dto = match progress.recorded_separate.take() {
|
||||
Some(dto) => dto,
|
||||
@@ -119,22 +121,25 @@ pub(crate) async fn run_ui_design_doc_workflow(
|
||||
progress.state.clone(),
|
||||
)
|
||||
.await?;
|
||||
// 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。
|
||||
let cut_images = register_cut_image_sprites(root, &dto)?;
|
||||
let mut backfill_errors = cut_images.errors;
|
||||
progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?;
|
||||
backfill_errors.extend(apply_separation(
|
||||
&mut progress.state,
|
||||
&dto,
|
||||
&cut_images.by_path,
|
||||
));
|
||||
progress.backfill_errors = backfill_errors;
|
||||
log.append(&CheckpointLine::Separate {
|
||||
at: checkpoint_timestamp(),
|
||||
dto: to_checkpoint_value(&dto)?,
|
||||
state: Some(to_checkpoint_value(&progress.state)?),
|
||||
backfill_errors: progress.backfill_errors.clone(),
|
||||
})?;
|
||||
dto
|
||||
}
|
||||
};
|
||||
// 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。
|
||||
let cut_images = register_cut_image_sprites(root, &separate_dto)?;
|
||||
progress.backfill_errors.extend(cut_images.errors);
|
||||
progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?;
|
||||
progress.backfill_errors.extend(apply_separation(
|
||||
&mut progress.state,
|
||||
&separate_dto,
|
||||
&cut_images.by_path,
|
||||
));
|
||||
|
||||
let revision = if progress.already_written_back {
|
||||
record_write_back(&log, snapshot.revision)?;
|
||||
@@ -164,11 +169,13 @@ pub(crate) async fn run_ui_design_doc_workflow(
|
||||
})
|
||||
}
|
||||
|
||||
/// 恢复一轮未完成的工作流:先按记录重建目标 State,若文档已经等于目标 State,
|
||||
/// 说明保存成功但缺 `write-back` 行,直接补行;否则文档必须仍是本轮起点,否则判
|
||||
/// 漂移并追加 `outdated`。
|
||||
/// 恢复一轮未完成的工作流。
|
||||
///
|
||||
/// 已完成的步骤只从检查点取结果:State 直接读回那一步的快照,整步不再重放,所以
|
||||
/// 既不会重复登记切图,也不会把回填说明重复累加进本轮报告。文档等于切分后那份
|
||||
/// State 说明上一轮已经写回、只差 `write-back` 行;文档既不等于本轮起点、也不等于
|
||||
/// 切分后 State 时判漂移并追加 `outdated`。
|
||||
fn resume_progress(
|
||||
root: &Path,
|
||||
log: &WorkflowLog,
|
||||
snapshot: &UiDesignStateSnapshot,
|
||||
round: &WorkflowRound,
|
||||
@@ -180,49 +187,243 @@ fn resume_progress(
|
||||
round.document_asset_id
|
||||
));
|
||||
}
|
||||
let state: State = serde_json::from_value(round.base_state.clone())
|
||||
let base_state: State = serde_json::from_value(round.base_state.clone())
|
||||
.map_err(|error| format!("工作流检查点的起始 State 无法还原:{error}"))?;
|
||||
let recorded_recognize = round
|
||||
.recognize_dto
|
||||
.clone()
|
||||
.map(serde_json::from_value::<RecognitionDTO>)
|
||||
.transpose()
|
||||
.map_err(|error| format!("工作流检查点的识别结果无法还原:{error}"))?;
|
||||
let recorded_separate = round
|
||||
.separate_dto
|
||||
.clone()
|
||||
.map(serde_json::from_value::<SeparationDTO>)
|
||||
.transpose()
|
||||
.map_err(|error| format!("工作流检查点的切分结果无法还原:{error}"))?;
|
||||
if let Some(separate) = &recorded_separate {
|
||||
let (target, backfill_errors) =
|
||||
rebuild_target_state(root, &state, recorded_recognize.as_ref(), separate)?;
|
||||
if snapshot.state == target {
|
||||
return Ok(WorkflowProgress {
|
||||
state: target,
|
||||
recorded_recognize,
|
||||
recorded_separate,
|
||||
backfill_errors,
|
||||
already_written_back: true,
|
||||
});
|
||||
let mut progress = WorkflowProgress {
|
||||
state: base_state.clone(),
|
||||
recorded_recognize: None,
|
||||
recorded_separate: None,
|
||||
backfill_errors: Vec::new(),
|
||||
already_written_back: false,
|
||||
};
|
||||
// 只认「带状态快照」的步骤为已完成:旧日志没有快照,按没做过处理,让主流程重跑那一步。
|
||||
if let (Some(dto), Some(state)) = (&round.recognize_dto, &round.recognize_state) {
|
||||
progress.state = serde_json::from_value(state.clone())
|
||||
.map_err(|error| format!("工作流检查点的识别后 State 无法还原:{error}"))?;
|
||||
progress.recorded_recognize = Some(
|
||||
serde_json::from_value::<RecognitionDTO>(dto.clone())
|
||||
.map_err(|error| format!("工作流检查点的识别结果无法还原:{error}"))?,
|
||||
);
|
||||
}
|
||||
// 切分后的 State 已经含识别结果,只有识别那步也认下来了才用它接着走。
|
||||
if progress.recorded_recognize.is_some() {
|
||||
if let (Some(dto), Some(state)) = (&round.separate_dto, &round.separate_state) {
|
||||
progress.state = serde_json::from_value(state.clone())
|
||||
.map_err(|error| format!("工作流检查点的切分后 State 无法还原:{error}"))?;
|
||||
progress.recorded_separate = Some(
|
||||
serde_json::from_value::<SeparationDTO>(dto.clone())
|
||||
.map_err(|error| format!("工作流检查点的切分结果无法还原:{error}"))?,
|
||||
);
|
||||
progress.backfill_errors = round.backfill_errors.clone();
|
||||
}
|
||||
}
|
||||
if snapshot.state != state {
|
||||
if progress.recorded_separate.is_some() && snapshot.state == progress.state {
|
||||
progress.already_written_back = true;
|
||||
return Ok(progress);
|
||||
}
|
||||
if snapshot.state != base_state {
|
||||
log.append(&CheckpointLine::Outdated {
|
||||
at: checkpoint_timestamp(),
|
||||
reason: DRIFT_REASON.to_string(),
|
||||
})?;
|
||||
return Err(checkpoint_drift_message(snapshot.revision, round));
|
||||
}
|
||||
Ok(WorkflowProgress {
|
||||
state,
|
||||
recorded_recognize,
|
||||
recorded_separate,
|
||||
backfill_errors: Vec::new(),
|
||||
already_written_back: false,
|
||||
})
|
||||
Ok(progress)
|
||||
}
|
||||
|
||||
fn to_checkpoint_value<T: Serialize>(value: &T) -> Result<Value, String> {
|
||||
serde_json::to_value(value).map_err(|error| format!("序列化工作流检查点行失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui_editor::agent_tools::checkpoint::workflow_log_path;
|
||||
use serde_json::json;
|
||||
|
||||
const ASSET_ID: &str = "generated-1-1";
|
||||
|
||||
fn design_image(path: &str) -> Value {
|
||||
json!({ "path": path, "pixel_size": [100.0, 80.0], "pixels_per_unit": 1.0 })
|
||||
}
|
||||
|
||||
/// 用界面图条目当进度标记:出现 `marker` 那张图就说明对应步骤的 State 已经拿到。
|
||||
fn state_with_marker(marker: Option<&str>) -> Value {
|
||||
let mut images = serde_json::Map::new();
|
||||
images.insert("page".to_string(), design_image("assets/page.png"));
|
||||
if let Some(marker) = marker {
|
||||
images.insert(
|
||||
marker.to_string(),
|
||||
design_image(&format!("assets/{marker}.png")),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"ui_trees": [],
|
||||
"ui_design_images": images,
|
||||
"sprite_assets": {},
|
||||
"font_assets": {},
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot_with(state: Value) -> UiDesignStateSnapshot {
|
||||
UiDesignStateSnapshot {
|
||||
revision: 7,
|
||||
state: serde_json::from_value(state).expect("deserialize snapshot state"),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_lines(directory: &tempfile::TempDir, lines: &[CheckpointLine]) -> WorkflowLog {
|
||||
let log = WorkflowLog::open(directory.path(), "ui/UI 设计 1.json").expect("open log");
|
||||
for line in lines {
|
||||
log.append(line).expect("append line");
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
fn run_line() -> CheckpointLine {
|
||||
CheckpointLine::Run {
|
||||
at: 1,
|
||||
doc: CheckpointDocument {
|
||||
asset_id: ASSET_ID.to_string(),
|
||||
},
|
||||
revision: 7,
|
||||
state: state_with_marker(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_log(directory: &tempfile::TempDir) -> String {
|
||||
std::fs::read_to_string(
|
||||
workflow_log_path(directory.path(), "ui/UI 设计 1.json").expect("log path"),
|
||||
)
|
||||
.expect("read log")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_takes_state_from_checkpoint_without_replaying_recorded_steps() {
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
let log = log_lines(
|
||||
&directory,
|
||||
&[
|
||||
run_line(),
|
||||
CheckpointLine::Recognize {
|
||||
at: 2,
|
||||
dto: json!({ "ui_trees": [] }),
|
||||
state: Some(state_with_marker(Some("recognized"))),
|
||||
},
|
||||
CheckpointLine::Separate {
|
||||
at: 3,
|
||||
dto: json!({ "bound_nodes": [], "problematic_nodes": [] }),
|
||||
state: Some(state_with_marker(Some("separated"))),
|
||||
backfill_errors: vec!["素材缺失:assets/cut-1.png".to_string()],
|
||||
},
|
||||
],
|
||||
);
|
||||
let round = log.last_round().expect("scan").expect("round");
|
||||
|
||||
let progress = resume_progress(
|
||||
&log,
|
||||
&snapshot_with(state_with_marker(None)),
|
||||
&round,
|
||||
ASSET_ID,
|
||||
)
|
||||
.expect("resume open round");
|
||||
|
||||
// 状态直接来自检查点里切分那一步的快照,识别与切分都不会被重放。
|
||||
let state = serde_json::to_value(&progress.state).expect("state json");
|
||||
assert!(state["ui_design_images"].get("separated").is_some());
|
||||
assert!(progress.recorded_recognize.is_some());
|
||||
assert!(progress.recorded_separate.is_some());
|
||||
assert_eq!(
|
||||
progress.backfill_errors,
|
||||
vec!["素材缺失:assets/cut-1.png".to_string()]
|
||||
);
|
||||
assert!(!progress.already_written_back);
|
||||
assert!(!raw_log(&directory).contains("outdated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_reports_already_written_back_when_document_holds_the_separated_state() {
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
let log = log_lines(
|
||||
&directory,
|
||||
&[
|
||||
run_line(),
|
||||
CheckpointLine::Recognize {
|
||||
at: 2,
|
||||
dto: json!({ "ui_trees": [] }),
|
||||
state: Some(state_with_marker(Some("recognized"))),
|
||||
},
|
||||
CheckpointLine::Separate {
|
||||
at: 3,
|
||||
dto: json!({ "bound_nodes": [], "problematic_nodes": [] }),
|
||||
state: Some(state_with_marker(Some("separated"))),
|
||||
backfill_errors: Vec::new(),
|
||||
},
|
||||
],
|
||||
);
|
||||
let round = log.last_round().expect("scan").expect("round");
|
||||
|
||||
let progress = resume_progress(
|
||||
&log,
|
||||
&snapshot_with(state_with_marker(Some("separated"))),
|
||||
&round,
|
||||
ASSET_ID,
|
||||
)
|
||||
.expect("resume written back round");
|
||||
|
||||
assert!(progress.already_written_back);
|
||||
assert!(progress.recorded_separate.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_treats_steps_without_state_snapshots_as_unfinished() {
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
let log = log_lines(
|
||||
&directory,
|
||||
&[
|
||||
run_line(),
|
||||
CheckpointLine::Recognize {
|
||||
at: 2,
|
||||
dto: json!({ "ui_trees": [] }),
|
||||
state: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
let round = log.last_round().expect("scan").expect("round");
|
||||
|
||||
let progress = resume_progress(
|
||||
&log,
|
||||
&snapshot_with(state_with_marker(None)),
|
||||
&round,
|
||||
ASSET_ID,
|
||||
)
|
||||
.expect("resume legacy round");
|
||||
|
||||
// 旧日志只有识别结果、没有状态快照:整步按没做过处理,由主流程重跑。
|
||||
assert!(progress.recorded_recognize.is_none());
|
||||
assert!(progress.recorded_separate.is_none());
|
||||
let state = serde_json::to_value(&progress.state).expect("state json");
|
||||
assert!(state["ui_design_images"].get("recognized").is_none());
|
||||
assert!(!progress.already_written_back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_marks_round_outdated_when_document_drifted() {
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
let log = log_lines(&directory, &[run_line()]);
|
||||
let round = log.last_round().expect("scan").expect("round");
|
||||
|
||||
let error = match resume_progress(
|
||||
&log,
|
||||
&snapshot_with(state_with_marker(Some("edited"))),
|
||||
&round,
|
||||
ASSET_ID,
|
||||
) {
|
||||
Ok(_) => panic!("漂移的文档必须拒绝恢复"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(error.contains("中途被改动"), "{error}");
|
||||
assert!(raw_log(&directory).contains("outdated"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
pub(crate) mod recognize;
|
||||
pub(crate) mod separate;
|
||||
pub(crate) mod write_back;
|
||||
|
||||
use crate::ui_editor::commands::{RecognitionDTO, SeparationDTO};
|
||||
use crate::ui_editor::state::State;
|
||||
use std::path::Path;
|
||||
|
||||
/// 用已记录的 DTO 重放本轮的三步变换,得到目标 State;供写回幂等判定与恢复复用。
|
||||
pub(crate) fn rebuild_target_state(
|
||||
root: &Path,
|
||||
base: &State,
|
||||
recognize: Option<&RecognitionDTO>,
|
||||
separate: &SeparationDTO,
|
||||
) -> Result<(State, Vec<String>), String> {
|
||||
let mut state = base.clone();
|
||||
if let Some(dto) = recognize {
|
||||
recognize::apply_recognition(&mut state, dto)?;
|
||||
}
|
||||
let cut_images = separate::register_cut_image_sprites(root, separate)?;
|
||||
let mut backfill_errors = cut_images.errors;
|
||||
state = separate::add_sprite_assets(&state, &cut_images.sprites)?;
|
||||
backfill_errors.extend(separate::apply_separation(
|
||||
&mut state,
|
||||
separate,
|
||||
&cut_images.by_path,
|
||||
));
|
||||
Ok((state, backfill_errors))
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
mod cut_images;
|
||||
|
||||
pub(crate) use cut_images::{register_cut_image_sprites, CutImageSprites};
|
||||
pub(crate) use cut_images::register_cut_image_sprites;
|
||||
|
||||
use crate::ui_editor::commands::separation::{BoundNode, ProblematicNode};
|
||||
use crate::ui_editor::commands::SeparationDTO;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# UI 工作流检查点用追加式 JSONL 日志
|
||||
|
||||
UI 设计文档的工作流由 Agent 工具驱动、跨多次外部调用,崩溃后需要能续跑。检查点定为**文档旁一条按行追加的 JSONL 日志**:每完成一个步骤追加一行——原始 State 快照、识别 DTO、切分 DTO、回写标记——"某步是否完成"只看日志里有没有对应的行,没有就是没完成,恢复时从第一个缺失的行对应的步骤继续。
|
||||
UI 设计文档的工作流由 Agent 工具驱动、跨多次外部调用,崩溃后需要能续跑。检查点定为**文档旁一条按行追加的 JSONL 日志**:每一步做完就追加一行——起始一轮记原始 State 快照,识别与切分各记自己的 DTO **以及该步应用完之后的那份 State 快照**,最后一行是回写标记——"某步是否完成"只看日志里有没有对应的行,没有就是没完成。恢复只做两件事:读回已完成步骤留下的 State 快照,然后从第一个没有快照的步骤继续。
|
||||
|
||||
## 考虑过的方案
|
||||
|
||||
- 每步一个 sidecar 状态机(步骤状态 + sealedAtRevision + evidence 清单):要额外维护状态迁移、封条判据和证据校验,等于给每一步发明一套小协议。
|
||||
- 把切分专用的 `SeparationState` 泛化成通用检查点:切分需要"每个批次、每个节点"的细粒度恢复语义,其它步骤不需要,泛化会把这份复杂度摊给所有步骤。
|
||||
|
||||
追加式日志只需要"存在即完成"这一条判据,新增步骤类型等于新增一种行,天然可扩展。
|
||||
追加式日志只需要"存在即完成"这一条判据,新增步骤类型等于新增一种行,天然可扩展。每步应用完的 State 快照随该步行一同落下,恢复就只是"读回最新快照",不必再维护一份"照 DTO 重算一遍"的镜像逻辑。
|
||||
|
||||
## 后果
|
||||
|
||||
- 日志是恢复用的派生信息,不是项目内容:不登记为 manifest 资产,不推进项目 revision。
|
||||
- 每行必须一次性原子追加;崩溃时可能留下写了一半的最后一行,未形成完整行的步骤一律视为未完成。
|
||||
- 同一份文档可以被多次运行,日志必须能区分轮次:每一轮以一行原始 State 快照开头,该轮的第一行回写标记即为这一轮结束;恢复只针对最后一个没有回写标记的轮次。
|
||||
- 恢复只看快照、不重放步骤:已完成步骤在日志里带着"那一步应用完"的 State,恢复直接采纳并整步跳过。若照 DTO 再跑一遍,会重复登记切图、把同一条回填出错原因重复累加。只有带 State 快照的行才算已完成,旧格式(只有 DTO)按未完成重跑。
|
||||
- 切分 op 内部的细粒度恢复仍由 `SeparationState` 承担,日志只记录工作流层面的步骤完成,不顺带复制它的进度。
|
||||
|
||||
@@ -9279,7 +9279,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
|
||||
- 背景:UI 编辑器的 Agent 链路原本只有一个 `ui.workflow.run`,把发现页面、桥接设计图、结构识别、多树合并、组件绑定、finalize 全塞进一个工具,工具参数本身就是工作流状态;识别与切分的产物由前端 `useUiEditorPage.ts` 落 State 再保存,Agent 侧没有任何恢复点,任一步失败只能整轮重来。
|
||||
- 决策:拆成三个各自只做一件事的工具——`ui-design-doc.from-images`(一至四张设计图新建并登记文档,返回 `assetId` 与 `relativePath`)、`ui-design-doc.run-workflow`(`recognize → separate → write-back`)、`ui-design-doc.into-js`(渲染 `ui/generated-<stem>-<digest>.js`,不推进 revision)。只有 `run-workflow` 带崩溃恢复,粒度到子步骤。
|
||||
- 决策(检查点):恢复判据只有「这一步有没有对应的检查点行」。检查点是文档旁追加式 JSONL `ui/.<文档名>-workflow.jsonl`(不进 manifest、不推进 revision),行类型 `run` / `recognize` / `separate` / `write-back` / `outdated`;一轮以 `run` 开头、以首个 `write-back` 或 `outdated` 结束,只有最后一轮没有结束行时才恢复。追加前截断崩溃留下的半行;文档中途漂移(当前 State 与 `run` 行快照不一致)时追加 `outdated` 并返回错误,由下一次调用显式开新一轮,不在同一次调用里自动重启;写回按「目标 State 与文档当前 State 相等」判幂等并补 `write-back` 行。
|
||||
- 决策(检查点):恢复判据只有「这一步有没有对应、且带着 State 快照的检查点行」。检查点是文档旁追加式 JSONL `ui/.<文档名>-workflow.jsonl`(不进 manifest、不推进 revision),行类型 `run` / `recognize` / `separate` / `write-back` / `outdated`;`run` 行带这一轮开始时的 State 快照,`recognize` / `separate` 行带该步应用完之后的 State 快照。恢复只读快照:逐级取回已完成步骤留下的 State,只把新完成那一步的改动应用到文档,已完成步骤整步跳过、绝不照 DTO 重跑(重跑会重复登记切图、把同一条回填出错原因重复报一遍)。只有 DTO、没有 State 快照的旧行按未完成处理,由主流程重跑该步。一轮以 `run` 开头、以首个 `write-back` 或 `outdated` 结束,只有最后一轮没有结束行时才恢复。追加前截断崩溃留下的半行;文档中途漂移(当前 State 既不是 `run` 行快照、也不是切分后那份快照)时追加 `outdated` 并返回错误,由下一次调用显式开新一轮,不在同一次调用里自动重启;写回按「切分后那份 State 快照与文档当前 State 相等」判幂等并补 `write-back` 行。
|
||||
- 决策(边界):文档内设计图身份直接采用该图在 manifest 里的 `assetId`,输入给相对路径时先登记再用它的 `assetId`;每次调用都新建文档,不做「原型 → 已存在文档」的幂等查找。切图资源失败不回滚,重放靠 by-path 复用接上;切分 op 内部更细粒度的恢复仍由 `SeparationState` sidecar 承担,日志不复制它的进度。前端 `useUiEditorPage.ts` 的人工链路保留同语义,Rust 只是第二份实现,不把编排搬进 Rust。
|
||||
- 退役:`ui.workflow.run`、`ui_editor/commands/{merge.rs,binding.rs}`、`ui_editor/workflow.rs`、`ensure_ui_design_resource_for_prototype`、`ui/ui-workflow-<sha256前24>.json` 命名与 `ui-workflow.*` manifest 阶段全部删除,不保留迁移、兼容与 fallback。
|
||||
- 代价与取舍:不接受跨语言 fixture 比对(四个 seam 都是简单变换,靠同语义实现与各自单测覆盖);`run-workflow` 每次调用都推进一轮,调用方重复调用会重新识别而不是被幂等短路(除「保存成功但缺 `write-back` 行」这一种重放)。工具名 `ui-design-doc.*` 含连字符,Function Calling 的函数名归一同时处理 `.` 与 `-`。
|
||||
|
||||
@@ -1758,5 +1758,5 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
|
||||
|
||||
- `ui.workflow.run` 单工具(`discover → prepare → recognize → merge → binding → status → finalize`)整体退役:它的参数就是工作流状态,任一步失败只能整轮重来,步骤产物又由前端会话落盘,Agent 侧没有任何恢复点。同时退役 `merge.rs`、`binding.rs`、`workflow.rs`、`ensure_ui_design_resource_for_prototype`(原型 → 文档的幂等查找)、`ui/ui-workflow-<sha256前24>.json` 命名与 `ui-workflow.*` manifest 阶段,均不保留兼容、迁移或 fallback。
|
||||
- 当前只保留三个工具:`ui-design-doc.from-images`(一至四张设计图 → 新建并登记文档,返回 `assetId` 与 `relativePath`,无状态)、`ui-design-doc.run-workflow`(`recognize → separate → write-back`,唯一带崩溃恢复的工具)、`ui-design-doc.into-js`(渲染 `ui/generated-<stem>-<digest>.js`,无状态、不推进 revision)。项目根目录、项目 ID 与 provider 身份由 Runtime 注入,模型只给设计图引用或文档 `assetId`。
|
||||
- `run-workflow` 的恢复判据只有一条:这一步有没有对应的检查点行。检查点是文档旁追加式 JSONL(`ui/.<文档名>-workflow.jsonl`),行类型为 `run` / `recognize` / `separate` / `write-back` / `outdated`,一轮以 `run` 开头、以首个 `write-back` 或 `outdated` 结束;只有最后一轮没有结束行时才恢复。追加前先截断崩溃留下的半行,文档中途漂移时追加 `outdated` 并返回错误,由下一次调用显式开新一轮,不在同一次调用里自动重启。切图资源失败不回滚,靠 manifest 的 by-path 复用接上;切分 op 内部更细粒度的恢复仍由 `SeparationState` sidecar 承担,检查点日志不复制它的进度。
|
||||
- `run-workflow` 的恢复判据只有一条:这一步有没有对应、且带着 State 快照的检查点行。检查点是文档旁追加式 JSONL(`ui/.<文档名>-workflow.jsonl`),行类型为 `run` / `recognize` / `separate` / `write-back` / `outdated`,`run` 行带本轮起始 State,`recognize` / `separate` 行带该步应用完之后的 State。恢复只读快照、整步跳过已完成步骤,只把新完成那一步的改动应用到文档,绝不照 DTO 重放(重放会重复登记切图、重复累加回填出错说明);只有 DTO 的旧行按未完成重跑。一轮以 `run` 开头、以首个 `write-back` 或 `outdated` 结束;只有最后一轮没有结束行时才恢复。追加前先截断崩溃留下的半行,文档中途漂移(当前 State 既不是 `run` 行快照、也不是切分后那份快照)时追加 `outdated` 并返回错误,由下一次调用显式开新一轮,不在同一次调用里自动重启。切图资源失败不回滚,靠 manifest 的 by-path 复用接上;切分 op 内部更细粒度的恢复仍由 `SeparationState` sidecar 承担,检查点日志不复制它的进度。
|
||||
- 三个工具的描述与参数文案都在 `prompts/runtime/texts/ui-design-doc.json`(目录 ID `uiDesignDoc`),Rust 侧不得硬编码面向模型的长文案。策划 `design-foundation` 的自主构建白名单同步登记这三个工具,命令映射复用 `asset.register` / `file.write`。
|
||||
|
||||
@@ -48,12 +48,13 @@
|
||||
检查点是文档旁一条追加式 JSONL 日志,方案与被否方案见 [ADR:UI 工作流检查点用追加式 JSONL 日志](../adr/【ADR】UI工作流检查点用追加式JSONL日志-2026-09-23.md)。
|
||||
|
||||
- 位置:`ui/.<文档文件名去扩展名>-workflow.jsonl`,与文档同级,不进 manifest、不推进项目 revision。
|
||||
- 行格式:`run`(原始 State 快照 + 起始 revision)、`recognize`(DTO)、`separate`(DTO)、`write-back`(新 revision)、`outdated`(本轮作废原因);行内另带 `at` 时间戳。
|
||||
- 行格式:`run`(原始 State 快照 + 起始 revision)、`recognize`(DTO + 该步应用完的 State 快照)、`separate`(DTO + 该步应用完的 State 快照 + 回填说明)、`write-back`(新 revision)、`outdated`(本轮作废原因);行内另带 `at` 时间戳。
|
||||
- 判据只有一条:「本步有没有对应的行」。每行必须一次性原子追加,崩溃留下的半行一律视为该步未完成。
|
||||
- 恢复只看快照、不重放步骤:已完成的步骤在检查点里带着那一步应用完之后的 State 快照,恢复时直接读回这份 State 并整步跳过,不按记录下来的 DTO 重放该步 delta——重放会重复登记切图、重复累加回填说明,把同一份错误报两遍。只有带状态快照的行才算已完成,旧格式(只有 DTO)的行按未完成重跑。
|
||||
- 轮次:一轮以 `run` 行开头,以该轮第一行 `write-back` 或 `outdated` 结束;只有最后一轮没有结束行时才需要恢复。`outdated` 只作废未完成的那一轮,不删除既有行。
|
||||
- 半行:追加前先把日志截断到最后一个换行,丢掉崩溃留下的半行,避免它夹在日志中间。
|
||||
- 漂移:文档在轮次中途被改动(当前 State 与 `run` 行快照不一致)时,追加一行 `outdated` 并**返回错误**,不在同一次调用里自动重开新一轮;下一次调用看到 `outdated` 才从头开新一轮,且以当前文档为基准。
|
||||
- 写回幂等:`save` 成功但 `write-back` 行没追加时,重放会重建出同一份目标 State,此时按「目标 State 与文档当前 State 相等」判定为已写完,直接补 `write-back` 行并返回成功。这条判据成立的前提是工作流这条路不产生随机身份:识别树的根节点 id 来自 DTO,切图资源 id 走 manifest 的 by-path 复用。
|
||||
- 漂移:文档在轮次中途被改动(当前 State 既不是 `run` 行快照、也不是切分后那份快照)时,追加一行 `outdated` 并**返回错误**,不在同一次调用里自动重开新一轮;下一次调用看到 `outdated` 才从头开新一轮,且以当前文档为基准。
|
||||
- 写回幂等:`save` 成功但 `write-back` 行没追加时,恢复读回的切分后 State 与文档当前 State 相等,即判为已写完,直接补 `write-back` 行并返回成功。这条判据成立的前提是工作流这条路不产生随机身份:识别树的根节点 id 来自 DTO,切图资源 id 走 manifest 的 by-path 复用。
|
||||
- 切图资源不回滚:切分产出的图片与已登记资源在失败后保留,重放靠 by-path 复用接上,不做回滚清理。
|
||||
- 写回成功后才清理 sidecar:与前端切分链路一致,`backfill_errors` 为空且 `problematic_nodes` 为空时调用
|
||||
`finalize_separation`;有回填问题或问题节点时保留 sidecar,交给编辑器显示恢复入口。
|
||||
@@ -71,7 +72,7 @@ src/ui_editor/agent_tools/
|
||||
├─ checkpoint.rs JSONL 追加、读取、轮次判定
|
||||
├─ run_workflow.rs recognize → separate → write-back 编排与恢复
|
||||
├─ steps/
|
||||
│ ├─ mod.rs 用已记录 DTO 重放目标 State
|
||||
│ ├─ mod.rs 步骤子模块声明
|
||||
│ ├─ recognize.rs 识别 DTO 落 State
|
||||
│ ├─ separate/
|
||||
│ │ ├─ mod.rs 切分 DTO 落 State(回填、清状态、写 NeedReview)
|
||||
|
||||
@@ -27,7 +27,7 @@ view/ui-editor (页面/组件)
|
||||
| `component/` | 组件枚举 `Component::{Image, Text}`、`NodeComponent`(LLM 工具载荷的 `PureNode`/`WithComponent` 判别式) |
|
||||
| `resource/` | 界面图(`path` / `pixel_size` / `pixels_per_unit`)、sprite(含 `SpriteBorder` 九宫格)、字体(格式/媒体类型/CSS format)资源描述 |
|
||||
| `persistence.rs` | 文档读写、`revision` 乐观并发保存、领域校验(重复 ID、树/资源引用、组件状态)、代码生成写盘 |
|
||||
| `agent_tools/` | Agent 工具链路:`creation.rs` 用一至四张设计图新建文档(登记未登记图片、按 `ui/UI 设计 N.json` 取号、持项目写锁装 revision 0、失败回滚)、`checkpoint.rs` JSONL 检查点日志、`run_workflow.rs` 三步编排与崩溃恢复、`steps/` 逐步落 State(`mod.rs` 用已记录 DTO 重放目标 State、`recognize.rs` 识别、`separate/` 切分:`cut_images.rs` 登记切图与 `SpriteAsset` 构造、`mod.rs` 回填与问题状态、`write_back.rs` 保存与漂移文案) |
|
||||
| `agent_tools/` | Agent 工具链路:`creation.rs` 用一至四张设计图新建文档(登记未登记图片、按 `ui/UI 设计 N.json` 取号、持项目写锁装 revision 0、失败回滚)、`checkpoint.rs` JSONL 检查点日志、`run_workflow.rs` 三步编排与崩溃恢复(恢复只读检查点里的 State 快照、整步跳过已完成步骤,不按 DTO 重放)、`steps/` 逐步落 State(`mod.rs` 步骤子模块声明、`recognize.rs` 识别、`separate/` 切分:`cut_images.rs` 登记切图与 `SpriteAsset` 构造、`mod.rs` 回填与问题状态、`write_back.rs` 保存与漂移文案) |
|
||||
| `html_renderer/` | 由 State 生成 HTML 片段与 JS(maud + 布局/组件 CSS 映射),供预览与 `ui/generated-*.js` |
|
||||
| `commands/` | LLM 工具链:`recognition`(结构识别)、`separation/`(自动切分素材)、`utils.rs`(LLM 请求、重试、`required_tool_arguments`) |
|
||||
| `commands/separation/` | 切分批处理、截图/预切、sidecar 恢复(inspect / finalize / discard)、patch 回写 |
|
||||
|
||||
Reference in New Issue
Block a user