diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/checkpoint.rs index 28daca2f2..97d8aa8ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/checkpoint.rs @@ -27,21 +27,22 @@ pub(crate) enum CheckpointLine { Recognize { at: u64, dto: Value, + /// 这一步在内存里应用完之后的 State 快照。恢复时直接读回来, + /// 不再按 dto 重放这一步的 delta。旧版日志没有这一栏,缺失即视为该步不可恢复。 + state: Option, }, Separate { at: u64, dto: Value, + state: Option, + /// 这一步产出的回填说明:恢复不再重跑该步,所以和状态快照一起存。 + #[serde(default)] + backfill_errors: Vec, }, /// 一轮的结束:文档已写回这个 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, + pub(crate) recognize_state: Option, pub(crate) separate_dto: Option, + pub(crate) separate_state: Option, + pub(crate) backfill_errors: Vec, pub(crate) outcome: RoundOutcome, } @@ -234,18 +239,29 @@ fn apply_line(round: &mut Option, 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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs index c7f9a4a0d..a92e00b21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs @@ -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::) - .transpose() - .map_err(|error| format!("工作流检查点的识别结果无法还原:{error}"))?; - let recorded_separate = round - .separate_dto - .clone() - .map(serde_json::from_value::) - .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::(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::(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(value: &T) -> Result { 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")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/mod.rs index a6993e09e..ac33a38df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/mod.rs @@ -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> { - 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)) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/mod.rs index 002584a94..8fea30925 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/mod.rs @@ -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; diff --git a/docs/adr/【ADR】UI工作流检查点用追加式JSONL日志-2026-09-23.md b/docs/adr/【ADR】UI工作流检查点用追加式JSONL日志-2026-09-23.md index ad0bcab61..07865dee0 100644 --- a/docs/adr/【ADR】UI工作流检查点用追加式JSONL日志-2026-09-23.md +++ b/docs/adr/【ADR】UI工作流检查点用追加式JSONL日志-2026-09-23.md @@ -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` 承担,日志只记录工作流层面的步骤完成,不顺带复制它的进度。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cfa667b3e..f4b572cca 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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--.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-.json` 命名与 `ui-workflow.*` manifest 阶段全部删除,不保留迁移、兼容与 fallback。 - 代价与取舍:不接受跨语言 fixture 比对(四个 seam 都是简单变换,靠同语义实现与各自单测覆盖);`run-workflow` 每次调用都推进一轮,调用方重复调用会重新识别而不是被幂等短路(除「保存成功但缺 `write-back` 行」这一种重放)。工具名 `ui-design-doc.*` 含连字符,Function Calling 的函数名归一同时处理 `.` 与 `-`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 73d53a0bc..4f81fb017 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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-.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--.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`。 diff --git a/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md b/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md index eecbc5aca..197f3220e 100644 --- a/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md +++ b/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md @@ -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) diff --git a/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md b/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md index 03906c91d..c1ef44d01 100644 --- a/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md +++ b/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md @@ -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 回写 |