From f927edcfb2980bceeedf60458fb9ed5e2edbb6b8 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 22 Oct 2026 21:38:00 +0800 Subject: [PATCH] fix(desktop): recover original image tasks and close test clients --- apps/detail-photocraft/JIANSUN.md | 23 + .../apps/photocraft/src/jiansun_backend.rs | 123 +++- .../apps/photocraft/src/jiansun_creation.rs | 58 +- .../apps/photocraft/src/jiansun_images.rs | 348 +++++++-- .../apps/photocraft/src/jiansun_lifecycle.rs | 353 ++++++++++ .../apps/photocraft/src/jiansun_projects.rs | 1 + .../apps/photocraft/src/jiansun_recovery.rs | 665 ++++++++++++++++++ .../apps/photocraft/src/main.rs | 10 + .../crates/ui-egui/src/jiansun.rs | 135 +++- .../ui-egui/src/jiansun/project_browser.rs | 2 +- ...ient-web-closeout-acceptance-2026-10-22.md | 8 + ...-restart-recovery-acceptance-2026-10-22.md | 65 ++ docs/photocraft-client-design.md | 3 +- 13 files changed, 1722 insertions(+), 72 deletions(-) create mode 100644 apps/detail-photocraft/apps/photocraft/src/jiansun_lifecycle.rs create mode 100644 apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs create mode 100644 docs/native-client-restart-recovery-acceptance-2026-10-22.md diff --git a/apps/detail-photocraft/JIANSUN.md b/apps/detail-photocraft/JIANSUN.md index 15f81af3..d8852de2 100644 --- a/apps/detail-photocraft/JIANSUN.md +++ b/apps/detail-photocraft/JIANSUN.md @@ -67,6 +67,21 @@ cargo xtask wasm 也须通过原 Key 校验,不能覆盖已有的不同请求号。98k 留存接口的原始响应直接解析,另支持 明确的 `data` 对象封装。 +启动后按原账户扫描本机未决任务,只使用原 Key 核查。处理中或临时网络失败按逐渐延长的 +间隔继续 GET,本次启动共用最多 180 秒恢复窗口;404、410、无法确认归属或结果不可取回 +立即保留待核查。这个窗口沿用图片请求的等待预算,不虚构旧任务的派发时间,也不重发生成。 + +原图片先取回所属项目;打开原项目时,只替换基线未改动的目标屏,保留其他屏的中文、 +图层身份和人工修改。目标屏、方案或画布结构已经变化时保留原图片等待处理。 +已恢复的结果再次启动不会重复插入。后台扫描不会切走当前项目;最近项目仅在界面真正 +接受打开后记录。打开与联网恢复在独立工作线程执行,本机保存继续由主请求队列处理。 +应用成功后同步原图片任务的 `applied` 终态;旧完成响应不能重新使其可应用,重启只补齐 +已应用日志,不再次插入画布。存在冲突或尚未应用的结果仍保留供用户接手。 + +本机 `jiansun/lifecycle/` 只保留当前和前次 GUI 生命周期记录,各最多 4KiB;不记录 Key、 +命令参数、作品内容或 panic 原文。缺少正常返回只能说明非正常结束,原因保持 unknown, +不能用新记录倒推历史进程退出原因。 + 真实顾家五屏文件的本地回归: ```powershell @@ -77,3 +92,11 @@ cargo test -p photocraft --bin photocraft jiansun_acceptance:: -- --ignored 此项验证中文编辑、撤销重做、原生项目保存恢复及分层 PSD 导出后重读。 它不调用模型,不代替真实 Agent、完整界面或 Photoshop 实机验收。 + +## 验收实例收尾 + +开始前记录用户现有客户端的 PID 和程序路径。一次只开一个独立 profile 的 `--control` +验收窗口;脚本在 `finally` 中保存自己的未保存测试内容、正常退出并等待进程结束,最后 +复核用户原窗口仍在。只处理本轮明确创建的实例,不按程序名批量结束客户端。 +完成后清理本轮专用构建缓存及测试二进制,保留必要源码、回执和验收证据;删除被自动审批 +拒绝时记录准确路径与原因,不换工具重试。桌面常规启动沿用单实例机制,已有窗口优先激活。 diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs index c53697b3..5d1f4c9d 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs @@ -35,6 +35,8 @@ mod pi_runtime; mod sync; #[path = "jiansun_creation.rs"] mod creation; +#[path = "jiansun_recovery.rs"] +mod recovery; /// Opt-in, local SDK transport. The personal Key never leaves this native process. pub fn pi_stdio() -> R<()> { @@ -120,6 +122,32 @@ fn write_json(path: &Path, v: &Value) -> R<()> { let bytes = serde_json::to_vec(v).map_err(|_| "项目记录无法保存")?; photocraft_format::atomic_write(path, &bytes).map_err(|_| "本机保存失败,请检查磁盘空间和目录权限".into()) } +fn project_not_trashed(dir: &Path) -> R<()> { + if let (Some(account), Some(local)) = (dir.parent().and_then(Path::parent), dir.file_name()) + && account.join("trash").join(local).exists() { + return Err("原项目已删除,迟到结果未重建项目;原任务在回收区保留".into()); + } + Ok(()) +} +fn original_project_present(dir: &Path) -> R<()> { + project_not_trashed(dir)?; + if !dir.is_dir() || !dir.join("context.json").is_file() { return Err("原项目已关闭或移除,结果未写入其他项目".into()); } + Ok(()) +} +/// The slow open owns no receive-loop state. Its completion is queued behind +/// ordinary Save requests, and only that loop may adopt the stable binding. +fn enqueue_open(requests: mpsc::Sender, open: impl FnOnce() -> T + Send + 'static) { + std::thread::spawn(move || { let _=requests.send(open()); }); +} +fn open_binding_local(binding:&Value,scope:&str,origin:&str,account:&Value)->R { + if binding["accountScope"]!=scope || binding["origin"]!=origin || binding["accountId"]!=account["id"] {return Err("打开期间账户已变化,原项目未载入当前账户".into());} + binding["localId"].as_str().filter(|id|Uuid::parse_str(id).is_ok() || id.parse::().is_ok()).map(str::to_owned).ok_or("原项目本机身份未能确认".into()) +} +fn adopt_open_mapping(owners:&mut HashMap,locations:&mut HashMap,doc:u64,binding:&Value,scope:&str,origin:&str,account:&Value)->R<()> { + let local=open_binding_local(binding,scope,origin,account)?; + owners.insert(doc,scope.into());locations.insert(doc,local);Ok(()) +} +fn remember_open(binding:&Value)->bool {binding["autoResume"]!=true} fn body(response: Response, limit: u64) -> R<(u16, Value)> { let status = response.status().as_u16(); if response.content_length().is_some_and(|n| n > limit) { @@ -165,6 +193,8 @@ struct Backend { owners: HashMap, locations: HashMap, last_saved: HashMap, + recovery_attempts: Arc>>, + recovery_serial: Arc>, } impl Backend { fn new(root: PathBuf) -> R { @@ -192,6 +222,8 @@ impl Backend { owners: HashMap::new(), locations: HashMap::new(), last_saved: HashMap::new(), + recovery_attempts: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + recovery_serial: Arc::new(std::sync::Mutex::new(())), }) } fn scope(&self) -> String { @@ -362,6 +394,7 @@ impl Backend { result["origin"] = json!(self.origin); result["projects"] = json!(projects); result["message"] = json!(message); + result["resumeProject"] = self.resume_project(); Ok(result) } fn login(&mut self, input: &Value, tx: &mpsc::Sender, ctx: &egui::Context) -> R { @@ -474,6 +507,7 @@ impl Backend { value["syncDirty"] = json!(meta["syncDirty"] == true || previous["syncDirty"] == true || changed_revision); if !value["status"].is_string() || changed_revision || meta["syncDirty"] == true { value["status"] = json!("draft"); } write_json(&dir.join("context.json"), &value)?; + self.remember_project(project)?; Ok(json!({"time":value["time"],"localId":project,"status":value["status"],"project":value["project"],"cloudLink":value["project"],"syncedRevision":value["syncedRevision"],"syncDirty":value["syncDirty"],"projects":self.native_projects()?})) } fn draft(&mut self, id: u64) -> R { @@ -691,6 +725,12 @@ impl Backend { self.state() } "projects" => Ok(json!(self.projects()?)), + "project-activate" => { + let doc=input["doc"].as_u64().ok_or("项目身份无效")?;self.ensure_owner(doc)?; + let local=self.locations.get(&doc).ok_or("项目身份尚未绑定")?; + let _guard=sync::context_guard();original_project_present(&self.project_dir(doc)?)?;self.remember_project(local)?; + Ok(json!({"doc":doc,"localId":local})) + } "project-delete" => self.delete_project(input), "project-sync" => self.sync_project(input), "project-state" => self.project_state(input), @@ -729,13 +769,19 @@ impl Backend { _ => Err("未开放的本机业务操作".into()), } } + #[cfg(test)] fn open(&mut self, input: &Value) -> R<(Document, Value)> { + let (tx, _) = mpsc::channel(); + self.open_with_recovery(input, &tx, &egui::Context::default()) + } + fn open_with_recovery(&mut self, input: &Value, tx: &mpsc::Sender, ctx: &egui::Context) -> R<(Document, Value)> { let id = input["id"].as_str().ok_or("项目编号无效")?; if input["source"] == "native" { if Uuid::parse_str(id).is_err() && id.parse::().is_err() { return Err("项目编号无效".into()); } let dir = self.dir().join("projects").join(id); + let recovery = self.auto_recover_project(id, true, tx, ctx).unwrap_or_else(|message| json!({"state":"pending","applied":false,"message":message,"projectLocalId":id})); let mut doc = photocraft_format::load_path(&dir.join("canvas.pcraft")).map_err(|_| "原生草稿未能恢复;文件已保留")?; doc.id = photocraft_doc::DocId::fresh(); self.owners.insert(doc.id.0, self.scope()); @@ -743,6 +789,9 @@ impl Backend { let mut meta = read_json(&dir.join("context.json"))?; meta["path"] = json!(dir.join("canvas.pcraft").to_string_lossy()); meta["localId"] = json!(id); + meta["autoRecovery"] = recovery; + meta["autoResume"] = input["autoResume"].clone(); + meta["accountId"] = self.auth["account"]["id"].clone(); meta["origin"] = json!(self.origin); return Ok((doc, meta)); } self.validate()?; @@ -793,6 +842,7 @@ impl Backend { pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { let (tx, rx) = mpsc::channel(); let (events, results) = mpsc::channel(); + let internal_requests=tx.clone(); std::thread::spawn(move || { let mut backend = match Backend::new(root.join("jiansun")) { Ok(b) => b, @@ -803,8 +853,27 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { } }; let model_busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut recovery_scope = String::new(); + if !backend.auth.is_null() { + recovery_scope = backend.scope(); + let mut b = backend.clone(); let tx = events.clone(); let ctx = ctx.clone(); + std::thread::spawn(move || b.scan_recovery(&tx, &ctx)); + } for request in rx { match request { + Request::OpenedResult {binding,result} => { + let label=if binding["autoResume"]==true {"startup-open"} else {"open"}; + let adopted=(||->R<(Box,Value)>{ + let (doc,mut meta)=result?; + let scope=backend.scope();let local=open_binding_local(&binding,&scope,&backend.origin,&backend.auth["account"])?; + {let _guard=sync::context_guard();original_project_present(&backend.dir().join("projects").join(&local))?;} + adopt_open_mapping(&mut backend.owners,&mut backend.locations,doc.id.0,&binding,&scope,&backend.origin,&backend.auth["account"])?; + meta["localId"]=json!(local);meta["autoResume"]=binding["autoResume"].clone();meta["accountId"]=binding["accountId"].clone();meta["origin"]=binding["origin"].clone(); + if remember_open(&binding) {backend.remember_project(&local)?;} + Ok((doc,meta)) + })(); + match adopted {Ok((doc,meta))=>{let _=events.send(Event::Opened(doc,meta));},Err(error)=>{let _=events.send(Event::Json(label.into(),Err(error)));}} + } Request::Generate(doc, input) => { use std::sync::atomic::Ordering; backend.bind_new(doc.id.0); @@ -909,14 +978,14 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { } } } else if method == "open" { - match backend.open(&input) { - Ok((doc, meta)) => { - let _ = events.send(Event::Opened(Box::new(doc), meta)); - } - Err(e) => { - let _ = events.send(Event::Json(method, Err(e))); - } - } + let mut b=backend.clone();let events=events.clone();let ctx=ctx.clone(); + let mut binding=json!({"accountScope":b.scope(),"origin":b.origin,"accountId":b.auth["account"]["id"],"autoResume":input["autoResume"]}); + enqueue_open(internal_requests.clone(),move||{ + let result=b.open_with_recovery(&input,&events,&ctx).map(|(doc,meta)|{ + binding["localId"]=json!(b.locations.get(&doc.id.0));(Box::new(doc),meta) + }); + ctx.request_repaint();Request::OpenedResult{binding,result} + }); } else if method == "place" || method == "preview" { let result = (|| -> R { backend.validate()?; @@ -955,6 +1024,11 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { } } } + if !backend.auth.is_null() && recovery_scope != backend.scope() { + recovery_scope = backend.scope(); + let mut b = backend.clone(); let tx = events.clone(); let ctx = ctx.clone(); + std::thread::spawn(move || b.scan_recovery(&tx, &ctx)); + } ctx.request_repaint(); } }); @@ -965,6 +1039,33 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { mod tests { use super::*; #[test] + fn a_gated_open_leaves_the_receive_queue_free_for_another_project_save() { + enum Message {SaveB,Opened(Value)} + let root=std::env::temp_dir().join(format!("native-open-queue-{}",Uuid::new_v4())); + let first="024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692";let second=Uuid::new_v4().to_string(); + let binding=json!({"accountScope":"own-scope","origin":"https://sc.jiansun.vip","accountId":"owner","localId":first,"autoResume":true}); + let (requests,receiver)=mpsc::channel();let (started,ready)=mpsc::channel();let (release,gate)=mpsc::channel(); + enqueue_open(requests.clone(),move||{started.send(()).unwrap();gate.recv().unwrap();Message::Opened(binding)}); + ready.recv_timeout(Duration::from_secs(2)).unwrap();requests.send(Message::SaveB).unwrap(); + let (saved,saved_receipt)=mpsc::channel(); + match receiver.recv_timeout(Duration::from_secs(2)).unwrap(){Message::SaveB=>{ + write_json(&root.join("projects").join(&second).join("context.json"),&json!({"localId":second,"revision":8,"name":"B的本机草稿"})).unwrap(); + write_json(&root.join("last-project.json"),&json!({"id":second,"source":"native"})).unwrap();saved.send((second.clone(),8)).unwrap(); + },Message::Opened(_)=>panic!("open gate has not been released")} + assert_eq!(saved_receipt.recv_timeout(Duration::from_secs(2)).unwrap(),(second.clone(),8)); + assert_eq!(read_json(&root.join("projects").join(&second).join("context.json")).unwrap()["revision"],8); + let mut owners=HashMap::from([(2,"own-scope".to_owned())]);let mut locations=HashMap::from([(2,second.clone())]); + assert!(!owners.contains_key(&3));release.send(()).unwrap(); + let Message::Opened(binding)=receiver.recv_timeout(Duration::from_secs(2)).unwrap() else {panic!("expected original open completion")}; + adopt_open_mapping(&mut owners,&mut locations,3,&binding,"own-scope","https://sc.jiansun.vip",&json!({"id":"owner"})).unwrap(); + assert_eq!(owners[&3],"own-scope");assert_eq!(locations[&3],first);assert_eq!(locations[&2],second); + if remember_open(&binding){write_json(&root.join("last-project.json"),&json!({"id":first,"source":"native"})).unwrap();} + assert_eq!(read_json(&root.join("last-project.json")).unwrap()["id"],second); + let before=(owners.clone(),locations.clone()); + assert!(adopt_open_mapping(&mut owners,&mut locations,4,&binding,"other-account","https://sc.jiansun.vip",&json!({"id":"other"})).is_err()); + assert_eq!((owners,locations),before);fs::remove_dir_all(root).unwrap(); + } + #[test] #[ignore = "read-only public TLS acceptance; no account, API Key or inference request"] fn jiansun_security_revoked_intermediates_fail_tls_validation() { let client = Client::builder().timeout(Duration::from_secs(15)).redirect(reqwest::redirect::Policy::none()).build().unwrap(); @@ -1030,6 +1131,8 @@ mod tests { owners: HashMap::new(), locations: HashMap::new(), last_saved: HashMap::new(), + recovery_attempts: Default::default(), + recovery_serial: Default::default(), }; let first = b.scope(); b.auth = json!({"account":{"id":"account-b"}}); @@ -1063,6 +1166,8 @@ mod tests { owners: HashMap::new(), locations: HashMap::new(), last_saved: HashMap::new(), + recovery_attempts: Default::default(), + recovery_serial: Default::default(), }; b.bind_new(7); let file = b.project_dir(7).unwrap().join("task.json"); @@ -1085,6 +1190,8 @@ mod tests { owners: HashMap::new(), locations: HashMap::new(), last_saved: HashMap::new(), + recovery_attempts: Default::default(), + recovery_serial: Default::default(), }; let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"name":"原项目","width":20,"height":20})).unwrap(); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs index 1d8f3c54..1dbd2018 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs @@ -5,7 +5,7 @@ use base64::engine::general_purpose::STANDARD; #[path = "jiansun_creation_model.rs"] mod model; -fn load(dir: &Path) -> R { +pub(super) fn load(dir: &Path) -> R { let file = dir.join("creation.json"); if file.exists() { let c = read_json(&file)?; @@ -17,11 +17,47 @@ fn load(dir: &Path) -> R { Ok(model::empty()) } } -fn save(dir: &Path, c: &mut Value) -> R<()> { +pub(super) fn save(dir: &Path, c: &mut Value) -> R<()> { + let _guard = sync::context_guard(); + project_not_trashed(dir)?; + let file = dir.join("creation.json"); + if file.exists() && read_json(&file)?["revision"] != c["revision"] { + return Err("创作资料已有新版本,旧任务结果已保留,未覆盖新方案".into()); + } let n = c["revision"].as_u64().ok_or("创作版本无效")?.checked_add(1).filter(|n| *n < 2_147_483_647).ok_or("创作版本已达上限")?; c["revision"] = json!(n); write_json(&dir.join("creation.json"), c) } +/// Reserve the screen/task association before a paid dispatch. The immutable +/// native baseline makes automatic recovery conditional on *unchanged* editing. +pub(super) fn bind_image_dispatch(dir: &Path, task: &mut Value) -> R<()> { + let mut c = load(dir)?; + let b = task["creationBinding"].clone(); + for (actual, expected) in [(c["generation"]["run"].clone(), b["run"].clone()), (c["generation"]["planHash"].clone(), b["planHash"].clone()), (c["generation"]["pending"]["screenId"].clone(), b["screenId"].clone())] { + if actual.is_null() || actual != expected { return Err("图片派发前方案关联已变化,未发送请求".into()); } + } + if c["plan"]["status"] != "confirmed" || plan_hash(&c)? != b["planHash"] { return Err("页面方案已有新版本,未发送请求".into()); } + if c["generation"]["pending"]["taskId"].is_string() && c["generation"]["pending"]["taskId"] != task["id"] { return Err("屏幕已有关联任务,未发送请求".into()); } + let baseline = if !dir.join("creation-canvas.pcraft").exists() && c["generation"]["screens"].as_object().is_some_and(|rows| rows.is_empty()) && dir.join("canvas.pcraft").exists() { + Some(("initial", fs::read(dir.join("canvas.pcraft")).map_err(|_| "恢复基线无法读取,未发送请求")?)) + } else { + // Snapshot THIS confirmed plan and its current generated rows, never a + // creation-canvas left by a different generation run or an older plan. + let native = canvas(&c, dir, 0)?; + Some(("template", photocraft_format::save_to_bytes(&native, &Default::default()).map_err(|_| "原屏幕恢复基线无法保存,未发送请求")?)) + }; + if let Some((kind, bytes)) = baseline { + if bytes.len() > 256 * 1024 * 1024 { return Err("恢复基线超过限制,未发送请求".into()); } + let path = dir.join("image-jobs").join(task["id"].as_str().ok_or("任务身份无效")?).join("before-canvas.pcraft"); + { let _guard=sync::context_guard(); original_project_present(dir)?; + photocraft_format::atomic_write(&path, &bytes).map_err(|_| "恢复基线无法保存,未发送请求")?; } + task["creationBinding"]["baselineKind"] = json!(kind); + task["creationBinding"]["baselineSha256"] = json!(format!("{:x}", Sha256::digest(&bytes))); + } + c["generation"]["pending"]["taskId"] = task["id"].clone(); + c["generation"]["pending"]["binding"] = task["creationBinding"].clone(); + save(dir, &mut c) +} fn revision(c: &Value, input: &Value) -> R<()> { if input["revision"] != c["revision"] { Err("创作资料已有新版本,请重新读取;原草稿已保留".into()) } else { Ok(()) } } @@ -72,6 +108,8 @@ fn image_copy(dir: &Path, bytes: &[u8], name: &str) -> R { } let source = format!("local:{:x}", Sha256::digest(&encoded)); let file = assets_file(dir, &source)?; + let _guard = sync::context_guard(); + project_not_trashed(dir)?; if let Some(parent) = file.parent() { fs::create_dir_all(parent).map_err(|_| "本机产品资料目录不可写")?; } @@ -626,7 +664,7 @@ impl Backend { } } -fn plan_hash(c: &Value) -> R { +pub(super) fn plan_hash(c: &Value) -> R { Ok(hash(&json!({"input":model::input_hash("plan",c)?,"screens":c["plan"]["screens"]}).to_string())) } @@ -726,7 +764,7 @@ fn definition(c: &Value) -> R { } Ok(json!({"version":2,"name":c["brief"]["name"],"screens":screens})) } -fn canvas(c: &Value, dir: &Path, doc: u64) -> R { +pub(super) fn canvas(c: &Value, dir: &Path, doc: u64) -> R { let (layout, assets) = loaded_layout(c, dir)?; let original = definition(&layout)?; let mut native = crate::jiansun_document::import(&original, &assets)?; @@ -915,7 +953,7 @@ impl Backend { ctx.request_repaint(); Ok(result) } - fn creation_record_image(&self, dir: &Path, c: &mut Value, screen: &str, result: &Value) -> R<()> { + pub(super) fn creation_record_image(&self, dir: &Path, c: &mut Value, screen: &str, result: &Value) -> R<()> { if !matches!(result["phase"].as_str(), Some("complete" | "applied")) { return Err("图片原请求仍待核查,后续屏幕未发送".into()); } @@ -1002,7 +1040,8 @@ impl Backend { } let (references, ids) = reference_canvas(&c, &dir, doc)?; let prior = if dir.join("image-task.json").exists() { read_json(&dir.join("image-task.json"))?["id"].clone() } else { Value::Null }; - c["generation"]["pending"] = json!({"screenId":id,"sequence":index,"baseRevision":input["baseRevision"],"previousTaskId":prior}); + let binding = json!({"version":1,"localProjectId":self.locations.get(&doc),"accountScope":self.scope(),"origin":self.origin,"screenId":id,"run":run,"planHash":plan,"sequence":index}); + c["generation"]["pending"] = json!({"screenId":id,"sequence":index,"baseRevision":input["baseRevision"],"previousTaskId":prior,"binding":binding}); save(&dir, &mut c)?; let style = json!({"name":c["brief"]["brandName"],"tone":c["designSnapshot"]["brand"]["profile"]["tone"],"backgroundColor":color(&c,"backgroundColor")?,"primaryColor":color(&c,"primaryColor")?,"textColor":color(&c,"textColor")?,"fontFamily":"Noto Sans SC"}); let prompt = format!( @@ -1010,7 +1049,7 @@ impl Backend { screen["imagePrompt"].as_str().ok_or("画面要求无效")?, count ); - let image_input = json!({"doc":doc,"model":settings["imageModel"],"revision":input["baseRevision"],"mode":"generate","text":prompt,"screen":screen["name"],"references":ids,"brand":style,"facts":c["facts"],"center":null}); + let image_input = json!({"doc":doc,"model":settings["imageModel"],"revision":input["baseRevision"],"mode":"generate","text":prompt,"screen":screen["name"],"references":ids,"brand":style,"facts":c["facts"],"center":null,"creationBinding":binding}); let result = match self.image_job(Some(Arc::new(references)), &image_input, false, tx, ctx) { Ok(result) => result, Err(error) => { @@ -1022,6 +1061,10 @@ impl Backend { return Err(error); } }; + c = load(&dir)?; + if c["generation"]["run"] != run || plan_hash(&c)? != plan || c["generation"]["pending"]["screenId"] != id { + return Err("原图片已保存,页面方案已有新版本,未覆盖新资料".into()); + } c["generation"]["pending"]["taskId"] = result["taskId"].clone(); save(&dir, &mut c)?; if result["phase"] == "failed" { @@ -1042,6 +1085,7 @@ impl Backend { out["message"] = json!("原图片已保存,资料已有新版本,未覆盖画布"); return Ok(out); } + c = latest; self.creation_record_image(&dir, &mut c, id, &result)?; self.creation_publish(input, &c, &run, index, tx, ctx)?; completed += 1; diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs index d9109341..bf689804 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs @@ -6,6 +6,12 @@ use photocraft_doc::{LayerContent, LayerId}; pub(super) mod protocol; const IMAGE_BYTES: u64 = 20 * 1024 * 1024; const RESPONSE_BYTES: u64 = 40 * 1024 * 1024; +pub(super) const IMAGE_REQUEST_TIMEOUT: Duration = Duration::from_secs(180); +fn recovery_timeout(deadline: Option, cap: Duration) -> R { + let remaining=deadline.map(|end|end.saturating_duration_since(Instant::now())).unwrap_or(cap); + if remaining.is_zero() {return Err("本次恢复查询窗口已结束,原请求保留待核查;不会重发".into());} + Ok(remaining.min(cap)) +} pub(super) fn pending(dir: &Path) -> R { let file = dir.join("image-task.json"); @@ -29,10 +35,62 @@ fn png(bytes: &[u8]) -> R> { } Ok(out) } -fn job_save(dir: &Path, t: &Value) -> R<()> { +pub(super) fn job_save(dir: &Path, t: &Value) -> R<()> { + let _guard = sync::context_guard(); + job_save_locked(dir, t, false) +} +// The caller holds context_guard. Recovery already owns this lock while switching +// the canonical canvas and must settle the journal without locking it recursively. +fn job_save_locked(dir: &Path, t: &Value, current_only: bool) -> R<()> { + project_not_trashed(dir)?; + if t["creationBinding"].is_object() || t["localProjectId"].is_string() { original_project_present(dir)?; } let id = t["id"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("任务身份无效")?; - write_json(&dir.join("image-jobs").join(id).join("task.json"), t)?; - write_json(&dir.join("image-task.json"), t) + let archive = dir.join("image-jobs").join(id).join("task.json"); + let mut settled = t.clone(); + if archive.exists() { + let existing = read_json(&archive)?; + if existing["id"] != t["id"] || existing["keyFingerprint"].is_string() && existing["keyFingerprint"] != t["keyFingerprint"] + || existing["requestId"].is_string() && existing["requestId"] != t["requestId"] { return Err("原任务身份已有新记录,未覆盖原归档".into()); } + // An applied result is terminal, including when an older GET finishes with + // complete after the canvas switch. Never expose it as ready a second time. + if existing["phase"] == "applied" || existing["phase"] == "complete" && matches!(t["phase"].as_str(), Some("unknown"|"running"|"dispatched")) { settled = existing; } + } + if !archive.exists() || read_json(&archive)? != settled { write_json(&archive, &settled)?; } + let pointer = dir.join("image-task.json"); + if pointer.exists() { + let current = read_json(&pointer)?; + if current["id"] != settled["id"] && !(!current_only && settled["phase"] == "dispatched" && settled["replacesTaskId"] == current["id"]) { + // An old GET may finish after another task became current. Its archive is + // durable, but it must never move the project's current pointer backwards. + return Ok(()); + } + if current == settled { return Ok(()); } + } else if current_only { + return Ok(()); + } + write_json(&pointer, &settled) +} +pub(super) fn mark_applied_locked(dir: &Path, task: &str) -> R<()> { + original_project_present(dir)?; + let task = Uuid::parse_str(task).map_err(|_| "原图片任务身份无效")?.to_string(); + let mut record = read_json(&dir.join("image-jobs").join(&task).join("task.json"))?; + if record["id"] != task || !matches!(record["phase"].as_str(), Some("complete" | "applied")) { return Err("原图片任务尚未完成,未标记应用".into()); } + record["phase"] = json!("applied"); + job_save_locked(dir, &record, true) +} +pub(super) fn require_ready_image(task: &Value, input: &Value) -> R<()> { + if task["id"] != input["taskId"] || task["phase"] != "complete" { return Err("没有待应用的图片".into()); } + Ok(()) +} +fn job_bytes(dir: &Path, path: &Path, bytes: &[u8]) -> R<()> { + let _guard = sync::context_guard(); + original_project_present(dir)?; + photocraft_format::atomic_write(path, bytes).map_err(|_| "原项目结果无法保存".into()) +} +fn job_json(dir: &Path, path: &Path, value: &Value) -> R<()> { + let _guard = sync::context_guard(); + original_project_present(dir)?; + write_json(path, value) } fn bind_recovery_request_id(t: &mut Value, input: &Value) -> R { let Some(provided) = input.get("requestId").filter(|value| !value.is_null()) else { @@ -52,6 +110,9 @@ fn located_recovery_request_id(code: u16, response: &Value) -> R { if code != 200 { return Err(format!("原请求定位 HTTP {code},保留待核查;不会重发")); } + if response["status"] == "processing" { + return Err("原请求仍在处理中(processing),保留待核查;不会重发".into()); + } if response["status"] != "succeeded" || response["result_available"] != true { return Err("原请求尚无可取回的成功结果,保留待核查;不会重发".into()); } @@ -65,6 +126,39 @@ fn located_recovery_request_id(code: u16, response: &Value) -> R { } Ok(request_id) } +/// This transport boundary has only GET paths. Tests can supply an isolated HTTP +/// server; production supplies the fixed public 98k origin and original Key. +fn original_response( + task: &mut Value, + mut get: impl FnMut(&str, Option<&str>) -> R<(u16, Value)>, + mut persist: impl FnMut(&Value) -> R<()>, +) -> R { + if task["requestId"].is_null() { + let task_id = task["id"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原任务身份无效,未发起查询")?; + let (code, located) = get("/api/external/requests/by-idempotency-key", Some(task_id))?; + let request_id = located_recovery_request_id(code, &located)?; + if bind_recovery_request_id(task, &json!({"requestId":request_id}))? { persist(task)?; } + } + let rid = task["requestId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原请求缺少可查询编号,请保持待核查;不会重发")?; + let (code, body) = get(&format!("/api/external/requests/{rid}/result"), None)?; + if code != 200 { return Err(format!("原结果尚未取回(HTTP {code});不会重发")); } + retained_body(body) +} +pub(super) fn retry_original_queries(mut query: impl FnMut(Duration) -> R, mut wait: impl FnMut(Duration), mut elapsed: impl FnMut() -> Duration) -> R { + let backoff=[3,5,8,13,20,30];let mut attempt=0usize; + loop { + let budget=IMAGE_REQUEST_TIMEOUT.saturating_sub(elapsed()); + if budget.is_zero() { return Err("本次恢复查询窗口已结束,原请求保留待核查;不会重发".into()); } + let result = query(budget); + let message = match &result { Ok(v) if v["phase"] == "unknown" || v["phase"] == "running" => v["reply"].as_str().unwrap_or(""), Err(e) => e, _ => return result }; + let transient = message.contains("processing") || message.contains("连接中断") || message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"); + let remaining=IMAGE_REQUEST_TIMEOUT.saturating_sub(elapsed()); + if !transient || remaining.is_zero() { return result; } + let delay=Duration::from_secs(*backoff.get(attempt).unwrap_or(&30)); + wait(delay.min(remaining));attempt=attempt.saturating_add(1); + if elapsed()>=IMAGE_REQUEST_TIMEOUT {return result;} + } +} pub(super) fn public(t: &Value, doc: u64, account: &Value, origin: &str) -> Value { json!({"doc":doc,"revision":t["revision"],"accountId":account["id"],"origin":origin,"image":true,"taskId":t["id"],"requestId":t["requestId"],"upstreamTaskId":t["upstreamTaskId"],"unknown":matches!(t["phase"].as_str(),Some("dispatched"|"unknown"|"running")),"phase":t["phase"],"path":t["artifact"],"target":t["target"],"mode":t["mode"],"referenceCount":t["references"].as_array().map_or(0,Vec::len),"modelId":t["modelId"],"reply":t["message"],"center":t["center"]}) } @@ -86,6 +180,14 @@ fn asset_url(s: &str) -> R { Ok(u) } impl Backend { + fn image_validate(&mut self, deadline: Option) -> R<()> { + if deadline.is_none() {return self.validate();} + let token=self.auth["access_token"].as_str().ok_or("请先在素材库登录")?; + let response=self.http.get(format!("{}/api/desktop/session",self.origin)).bearer_auth(token).timeout(recovery_timeout(deadline,Duration::from_secs(30))?).send().map_err(|_|"素材库授权校验连接中断")?; + let (code,value)=body(response,MAX_JSON)?; + if code!=200 || value["account"]["id"]!=self.auth["account"]["id"] {return Err("授权已失效或账户已变化,原请求保留待核查".into());} + self.auth["account"]=value["account"].clone();Ok(()) + } pub(super) fn agent_image_preview(&self, input: &Value) -> R<(Value, egui::ColorImage)> { let doc = input["doc"].as_u64().ok_or("项目身份无效")?; self.ensure_owner(doc)?; @@ -134,14 +236,20 @@ impl Backend { )) } pub(super) fn image_job(&mut self, doc: Option>, input: &Value, recover: bool, tx: &mpsc::Sender, ctx: &egui::Context) -> R { + let deadline=if recover {input["recoveryBudgetMs"].as_u64().map(|ms|Instant::now()+Duration::from_millis(ms.min(180_000)))} else {None}; let id = doc.as_ref().map(|d| d.id.0).or_else(|| input["doc"].as_u64()).ok_or("请先打开项目")?; self.ensure_owner(id)?; - self.validate()?; + self.image_validate(deadline)?; let dir = self.project_dir(id)?; let settings = self.settings()?; let key = settings["key"].as_str().ok_or("请在对话栏连接个人Key")?; let mut t = if recover { - read_json(&dir.join("image-task.json"))? + if let Some(task) = input["taskId"].as_str() { + Uuid::parse_str(task).map_err(|_| "原图片任务身份无效")?; + let original = read_json(&dir.join("image-jobs").join(task).join("task.json"))?; + if original["id"] != task { return Err("原图片任务归档不一致".into()); } + original + } else { read_json(&dir.join("image-task.json"))? } } else { if pending(&dir)? || super::creation::unresolved(&dir)? @@ -150,6 +258,16 @@ impl Backend { return Err("原请求待核查,未提交新任务".into()); } let doc = doc.ok_or("画布快照缺失")?; + if !dir.join("context.json").exists() { + // A brand-new unsaved document can invoke image generation before + // the normal autosave tick. Persist it once, never over a newer save. + let bytes=photocraft_format::save_to_bytes(&doc,&Default::default()).map_err(|_|"原画布无法保存,未提交生图")?; + let _guard=sync::context_guard(); project_not_trashed(&dir)?; + if !dir.join("context.json").exists() { + photocraft_format::atomic_write(&dir.join("canvas.pcraft"),&bytes).map_err(|_|"原画布无法保存,未提交生图")?; + write_json(&dir.join("context.json"),&json!({"doc":id,"localId":self.locations.get(&id),"name":doc.name,"revision":input["revision"],"time":now().to_string(),"status":"draft","syncDirty":true,"messages":[]}))?; + } + } self.refresh_models()?; let choice = input["model"].as_str().filter(|s| !s.is_empty()).ok_or("请选择生图模型")?; if settings["imageModel"] != choice { @@ -197,9 +315,8 @@ impl Backend { if bytes.len() > 10 * 1024 * 1024 || total > 24 * 1024 * 1024 { return Err("参考图总量过大,请减少图片".into()); } - fs::create_dir_all(&files).map_err(|_| "本机任务目录不可写")?; let path = files.join(format!("reference-{i}.png")); - photocraft_format::atomic_write(&path, &bytes).map_err(|_| "参考图保存失败,未提交")?; + job_bytes(&dir, &path, &bytes).map_err(|_| "参考图保存失败,未提交")?; references.push(json!({"layer":layer.0,"name":name,"path":path.to_string_lossy(),"sha256":format!("{:x}",Sha256::digest(&bytes))})); } let guidance = json!({"brand":input["brand"],"facts":input["facts"]}); @@ -211,7 +328,8 @@ impl Backend { doc.name, input["screen"].as_str().unwrap_or("当前图片") ); - json!({"version":1,"id":job,"doc":id,"revision":input["revision"],"mode":input["mode"],"target":target,"modelId":model_id,"modelKey":choice,"protocol":contract,"prompt":prompt,"references":references,"keyFingerprint":hash(key),"phase":"prepared","requestId":null,"upstreamTaskId":null,"center":input["center"]}) + let prior = if dir.join("image-task.json").exists() { read_json(&dir.join("image-task.json"))?["id"].clone() } else { Value::Null }; + json!({"version":1,"id":job,"doc":id,"revision":input["revision"],"mode":input["mode"],"target":target,"modelId":model_id,"modelKey":choice,"protocol":contract,"prompt":prompt,"references":references,"keyFingerprint":hash(key),"phase":"prepared","requestId":null,"upstreamTaskId":null,"center":input["center"],"accountScope":self.scope(),"localProjectId":self.locations.get(&id),"origin":self.origin,"createdAt":now(),"replacesTaskId":prior,"creationBinding":input["creationBinding"]}) }; if t["keyFingerprint"] != hash(key) { return Err("请切回原个人Key,只读核查原请求;不会重发".into()); @@ -244,34 +362,24 @@ impl Backend { if t["upstreamTaskId"].is_string() { Value::Null } else { - if t["requestId"].is_null() { - let task_id = t["id"].as_str().filter(|value| Uuid::parse_str(value).is_ok()).ok_or("原任务身份无效,未发起查询")?; - progress("正在定位原请求…"); - let (code, located) = body( - self.http - .get(format!("{UPSTREAM}/api/external/requests/by-idempotency-key")) - .bearer_auth(key) - .header("Idempotency-Key", task_id) - .timeout(Duration::from_secs(20)) - .send() - .map_err(|_| "原请求定位连接中断,保留待核查;不会重发")?, - 64 * 1024, - )?; - let request_id = located_recovery_request_id(code, &located)?; - if bind_recovery_request_id(&mut t, &json!({"requestId":request_id}))? { - job_save(&dir, &t)?; - } - } - let rid = t["requestId"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("原请求缺少可查询编号,请保持待核查;不会重发")?; - progress("正在核查原请求…"); - let (code, v) = body( - self.http.get(format!("{UPSTREAM}/api/external/requests/{rid}/result")).bearer_auth(key).send().map_err(|_| "核查连接中断")?, - RESPONSE_BYTES, - )?; - if code != 200 { - return Err(format!("原结果尚未取回(HTTP {code});不会重发")); - } - retained_body(v)? + let session = Uuid::new_v4().to_string(); + original_response(&mut t, |path, idempotency| { + progress(if idempotency.is_some() { "正在定位原请求…" } else { "正在核查原请求…" }); + let diagnostic_file = taskdir.join("recovery-http.json"); + let mut diagnostic = if diagnostic_file.exists() { read_json(&diagnostic_file)? } else { json!({"attempts":[]}) }; + let kind = if idempotency.is_some() { "lookup" } else { "result" }; + let attempt = json!({"session":session,"kind":kind,"method":"GET","at":now(),"status":null}); + let rows = diagnostic["attempts"].as_array_mut().ok_or("恢复诊断记录无效")?; + if rows.len() >= 256 { rows.remove(0); } + rows.push(attempt); + job_json(&dir, &diagnostic_file, &diagnostic)?; + let mut request = self.http.get(format!("{UPSTREAM}{path}")).bearer_auth(key).timeout(recovery_timeout(deadline,Duration::from_secs(20))?); + if let Some(id) = idempotency { request = request.header("Idempotency-Key", id); } + let response = request.send().map_err(|_| "原请求只读查询连接中断,保留待核查;不会重发")?; + if let Some(last) = diagnostic["attempts"].as_array_mut().and_then(|a| a.last_mut()) { last["status"] = json!(response.status().as_u16()); } + job_json(&dir, &diagnostic_file, &diagnostic)?; + body(response, if idempotency.is_some() { 64 * 1024 } else { RESPONSE_BYTES }) + }, |task| job_save(&dir, task))? } } else { let p = t["protocol"].clone(); @@ -292,14 +400,19 @@ impl Backend { if p["multipart"] != true && !protocol::accepts(&payload, &p["schema"], 0) { return Err("当前参数不满足98k公开契约,未提交".into()); } - self.validate()?; // Live permission immediately before a paid dispatch. + self.image_validate(deadline)?; // Live permission immediately before a paid dispatch. t["phase"] = json!("dispatched"); job_save(&dir, &t)?; + if t["creationBinding"].is_object() { + // Both journals know the immutable original task before the paid POST. + super::creation::bind_image_dispatch(&dir, &mut t)?; + job_save(&dir, &t)?; + } progress("98k 正在生成图片…"); let mut req = self .http .post(format!("{UPSTREAM}{}", p["path"].as_str().ok_or("模型路径无效")?)) - .timeout(Duration::from_secs(180)) + .timeout(IMAGE_REQUEST_TIMEOUT) .bearer_auth(key) .header("Idempotency-Key", t["id"].as_str().ok_or("任务身份无效")?) .header("X-Relay-Route-Option", p["route"].as_str().ok_or("线路身份无效")?); @@ -344,7 +457,7 @@ impl Backend { let uid = t["upstreamTaskId"].as_str().filter(|s| protocol::token(s)).ok_or("任务号无效")?; progress("正在等待原任务结果…"); let (code, v) = body( - self.http.get(format!("{UPSTREAM}/extensions/v1/tasks/{uid}/result")).bearer_auth(key).send().map_err(|_| "任务查询连接中断")?, + self.http.get(format!("{UPSTREAM}/extensions/v1/tasks/{uid}/result")).bearer_auth(key).timeout(recovery_timeout(deadline,Duration::from_secs(30))?).send().map_err(|_| "任务查询连接中断")?, RESPONSE_BYTES, )?; if code == 202 && v["ready"] == false { @@ -368,7 +481,7 @@ impl Backend { if raw["model"].is_string() && raw["model"] != t["modelId"] { return Err("原结果与任务模型不一致,未应用".into()); } - photocraft_format::atomic_write(&raw_file, &serde_json::to_vec(&raw).map_err(|_| "原响应保存失败")?) + job_bytes(&dir, &raw_file, &serde_json::to_vec(&raw).map_err(|_| "原响应保存失败")?) .map_err(|_| "原结果保存失败,请保留原请求号")?; let outputs = raw["data"].as_array().filter(|a| a.len() == 1).ok_or("98k图片结果结构无法确认;不会重发")?; let out = &outputs[0]; @@ -380,8 +493,8 @@ impl Backend { STANDARD.decode(b).map_err(|_| "原图片编码无效")? } else { let url = asset_url(out["url"].as_str().ok_or("原响应没有可下载的图片")?)?; - self.validate()?; - let r = self.http.get(url).bearer_auth(key).send().map_err(|_| "图片下载中断,保留原结果后可重取")?; + self.image_validate(deadline)?; + let r = self.http.get(url).bearer_auth(key).timeout(recovery_timeout(deadline,Duration::from_secs(30))?).send().map_err(|_| "图片下载连接中断,保留原结果后可重取")?; if !r.status().is_success() { return Err(format!("98k图片下载 HTTP {},原结果已保留", r.status().as_u16())); } @@ -394,11 +507,11 @@ impl Backend { } let bytes = png(&bytes)?; let path = taskdir.join("generated.png"); - photocraft_format::atomic_write(&path, &bytes).map_err(|_| "生成图片无法保存,本机任务已保留")?; + job_bytes(&dir, &path, &bytes).map_err(|_| "生成图片无法保存,本机任务已保留")?; t["artifact"] = json!(path.to_string_lossy()); t["sha256"] = json!(format!("{:x}", Sha256::digest(&bytes))); t["phase"] = json!("complete"); - self.validate()?; + self.image_validate(deadline)?; t["message"] = json!("图片已生成"); Ok(()) })(); @@ -431,9 +544,7 @@ impl Backend { self.ensure_owner(doc)?; self.validate()?; let t = read_json(&self.project_dir(doc)?.join("image-task.json"))?; - if t["id"] != input["taskId"] || t["phase"] != "complete" { - return Err("没有待应用的图片".into()); - } + require_ready_image(&t, input)?; let bytes = fs::read(t["artifact"].as_str().ok_or("图片文件缺失")?).map_err(|_| "生成图片不可读取")?; if bytes.len() as u64 > IMAGE_BYTES || t["sha256"] != format!("{:x}", Sha256::digest(&bytes)) { return Err("生成图片已变化,未应用".into()); @@ -447,11 +558,146 @@ impl Backend { mod tests { use super::*; #[test] + fn applied_image_journal_is_terminal_and_cannot_be_used_again() { + let dir = std::env::temp_dir().join(format!("image-applied-terminal-{}", Uuid::new_v4())); + write_json(&dir.join("context.json"), &json!({})).unwrap(); + let complete = json!({"id":Uuid::new_v4().to_string(),"phase":"complete","requestId":"cc6a9ed9-863b-419f-bd1a-b126833227fb","keyFingerprint":"original","artifact":"original.png","sha256":"original-pixels"}); + job_save(&dir, &complete).unwrap(); + let input = json!({"taskId":complete["id"]}); + assert!(require_ready_image(&complete, &input).is_ok()); + { let _guard = sync::context_guard(); mark_applied_locked(&dir, complete["id"].as_str().unwrap()).unwrap(); } + let applied = read_json(&dir.join("image-task.json")).unwrap(); + assert_eq!(applied["phase"], "applied"); + assert!(require_ready_image(&applied, &input).is_err(), "image_use must reject an automatically applied result"); + assert_ne!(public(&applied, 1, &json!({"id":"own-account"}), "own-origin")["phase"], "complete", "draft must not expose ready_image again"); + for phase in ["complete", "unknown", "running", "dispatched", "failed"] { + let mut late = complete.clone(); late["phase"] = json!(phase); + job_save(&dir, &late).unwrap(); + assert_eq!(read_json(&dir.join("image-task.json")).unwrap(), applied); + assert_eq!(read_json(&dir.join("image-jobs").join(complete["id"].as_str().unwrap()).join("task.json")).unwrap(), applied); + } + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn marking_an_old_image_applied_cannot_claim_a_new_or_absent_pointer() { + let dir = std::env::temp_dir().join(format!("image-applied-pointer-{}", Uuid::new_v4())); + write_json(&dir.join("context.json"), &json!({})).unwrap(); + let old = json!({"id":Uuid::new_v4().to_string(),"phase":"complete"}); + job_save(&dir, &old).unwrap(); + let current = json!({"id":Uuid::new_v4().to_string(),"phase":"dispatched","replacesTaskId":old["id"]}); + job_save(&dir, ¤t).unwrap(); + { let _guard = sync::context_guard(); mark_applied_locked(&dir, old["id"].as_str().unwrap()).unwrap(); } + assert_eq!(read_json(&dir.join("image-task.json")).unwrap(), current); + assert_eq!(read_json(&dir.join("image-jobs").join(old["id"].as_str().unwrap()).join("task.json")).unwrap()["phase"], "applied"); + fs::remove_file(dir.join("image-task.json")).unwrap(); + { let _guard = sync::context_guard(); mark_applied_locked(&dir, old["id"].as_str().unwrap()).unwrap(); } + assert!(!dir.join("image-task.json").exists(), "repair must not recreate a retired current pointer"); + fs::remove_dir_all(dir).unwrap(); + } + fn mock_recovery_http(lookup_code: u16, lookup: Value, result_code: u16) -> (R, Vec, usize) { + use std::net::{TcpListener, TcpStream}; + use std::sync::atomic::{AtomicUsize, Ordering}; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let durable = Arc::new(AtomicUsize::new(0)); let check = durable.clone(); + let server = std::thread::spawn(move || { + let start = Instant::now(); let mut methods = Vec::new(); + while start.elapsed() < Duration::from_secs(2) { + let (mut socket, _) = match listener.accept() { Ok(v) => v, Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(2)); continue; }, Err(e) => panic!("{e}") }; + socket.set_nonblocking(false).unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let mut request = Vec::new(); let mut byte = [0u8]; + while !request.ends_with(b"\r\n\r\n") && request.len() < 8192 { socket.read_exact(&mut byte).unwrap(); request.extend(byte); } + let request = String::from_utf8(request).unwrap(); + methods.push(request.lines().next().unwrap().to_owned()); + let is_lookup = request.contains("by-idempotency-key"); + if is_lookup && lookup_code == 0 { continue; } + let (code, body) = if is_lookup { (lookup_code, lookup.clone()) } else { + assert_eq!(check.load(Ordering::SeqCst), 1, "original request ID must be durable before the result GET"); + (result_code, json!({"data":[{"b64_json":"original-image"}]})) + }; + let text = body.to_string(); + write!(socket, "HTTP/1.1 {code} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap(); + } + methods + }); + let mut task = json!({"id":"42e84e04-4738-48c0-b3c0-31ee097b7a35","phase":"unknown","requestId":null}); + let result = original_response(&mut task, |path, key| { + let mut socket = TcpStream::connect(address).map_err(|e| e.to_string())?; + socket.set_read_timeout(Some(Duration::from_secs(1))).map_err(|e| e.to_string())?; + write!(socket,"GET {path} HTTP/1.1\r\nHost: {address}\r\nIdempotency-Key: {}\r\nConnection: close\r\n\r\n",key.unwrap_or("")).map_err(|e| e.to_string())?; + let mut response = String::new(); socket.read_to_string(&mut response).map_err(|e| e.to_string())?; + let (header, body) = response.split_once("\r\n\r\n").ok_or("原请求只读查询连接中断")?; + let code = header.split_whitespace().nth(1).ok_or("missing HTTP status")?.parse::().map_err(|e|e.to_string())?; + Ok((code, serde_json::from_str(body).map_err(|e|e.to_string())?)) + }, |_| { durable.fetch_add(1,Ordering::SeqCst); Ok(()) }); + assert_eq!(task["id"], "42e84e04-4738-48c0-b3c0-31ee097b7a35"); assert_eq!(task["phase"], "unknown"); + (result, server.join().unwrap(), durable.load(Ordering::SeqCst)) + } + #[test] + fn restart_http_recovery_uses_only_two_original_gets_with_durable_binding() { + let lookup = json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); + let (result, calls, writes) = mock_recovery_http(200, lookup, 200); + assert!(result.is_ok()); assert_eq!(writes,1); assert_eq!(calls.len(),2); + assert!(calls.iter().all(|v|v.starts_with("GET "))); assert!(calls[0].contains("by-idempotency-key")); assert!(calls[1].contains("cc6a9ed9-863b-419f-bd1a-b126833227fb/result")); + } + #[test] + fn restart_http_recovery_never_posts_when_result_is_missing_pending_or_expired() { + let original=json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); + for (code,status,available,result_code,expected) in [(404,"succeeded",true,200,1),(200,"processing",false,200,1),(200,"unresolved",false,200,1),(200,"succeeded",false,200,1),(200,"succeeded",true,410,2)] { + let mut lookup=original.clone(); lookup["status"]=json!(status); lookup["result_available"]=json!(available); + let (result,calls,_) = mock_recovery_http(code,lookup,result_code); + assert!(result.is_err()); assert_eq!(calls.len(),expected); assert!(calls.iter().all(|v|v.starts_with("GET "))); + } + let mut foreign=original; foreign["result_path"]=json!("https://other.invalid/result"); + let (result,calls,_) = mock_recovery_http(200,foreign,200); assert!(result.is_err()); assert_eq!(calls.len(),1); + } + #[test] + fn old_recovery_cannot_replace_the_current_project_task() { + let dir=std::env::temp_dir().join(format!("image-pointer-cas-{}",Uuid::new_v4())); + let old=json!({"id":Uuid::new_v4().to_string(),"phase":"unknown","requestId":null}); + job_save(&dir,&old).unwrap(); + let new=json!({"id":Uuid::new_v4().to_string(),"phase":"dispatched","replacesTaskId":old["id"]}); + job_save(&dir,&new).unwrap(); + let mut completed=old.clone(); completed["phase"]=json!("complete"); completed["requestId"]=json!("cc6a9ed9-863b-419f-bd1a-b126833227fb"); + job_save(&dir,&completed).unwrap(); + assert_eq!(read_json(&dir.join("image-task.json")).unwrap(),new); + assert_eq!(read_json(&dir.join("image-jobs").join(old["id"].as_str().unwrap()).join("task.json")).unwrap(),completed); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn bounded_restart_recovery_retries_processing_and_one_transport_break_with_only_gets() { + let original=json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); + for first_code in [200,0] { + let mut invocation=0;let mut calls=Vec::new();let mut waits=Vec::new(); + let result=retry_original_queries(|_|{ + invocation+=1;let mut lookup=original.clone();if invocation==1 {lookup["status"]=json!("processing");lookup["result_available"]=json!(false);} + let (result,actual,_) = mock_recovery_http(if invocation==1 {first_code}else{200},lookup,200);calls.extend(actual);result + },|delay|waits.push(delay),||Duration::ZERO); + assert!(result.is_ok());assert_eq!(invocation,2);assert_eq!(waits.len(),1);assert_eq!(calls.len(),3);assert!(calls.iter().all(|v|v.starts_with("GET "))); + } + let mut count=0; + let result=retry_original_queries(|_|{count+=1;Err("原结果尚未取回(HTTP 410);不会重发".into())},|_|panic!("expired result must not be polled"),||Duration::ZERO); + assert!(result.is_err());assert_eq!(count,1); + } + #[test] + fn processing_can_finish_after_two_seconds_but_this_recovery_window_stops_at_180() { + use std::cell::Cell; + let elapsed=Cell::new(Duration::ZERO);let mut polls=0; + let result=retry_original_queries(|budget|{polls+=1;assert!(budget<=IMAGE_REQUEST_TIMEOUT);Ok(if elapsed.get()>Duration::from_secs(2) {json!({"phase":"complete"})}else{json!({"phase":"unknown","reply":"processing"})})},|delay|elapsed.set(elapsed.get()+delay),||elapsed.get()); + assert_eq!(result.unwrap()["phase"],"complete");assert_eq!(polls,2);assert!(elapsed.get()>Duration::from_secs(2)); + elapsed.set(Duration::ZERO);let mut polls=0; + let result=retry_original_queries(|budget|{polls+=1;assert!(budget>Duration::ZERO);Ok(json!({"phase":"unknown","reply":"processing"}))},|delay|elapsed.set(elapsed.get()+delay),||elapsed.get()); + assert_eq!(result.unwrap()["phase"],"unknown");assert_eq!(elapsed.get(),IMAGE_REQUEST_TIMEOUT);assert_eq!(polls,10); + } + #[test] fn image_recovery_binds_original_request_without_changing_task_or_phase() { let dir = std::env::temp_dir().join(format!("jiansun-image-bind-{}", Uuid::new_v4())); - let id = Uuid::new_v4().to_string(); let request_id = "cc6a9ed9-863b-419f-bd1a-b126833227fb"; for phase in ["dispatched", "unknown", "running"] { + let id = Uuid::new_v4().to_string(); + if dir.join("image-task.json").exists() { fs::remove_file(dir.join("image-task.json")).unwrap(); } let original = json!({"id":id,"phase":phase,"requestId":null,"upstreamTaskId":"retained-upstream-task","keyFingerprint":"original-fingerprint","references":[{"path":"original-reference.png"}],"modelId":"original-model"}); job_save(&dir, &original).unwrap(); let mut restored = read_json(&dir.join("image-task.json")).unwrap(); @@ -464,11 +710,7 @@ mod tests { assert_eq!(read_json(&dir.join("image-jobs").join(&id).join("task.json")).unwrap(), expected); assert!(pending(&dir).unwrap(), "adding a query identity must not declare the original request completed"); } - fs::remove_file(dir.join("image-task.json")).unwrap(); - fs::remove_file(dir.join("image-jobs").join(&id).join("task.json")).unwrap(); - fs::remove_dir(dir.join("image-jobs").join(&id)).unwrap(); - fs::remove_dir(dir.join("image-jobs")).unwrap(); - fs::remove_dir(dir).unwrap(); + fs::remove_dir_all(dir).unwrap(); } #[test] fn image_recovery_rejects_invalid_request_identifiers_without_mutating_the_task() { diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_lifecycle.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_lifecycle.rs new file mode 100644 index 00000000..c46019e0 --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_lifecycle.rs @@ -0,0 +1,353 @@ +//! Bounded, profile-local GUI lifecycle evidence. No arguments, document data, credentials or +//! panic payloads are recorded. A missing normal return proves only an unclean end, not its cause. + +use serde_json::{Value, json}; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_RECORD_BYTES: u64 = 4096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum State { + Running, + CleanExit, + ReturnedError, +} + +impl State { + fn name(self) -> &'static str { + match self { + Self::Running => "running", + Self::CleanExit => "clean_exit", + Self::ReturnedError => "returned_error", + } + } + + fn observed_end(self) -> &'static str { + match self { + Self::Running => "unclean", + Self::CleanExit => "clean_exit", + Self::ReturnedError => "returned_error", + } + } +} + +struct Record { + session: String, + pid: u32, + version: String, + started_at_ms: u64, + last_event_at_ms: u64, + state: State, + panic_observed: bool, + panic_at_ms: Option, + previous_end: &'static str, +} + +impl Record { + fn value(&self) -> Value { + json!({ + "schema_version": 1, + "mode": "gui", + "session": self.session, + "pid": self.pid, + "version": self.version, + "started_at_ms": self.started_at_ms, + "last_event_at_ms": self.last_event_at_ms, + "state": self.state.name(), + "panic_observed": self.panic_observed, + "panic_at_ms": self.panic_at_ms, + "previous_end": self.previous_end, + "previous_unclean_reason": if self.previous_end == "unclean" { Some("unknown") } else { None }, + }) + } + + fn parse(v: &Value) -> Option { + if v.get("schema_version")?.as_u64()? != 1 || v.get("mode")?.as_str()? != "gui" { + return None; + } + let session = v.get("session")?.as_str()?; + uuid::Uuid::parse_str(session).ok()?; + let state = match v.get("state")?.as_str()? { + "running" => State::Running, + "clean_exit" => State::CleanExit, + "returned_error" => State::ReturnedError, + _ => return None, + }; + Some(Self { + session: session.to_string(), + pid: u32::try_from(v.get("pid")?.as_u64()?).ok()?, + version: safe_version(v.get("version")?.as_str()?), + started_at_ms: v.get("started_at_ms")?.as_u64()?, + last_event_at_ms: v.get("last_event_at_ms")?.as_u64()?, + state, + panic_observed: v.get("panic_observed")?.as_bool()?, + panic_at_ms: v.get("panic_at_ms").and_then(Value::as_u64), + // Previous history is not recursively copied into the next record. + previous_end: "no_record", + }) + } +} + +/// Holds the profile's writer lock until this GUI ends. There is deliberately no Drop that marks +/// success: panic, process termination and power loss must not become a fabricated clean exit. +pub struct Lifecycle { + dir: PathBuf, + record: Mutex, + _writer_lock: File, +} + +impl Lifecycle { + /// Diagnostics are best effort and never prevent the application opening. A concurrent GUI + /// using the same profile cannot overwrite the first GUI's record. + pub fn begin(profile: &Path, version: &str) -> Option> { + match Self::open(profile, version) { + Ok(lifecycle) => Some(Arc::new(lifecycle)), + Err(error) => { + log::warn!("GUI lifecycle diagnostics unavailable ({:?}); startup continues", error.kind()); + None + } + } + } + + fn open(profile: &Path, version: &str) -> io::Result { + let dir = profile.join("jiansun").join("lifecycle"); + std::fs::create_dir_all(&dir)?; + let writer_lock = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(dir.join("writer.lock"))?; + writer_lock.try_lock().map_err(|error| match error { + std::fs::TryLockError::WouldBlock => io::Error::from(io::ErrorKind::WouldBlock), + std::fs::TryLockError::Error(error) => error, + })?; + + let previous_end = match read_record(&dir.join("current.json")) { + Ok(Some(previous)) => { + let mut archived = previous.value(); + archived["observed_end"] = json!(previous.state.observed_end()); + archived["unclean_reason"] = if previous.state == State::Running { json!("unknown") } else { Value::Null }; + write_record(&dir.join("previous.json"), &archived)?; + previous.state.observed_end() + } + Ok(None) => "no_record", + // A corrupt/unreadable old record supplies no evidence of a clean or unclean exit. + Err(_) => "unreadable", + }; + let now = now_ms(); + let record = Record { + session: uuid::Uuid::new_v4().to_string(), + pid: std::process::id(), + version: safe_version(version), + started_at_ms: now, + last_event_at_ms: now, + state: State::Running, + panic_observed: false, + panic_at_ms: None, + previous_end, + }; + write_record(&dir.join("current.json"), &record.value())?; + Ok(Self { dir, record: Mutex::new(record), _writer_lock: writer_lock }) + } + + /// Chain the existing crash hook unchanged. Only the fact and time of observing a panic is + /// retained here; caught panics are not labelled process crashes or assigned an exit code. + pub fn install_panic_hook(self: &Arc) { + let lifecycle = Arc::clone(self); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + lifecycle.panic_observed(); + previous(info); + })); + } + + fn panic_observed(&self) { + // A panic while writing a record must not recursively wait for the same mutex. + let mut record = match self.record.try_lock() { + Ok(record) => record, + Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return, + }; + if record.state != State::Running { + return; + } + record.panic_observed = true; + record.panic_at_ms = Some(now_ms()); + record.last_event_at_ms = now_ms(); + let _ = write_record(&self.dir.join("current.json"), &record.value()); + } + + /// Called only after the GUI event loop actually returns. Does not infer who closed it, an OS + /// process exit status, or whether a previously caught panic caused a later termination. + pub fn finish(&self, returned_ok: bool) { + let mut record = self.record.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if record.state != State::Running { + return; + } + record.state = if returned_ok { State::CleanExit } else { State::ReturnedError }; + record.last_event_at_ms = now_ms(); + if let Err(error) = write_record(&self.dir.join("current.json"), &record.value()) { + log::warn!("GUI lifecycle final record unavailable ({:?})", error.kind()); + } + // A deliberate CPU-compatibility relaunch can begin before this process fully exits. + // The final state is immutable above, so its old hook cannot overwrite the next GUI. + let _ = self._writer_lock.unlock(); + } +} + +fn now_ms() -> u64 { + u64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()).unwrap_or(u64::MAX) +} + +fn safe_version(version: &str) -> String { + version.chars().filter(|c| c.is_ascii_alphanumeric() || " ._-+():".contains(*c)).take(96).collect() +} + +fn read_record(path: &Path) -> io::Result> { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let mut bytes = Vec::new(); + file.take(MAX_RECORD_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_RECORD_BYTES { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + let value: Value = serde_json::from_slice(&bytes).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; + Record::parse(&value).map(Some).ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData)) +} + +fn write_record(path: &Path, value: &Value) -> io::Result<()> { + let bytes = serde_json::to_vec(value).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; + if bytes.len() as u64 > MAX_RECORD_BYTES { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + photocraft_format::atomic_write(path, &bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + Self(std::env::temp_dir().join(format!("jiansun-lifecycle-test-{}", uuid::Uuid::new_v4()))) + } + fn record(&self, file: &str) -> Value { + serde_json::from_slice(&std::fs::read(self.0.join("jiansun/lifecycle").join(file)).unwrap()).unwrap() + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn normal_return_is_archived_and_history_is_bounded() { + let dir = TestDir::new(); + let first = Lifecycle::open(&dir.0, "1.2.3").unwrap(); + first.finish(true); + let session = dir.record("current.json")["session"].clone(); + drop(first); + let second = Lifecycle::open(&dir.0, "1.2.4").unwrap(); + assert_eq!(dir.record("current.json")["previous_end"], "clean_exit"); + assert_eq!(dir.record("previous.json")["session"], session); + assert_eq!(dir.record("previous.json")["observed_end"], "clean_exit"); + second.finish(false); + drop(second); + let third = Lifecycle::open(&dir.0, "1.2.5").unwrap(); + assert_eq!(dir.record("current.json")["previous_end"], "returned_error"); + assert_eq!(std::fs::read_dir(dir.0.join("jiansun/lifecycle")).unwrap().count(), 3); + third.finish(true); + } + + #[test] + fn dropped_running_session_only_proves_unclean_unknown() { + let dir = TestDir::new(); + drop(Lifecycle::open(&dir.0, "test").unwrap()); + let next = Lifecycle::open(&dir.0, "test").unwrap(); + let previous = dir.record("previous.json"); + assert_eq!(previous["observed_end"], "unclean"); + assert_eq!(previous["unclean_reason"], "unknown"); + assert!(previous.get("exit_code").is_none()); + assert!(previous.get("terminated_by").is_none()); + assert_eq!(dir.record("current.json")["previous_unclean_reason"], "unknown"); + next.finish(true); + } + + #[test] + fn panic_observation_does_not_claim_a_crash_or_store_payload() { + let dir = TestDir::new(); + let lifecycle = Lifecycle::open(&dir.0, "test").unwrap(); + lifecycle.panic_observed(); + assert_eq!(dir.record("current.json")["state"], "running"); + assert_eq!(dir.record("current.json")["panic_observed"], true); + lifecycle.finish(true); + let current = dir.record("current.json"); + assert_eq!(current["state"], "clean_exit"); + assert_eq!(current["panic_observed"], true); + assert!(current.get("panic_payload").is_none()); + assert!(current.get("message").is_none()); + } + + #[test] + fn second_writer_cannot_replace_running_evidence() { + let dir = TestDir::new(); + let first = Lifecycle::open(&dir.0, "test").unwrap(); + let session = dir.record("current.json")["session"].clone(); + assert_eq!(Lifecycle::open(&dir.0, "test").err().map(|e| e.kind()), Some(io::ErrorKind::WouldBlock)); + assert_eq!(dir.record("current.json")["session"], session); + first.finish(true); + } + + #[test] + fn finished_session_cannot_overwrite_a_compatibility_restart() { + let dir = TestDir::new(); + let first = Lifecycle::open(&dir.0, "first").unwrap(); + first.finish(false); + let second = Lifecycle::open(&dir.0, "restart").unwrap(); + let session = dir.record("current.json")["session"].clone(); + first.panic_observed(); + first.finish(true); + assert_eq!(dir.record("current.json")["session"], session); + assert_eq!(dir.record("current.json")["state"], "running"); + assert_eq!(dir.record("current.json")["panic_observed"], false); + second.finish(true); + } + + #[test] + fn hostile_previous_record_is_not_copied_or_called_a_crash() { + let dir = TestDir::new(); + let path = dir.0.join("jiansun/lifecycle"); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("current.json"), vec![b'x'; MAX_RECORD_BYTES as usize + 1]).unwrap(); + let lifecycle = Lifecycle::open(&dir.0, "test").unwrap(); + assert_eq!(dir.record("current.json")["previous_end"], "unreadable"); + assert!(!path.join("previous.json").exists()); + assert!(std::fs::metadata(path.join("current.json")).unwrap().len() <= MAX_RECORD_BYTES); + lifecycle.finish(true); + } + + #[test] + fn archive_uses_allowlist_and_does_not_copy_injected_fields() { + let dir = TestDir::new(); + let first = Lifecycle::open(&dir.0, "test").unwrap(); + first.finish(true); + drop(first); + let mut value = dir.record("current.json"); + value["api_key"] = json!("must-not-be-retained"); + value["prompt"] = json!("must-not-be-retained"); + write_record(&dir.0.join("jiansun/lifecycle/current.json"), &value).unwrap(); + let next = Lifecycle::open(&dir.0, "test").unwrap(); + let archived = dir.record("previous.json").to_string(); + assert!(!archived.contains("must-not-be-retained")); + assert!(!archived.contains("api_key")); + assert!(!archived.contains("prompt")); + next.finish(true); + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs index 44b651f5..22ef3822 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs @@ -42,6 +42,7 @@ fn cloud_matches(local: &Value, remote: &Value) -> bool { } fn trash_local(account: &Path, id: &str) -> R<()> { + let _guard = sync::context_guard(); let dir = local_path(&account.join("projects"), id)?; idle(&dir)?; let trash = account.join("trash"); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs new file mode 100644 index 00000000..201545b2 --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs @@ -0,0 +1,665 @@ +//! Restart recovery is GET-only and scoped to the original account/project/task. +//! Results are staged first; an edited or open canvas is never overwritten. +use super::*; + +fn uuid(value: &Value) -> R<&str> { + value.as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("原任务关联身份无效,保留待核查".into()) +} +// The canonical switch and its creation receipt have already succeeded. Keep the +// original task terminal too; a crash between those writes is repaired on open. +fn settle_applied_recovery_locked(dir: &Path, creation: &Value) -> R { + let recovery = &creation["generation"]["recovery"]; + if recovery["applied"] != true { return Ok(false); } + images::mark_applied_locked(dir, uuid(&recovery["taskId"])?)?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn an_already_applied_creation_repairs_its_original_image_journal_on_restart() { + let dir = std::env::temp_dir().join(format!("recovery-applied-repair-{}", Uuid::new_v4())); + write_json(&dir.join("context.json"), &json!({})).unwrap(); + let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete","requestId":"cc6a9ed9-863b-419f-bd1a-b126833227fb"}); + images::job_save(&dir, &task).unwrap(); + let c = json!({"version":1,"revision":8,"generation":{"recovery":{"taskId":task["id"],"applied":true,"state":"already-applied","recoveryApplyCount":1}}}); + write_json(&dir.join("creation.json"), &c).unwrap(); + let _guard = sync::context_guard(); + assert!(settle_applied_recovery_locked(&dir, &creation::load(&dir).unwrap()).unwrap()); + let file = dir.join("image-task.json"); + let before = fs::read(&file).unwrap(); + assert_eq!(read_json(&file).unwrap()["phase"], "applied"); + assert!(settle_applied_recovery_locked(&dir, &c).unwrap()); + assert_eq!(fs::read(&file).unwrap(), before); + assert_eq!(creation::load(&dir).unwrap(), c, "journal repair must not reapply the canvas or increment creation"); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn an_unapplied_or_conflicting_recovery_keeps_the_original_image_ready() { + let dir = std::env::temp_dir().join(format!("recovery-unapplied-ready-{}", Uuid::new_v4())); + write_json(&dir.join("context.json"), &json!({})).unwrap(); + let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete"}); + images::job_save(&dir, &task).unwrap(); + let _guard = sync::context_guard(); + for state in ["recovered", "conflict"] { + let c = json!({"generation":{"recovery":{"taskId":task["id"],"applied":false,"state":state}}}); + assert!(!settle_applied_recovery_locked(&dir, &c).unwrap()); + let current = read_json(&dir.join("image-task.json")).unwrap(); + assert_eq!(current, task); + assert!(images::require_ready_image(¤t, &json!({"taskId":task["id"]})).is_ok()); + } + fs::remove_dir_all(dir).unwrap(); + } + fn legacy_fixture() -> (PathBuf, Value, Value) { + let dir = std::env::temp_dir().join(format!("restart-association-{}", Uuid::new_v4())); + let mut c = json!({"version":1,"revision":0,"width":1440,"brief":{},"sellingPoints":{"selected":[],"batches":[]},"plan":{}}); + c["brief"]["name"] = json!("真实产品"); + c["plan"]["screens"] = json!([{"id":"5814971b-4a4c-415e-a4bc-df6cceae932d","name":"首屏","title":"原标题","body":"事实说明","imagePrompt":"首屏产品"},{"id":"6e877a5f-e83c-4d62-a6bf-33af0a401288","name":"人物细节","title":"细节","body":"实物细节","imagePrompt":"原产品的真实细节"}]); + c["plan"]["status"] = json!("confirmed"); + let plan = creation::plan_hash(&c).unwrap(); + c["plan"]["confirmedInput"] = json!(plan); + c["generation"] = json!({"run":"7862bc99-7485-420c-bbcd-df13a2c117fe","planHash":plan,"lastTaskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19","screens":{"5814971b-4a4c-415e-a4bc-df6cceae932d":{"taskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19"}},"pending":{"baseRevision":3,"previousTaskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19","screenId":"6e877a5f-e83c-4d62-a6bf-33af0a401288","sequence":1}}); + let reference = dir.join("image-jobs/42e84e04-4738-48c0-b3c0-31ee097b7a35/reference-0.png"); + fs::create_dir_all(reference.parent().unwrap()).unwrap(); + fs::write(&reference, b"original-pixels").unwrap(); + let task = json!({"id":"42e84e04-4738-48c0-b3c0-31ee097b7a35","phase":"dispatched","revision":3,"mode":"generate","target":null,"requestId":null,"prompt":"项目:真实产品\n当前屏:人物细节\n要求:原产品的真实细节","references":[{"path":reference.to_string_lossy(),"sha256":fingerprint(b"original-pixels")}]}); + write_json(&dir.join("image-jobs/42e84e04-4738-48c0-b3c0-31ee097b7a35/task.json"), &task).unwrap(); + (dir, c, task) + } + #[test] + fn legacy_without_request_id_task_id_or_timestamp_is_uniquely_bound() { + let (dir, c, task) = legacy_fixture(); + assert!(c["generation"]["pending"]["taskId"].is_null()); + assert!(task["requestId"].is_null()); + assert!(task["createdAt"].is_null()); + let binding = legacy_binding(&dir, &c, &task, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").unwrap(); + assert_eq!(binding["screenId"], "6e877a5f-e83c-4d62-a6bf-33af0a401288"); + assert_eq!(binding["legacyVerified"], true); + let mut bound = task.clone(); + bound["creationBinding"] = binding; + assert!(association(&c, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").is_ok()); + assert!(association(&c, &bound, "different-project", "scope", "https://sc.jiansun.vip").is_err()); + assert!(association(&c, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "other-account", "https://sc.jiansun.vip").is_err()); + let mut changed = c.clone(); + changed["plan"]["screens"][1]["title"] = json!("新方案"); + assert!(association(&changed, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").is_err()); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn legacy_ambiguity_reference_changes_and_wrong_sequence_stay_unknown() { + let (dir, c, task) = legacy_fixture(); + let mut wrong = c.clone(); + wrong["generation"]["pending"]["sequence"] = json!(0); + assert!(legacy_binding(&dir, &wrong, &task, "project", "scope", "origin").is_err()); + let mut duplicate = task.clone(); + duplicate["id"] = json!(Uuid::new_v4().to_string()); + let duplicate_file = dir.join("image-jobs").join(duplicate["id"].as_str().unwrap()).join("task.json"); + write_json(&duplicate_file, &duplicate).unwrap(); + assert!(legacy_binding(&dir, &c, &task, "project", "scope", "origin").is_err()); + fs::remove_file(duplicate_file).unwrap(); + fs::write(task["references"][0]["path"].as_str().unwrap(), b"modified").unwrap(); + assert!(legacy_binding(&dir, &c, &task, "project", "scope", "origin").is_err()); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn creation_revision_cas_preserves_edits_made_during_original_get() { + let (dir, mut c, _) = legacy_fixture(); + creation::save(&dir, &mut c).unwrap(); + let mut stale = c.clone(); + c["brief"]["name"] = json!("用户的新产品资料"); + creation::save(&dir, &mut c).unwrap(); + stale["generation"]["pending"] = Value::Null; + assert!(creation::save(&dir, &mut stale).is_err()); + assert_eq!(creation::load(&dir).unwrap(), c); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn failed_permission_or_original_key_checks_do_not_consume_the_recovery_slot() { + let attempts = std::sync::Mutex::new(std::collections::HashSet::new()); + let key = "account|project|original-task".to_owned(); + for message in ["授权校验连接中断", "请切回原个人Key"] { + assert!(reserve_attempt(&attempts, key.clone(), Err(message.into())).is_err()); + assert!(attempts.lock().unwrap().is_empty()); + } + assert!(reserve_attempt(&attempts, key.clone(), Ok(())).unwrap()); + assert!(!reserve_attempt(&attempts, key, Ok(())).unwrap()); + assert_eq!(attempts.lock().unwrap().len(), 1); + } + #[test] + fn legacy_numeric_projects_remain_readable_without_becoming_recent_uuid_projects() { + assert!(!rememberable_project("123456").unwrap()); + assert!(rememberable_project("024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692").unwrap()); + assert!(rememberable_project("../outside").is_err()); + let binding=json!({"accountScope":"own","origin":"https://sc.jiansun.vip","accountId":"owner","localId":"123456"}); + assert_eq!(open_binding_local(&binding,"own","https://sc.jiansun.vip",&json!({"id":"owner"})).unwrap(),"123456"); + } + #[test] + fn a_late_original_get_cannot_resurrect_a_deleted_project() { + let root = std::env::temp_dir().join(format!("restart-deleted-{}", Uuid::new_v4())); + let account = root.join("account"); + let local = Uuid::new_v4().to_string(); + let dir = account.join("projects").join(&local); + write_json(&dir.join("context.json"), &json!({"localId":local,"revision":1})).unwrap(); + let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete","creationBinding":{"localProjectId":local}}); + images::job_save(&dir, &task).unwrap(); + let trash = account.join("trash").join(&local); + fs::create_dir_all(trash.parent().unwrap()).unwrap(); + { + let _guard = sync::context_guard(); + fs::rename(&dir, &trash).unwrap(); + } + let mut late = task.clone(); + late["message"] = json!("GET arrived after deletion"); + assert!(images::job_save(&dir, &late).is_err()); + assert!(receipt_save(&dir, task["id"].as_str().unwrap(), &json!({"state":"recovered"})).is_err()); + let mut stale = json!({"version":1,"revision":0}); + assert!(creation::save(&dir, &mut stale).is_err()); + assert!(!dir.exists()); + assert_eq!(read_json(&trash.join("image-task.json")).unwrap(), task); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn single_screen_patch_keeps_other_chinese_edits_ids_and_moves_later_screens() { + let screen = |name: &str, height: u32, title: &str| json!({"name":name,"width":1440,"height":height,"background":"#ffffff","layers":[{"kind":"text","name":"标题","text":title,"font":"NotoSansSC-Regular","fontSize":36,"x":20,"y":20,"width":1000,"height":80},{"kind":"shape","shape":"rect","name":"画面","color":"#204060","x":0,"y":120,"width":1440,"height":height-120}]}); + let original = json!({"name":"三屏样张","screens":[screen("首屏",600,"原标题"),screen("细节",400,"细节"),screen("设备",500,"设备")]}); + let baseline = crate::jiansun_document::import(&original, &HashMap::new()).unwrap(); + let mut current = baseline.clone(); + let child = current.layers[0].children_mut().unwrap().iter_mut().find(|l| matches!(l.content, photocraft_doc::LayerContent::Text(_))).unwrap(); + if let photocraft_doc::LayerContent::Text(t) = &mut child.content { + t.text = "照见生活".into(); + } + let first = current.layers[0].clone(); + let third_id = current.layers[2].id; + let mut desired_json = original; + desired_json["screens"][1] = screen("细节", 800, "细节"); + let desired = crate::jiansun_document::import(&desired_json, &HashMap::new()).unwrap(); + let recovered = patch_screen(current.clone(), &baseline, &desired, 1).unwrap(); + assert_eq!(recovered.layers[0], first); + assert_eq!(recovered.layers[2].id, third_id); + assert_eq!(recovered.layers.len(), 3); + assert_eq!(recovered.size.height, 1900); + assert_eq!(recovered.layers[1].id, current.layers[1].id); + let mut ids = std::collections::HashSet::new(); + assert!(recovered.walk().iter().all(|(_, _, l)| ids.insert(l.id))); + current.layers[1].name = "用户改过目标屏".into(); + assert!(patch_screen(current.clone(), &baseline, &desired, 1).is_err()); + assert_eq!(current.layers[0], first); + } +} +fn fingerprint(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn reserve_attempt(attempts: &std::sync::Mutex>, key: String, permitted: R<()>) -> R { + permitted?; + Ok(attempts.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key)) +} +fn rememberable_project(local:&str)->R { + if local.parse::().is_ok(){return Ok(false);} + Uuid::parse_str(local).map(|_|true).map_err(|_|"项目身份无效".into()) +} +fn receipt_save(dir: &Path, task: &str, value: &Value) -> R<()> { + let _guard = sync::context_guard(); + original_project_present(dir)?; + write_json(&dir.join("image-jobs").join(task).join("recovery.json"), value) +} +fn read_canvas(path: &Path) -> R> { + let mut out = Vec::new(); + fs::File::open(path).map_err(|_| "原画布无法读取")?.take(256 * 1024 * 1024 + 1).read_to_end(&mut out).map_err(|_| "原画布读取中断")?; + if out.len() > 256 * 1024 * 1024 { + return Err("原画布超过恢复限制".into()); + } + Ok(out) +} +fn same_canvas(a: &Path, b: &Path) -> R { + let mut original = photocraft_format::load_path(a).map_err(|_| "原画布基线无法读取")?; + let mut current = photocraft_format::load_path(b).map_err(|_| "本机草稿无法读取")?; + original.id = photocraft_doc::DocId(0); + current.id = photocraft_doc::DocId(0); + Ok(original == current) +} +fn comparable_group(layer: &photocraft_doc::Layer) -> photocraft_doc::Layer { + let mut layer = layer.clone(); + fn normalize(layer: &mut photocraft_doc::Layer) { + layer.id = photocraft_doc::LayerId(0); + if let photocraft_doc::LayerContent::Text(text) = &mut layer.content { + text.cache = None; + text.psd_raw = None; + } + if let Some(children) = layer.children_mut() { + for child in children { + normalize(child); + } + } + } + normalize(&mut layer); + layer +} +/// Only the unchanged target screen is replaced. Other screens keep their exact +/// native layers (including user text/IDs); only downstream geometry is shifted. +fn patch_screen(mut current: Document, baseline: &Document, desired: &Document, sequence: usize) -> R { + if current.size != baseline.size + || current.layers.len() != baseline.layers.len() + || desired.layers.len() != baseline.layers.len() + || current.size.width != desired.size.width + { + return Err("画布尺寸或屏幕结构已有修改,原图片保留待应用".into()); + } + let before = baseline.layers.get(sequence).filter(|l| l.is_group()).ok_or("原屏幕基线缺失")?; + let target = current.layers.get(sequence).filter(|l| l.is_group()).ok_or("目标屏幕缺失")?; + if comparable_group(before) != comparable_group(target) || target.locks.all || target.locks.position || target.locks.pixels { + return Err("目标屏幕已有编辑,原图片保留待应用".into()); + } + let replacement = desired.layers.get(sequence).filter(|l| l.is_group()).ok_or("恢复屏幕缺失")?.duplicate(); + let delta = i64::from(desired.size.height) - i64::from(baseline.size.height); + let height = i64::from(current.size.height).checked_add(delta).filter(|n| (1..=30000).contains(n)).ok_or("恢复后画布尺寸超出限制")? as u32; + let downstream: Vec<_> = current.layers.iter().skip(sequence.saturating_add(1)).map(|l| l.id.0).collect(); + let slot = current.layers.get_mut(sequence).ok_or("目标屏幕缺失")?; + let original_id = slot.id; + *slot = replacement; + slot.id = original_id; + current.size.height = height; + let mut session = photocraft_engine::Session::new(); + session.add_document(current, None); + if delta != 0 && !downstream.is_empty() { + session.execute("layer.translate", json!({"layers":downstream,"dx":0,"dy":delta})).map_err(|_| "后续屏幕已锁定或无法平移,原图片保留待应用")?; + } + Ok(session.active().ok_or("恢复画布缺失")?.doc.as_ref().clone()) +} +/// Strict legacy association. Older tasks had no timestamp or stable binding; +/// uniqueness, original archive, confirmed plan and references must all agree. +fn legacy_binding(dir: &Path, c: &Value, t: &Value, local: &str, scope: &str, origin: &str) -> R { + let pending = &c["generation"]["pending"]; + let task_id = uuid(&t["id"])?; + let screen_id = uuid(&pending["screenId"])?; + let sequence = pending["sequence"].as_u64().ok_or("原屏幕顺序缺失")? as usize; + let screens = c["plan"]["screens"].as_array().ok_or("原页面方案缺失")?; + let screen = screens.get(sequence).filter(|s| s["id"] == screen_id).ok_or("原屏幕与方案顺序不一致")?; + let plan = creation::plan_hash(c)?; + if c["plan"]["status"] != "confirmed" + || c["plan"]["confirmedInput"] != plan + || c["generation"]["planHash"] != plan + || pending["taskId"].is_string() && pending["taskId"] != task_id + || t["mode"] != "generate" + || !t["target"].is_null() + || t["revision"] != pending["baseRevision"] + || t["id"] == pending["previousTaskId"] + || t["phase"] == "prepared" + { + return Err("旧任务与原屏幕关联未能确认,保留待核查".into()); + } + uuid(&c["generation"]["run"])?; + let original = read_json(&dir.join("image-jobs").join(task_id).join("task.json"))?; + if original != *t { + return Err("旧任务当前记录与归档不一致".into()); + } + let marker = format!("\n当前屏:{}\n", screen["name"].as_str().ok_or("原屏幕名称缺失")?); + let image_prompt = screen["imagePrompt"].as_str().filter(|s| !s.trim().is_empty()).ok_or("原屏幕要求缺失")?; + let prompt = t["prompt"].as_str().ok_or("原请求要求缺失")?; + if !prompt.contains(&marker) || !prompt.contains(image_prompt) { + return Err("旧请求与原屏幕要求不一致".into()); + } + let prior = &pending["previousTaskId"]; + if prior.is_string() { + let prior = uuid(prior)?; + let rows = c["generation"]["screens"].as_object().ok_or("原屏幕结果清单缺失")?; + if !rows.values().any(|v| v["taskId"] == prior) || c["generation"]["lastTaskId"] != prior { + return Err("旧任务前序屏幕关联不一致".into()); + } + } else if sequence != 0 { + return Err("旧任务缺少前序屏幕依据".into()); + } + let mut matching = 0; + for entry in fs::read_dir(dir.join("image-jobs")).map_err(|_| "原任务清单无法读取")?.take(4096) { + let p = entry.map_err(|_| "原任务清单无法读取")?.path().join("task.json"); + if !p.exists() { + continue; + } + let candidate = read_json(&p)?; + if candidate["prompt"].as_str().is_some_and(|s| s.contains(&marker) && s.contains(image_prompt)) { + matching += 1; + } + } + if matching != 1 { + return Err("旧屏幕存在多笔候选任务,保留待核查".into()); + } + for reference in t["references"].as_array().filter(|r| !r.is_empty()).ok_or("原产品参考图记录缺失")? { + let p = Path::new(reference["path"].as_str().ok_or("原参考图路径缺失")?); + let resolved = p.canonicalize().map_err(|_| "原参考图不可读取")?; + if !resolved.starts_with(dir.canonicalize().map_err(|_| "原项目路径不可读取")?) { + return Err("原参考图不属于此项目".into()); + } + let mut bytes = Vec::new(); + fs::File::open(&resolved).map_err(|_| "原参考图不可读取")?.take(10 * 1024 * 1024 + 1).read_to_end(&mut bytes).map_err(|_| "原参考图读取中断")?; + if bytes.len() > 10 * 1024 * 1024 || reference["sha256"] != fingerprint(&bytes) { + return Err("原参考图内容已变化".into()); + } + } + Ok( + json!({"version":1,"localProjectId":local,"accountScope":scope,"origin":origin,"screenId":screen_id,"sequence":sequence,"run":c["generation"]["run"],"planHash":plan,"legacyVerified":true}), + ) +} +fn association(c: &Value, task: &Value, local: &str, scope: &str, origin: &str) -> R<()> { + let b = &task["creationBinding"]; + uuid(&task["id"])?; + uuid(&b["screenId"])?; + uuid(&b["run"])?; + let pending = &c["generation"]["pending"]; + if b["localProjectId"] != local + || b["accountScope"] != scope + || b["origin"] != origin + || b["run"] != c["generation"]["run"] + || b["planHash"] != c["generation"]["planHash"] + || b["planHash"] != creation::plan_hash(c)? + || c["plan"]["status"] != "confirmed" + || c["plan"]["confirmedInput"] != b["planHash"] + || pending["screenId"] != b["screenId"] + || pending["taskId"].is_string() && pending["taskId"] != task["id"] + { + return Err("原任务关联或页面方案已有变化,结果保留在原任务内".into()); + } + Ok(()) +} + +impl Backend { + pub(super) fn remember_project(&self, local: &str) -> R<()> { + if !rememberable_project(local)? {return Ok(());} + write_json(&self.dir().join("last-project.json"), &json!({"id":local,"source":"native"})) + } + pub(super) fn resume_project(&self) -> Value { + let file = self.dir().join("last-project.json"); + if let Ok(v) = read_json(&file) + && let Some(id) = v["id"].as_str().filter(|id| Uuid::parse_str(id).is_ok()) + { + let dir = self.dir().join("projects").join(id); + if v["source"] == "native" && dir.join("canvas.pcraft").exists() && dir.join("context.json").exists() { + return v; + } + } + // First upgrade: do not guess a last document when several projects qualify. + // A sole strictly-associated original pending/staged project is unambiguous. + let Ok(entries) = fs::read_dir(self.dir().join("projects")) else { + return Value::Null; + }; + let mut candidates = Vec::new(); + for entry in entries.take(256).flatten() { + let Some(local) = entry.file_name().to_str().map(str::to_owned).filter(|id| Uuid::parse_str(id).is_ok()) else { + continue; + }; + let dir = entry.path(); + let Ok(c) = creation::load(&dir) else { + continue; + }; + let stage = &c["generation"]["recovery"]; + let eligible_stage = stage.is_object() + && stage["applied"] != true + && stage["binding"]["accountScope"] == self.scope() + && stage["binding"]["localProjectId"] == local; + let eligible_pending = if c["generation"]["pending"].is_object() { + if let Ok(mut task) = read_json(&dir.join("image-task.json")) { + if !task["creationBinding"].is_object() { + task["creationBinding"] = legacy_binding(&dir, &c, &task, &local, &self.scope(), &self.origin).unwrap_or(Value::Null); + } + association(&c, &task, &local, &self.scope(), &self.origin).is_ok() + } else { + false + } + } else { + false + }; + if (eligible_stage || eligible_pending) && dir.join("canvas.pcraft").exists() && dir.join("context.json").exists() { + candidates.push(local); + } + if candidates.len() > 1 { + return Value::Null; + } + } + candidates.first().map_or(Value::Null, |id| json!({"id":id,"source":"native"})) + } + pub(super) fn scan_recovery(&mut self, tx: &mpsc::Sender, ctx: &egui::Context) { + if self.auth["account"]["id"].as_str().is_none() { + return; + } + let Ok(entries) = fs::read_dir(self.dir().join("projects")) else { + return; + }; + let started = Instant::now(); + for entry in entries.take(256).flatten() { + let budget = images::IMAGE_REQUEST_TIMEOUT.saturating_sub(started.elapsed()); + if budget.is_zero() { + break; + } + let Some(local) = entry.file_name().to_str().map(str::to_owned).filter(|id| Uuid::parse_str(id).is_ok()) else { + continue; + }; + if !entry.path().join("creation.json").exists() { + continue; + } + match self.auto_recover_project_window(&local, false, budget, tx, ctx) { + Ok(value) if !value.is_null() => { + let _ = tx.send(Event::Json("recovery-status".into(), Ok(value))); + ctx.request_repaint(); + } + Err(_) | Ok(_) => {} + } + } + } + pub(super) fn auto_recover_project(&mut self, local: &str, apply: bool, tx: &mpsc::Sender, ctx: &egui::Context) -> R { + self.auto_recover_project_window(local, apply, images::IMAGE_REQUEST_TIMEOUT, tx, ctx) + } + fn auto_recover_project_window(&mut self, local: &str, apply: bool, window: Duration, tx: &mpsc::Sender, ctx: &egui::Context) -> R { + // A different project without a pending image must open immediately while + // the original project's bounded GET is still running in another worker. + Uuid::parse_str(local).map_err(|_|"项目身份无效")?; + let preview_dir=self.dir().join("projects").join(local); + if !preview_dir.join("creation.json").exists(){return Ok(Value::Null);} + let preview=creation::load(&preview_dir)?; + if !preview["generation"]["pending"].is_object() && (!apply || preview["generation"]["recovery"]["applied"]==true || !preview["generation"]["recovery"].is_object()) {return self.apply_recovered_canvas(local,apply);} + let serial = self.recovery_serial.clone(); + let _flight = serial.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + Uuid::parse_str(local).map_err(|_| "项目身份无效")?; + let dir = self.dir().join("projects").join(local); + if !dir.join("creation.json").exists() { + return Ok(Value::Null); + } + let mut c = creation::load(&dir)?; + let pending = c["generation"]["pending"].clone(); + if !pending.is_object() { + return self.apply_recovered_canvas(local, apply); + } + let mut task = if let Some(id) = pending["taskId"].as_str() { + Uuid::parse_str(id).map_err(|_| "原任务身份无效")?; + read_json(&dir.join("image-jobs").join(id).join("task.json"))? + } else { + read_json(&dir.join("image-task.json"))? + }; + if !task["creationBinding"].is_object() { + task["creationBinding"] = legacy_binding(&dir, &c, &task, local, &self.scope(), &self.origin)?; + } + association(&c, &task, local, &self.scope(), &self.origin)?; + let task_id = uuid(&task["id"])?.to_owned(); + let screen = uuid(&pending["screenId"])?.to_owned(); + let mut receipt = json!({"origin":self.origin,"accountId":self.auth["account"]["id"],"projectLocalId":local,"taskId":task_id,"screenId":screen,"state":"pending","applied":false,"message":"原请求待核查;不会重发"}); + let key = format!("{}|{}|{}", self.scope(), local, task_id); + // An original account and the exact original personal Key must still be valid. + let permitted = self.validate().and_then(|_| { + if self.settings()?["key"].as_str().is_none_or(|key| task["keyFingerprint"] != hash(key)) { + Err("请切回原个人Key,只读核查原请求;不会重发".into()) + } else { + Ok(()) + } + }); + if !reserve_attempt(&self.recovery_attempts, key, permitted)? { + return Ok(receipt); + } + if pending["taskId"].is_null() { + c["generation"]["pending"]["taskId"] = json!(task_id); + c["generation"]["pending"]["binding"] = task["creationBinding"].clone(); + creation::save(&dir, &mut c)?; + } + images::job_save(&dir, &task)?; + let baseline = dir.join("image-jobs").join(&task_id).join("before-canvas.pcraft"); + if !baseline.exists() && task["creationBinding"]["legacyVerified"] == true { + let original = creation::canvas(&c, &dir, 0)?; + let bytes = photocraft_format::save_to_bytes(&original, &Default::default()).map_err(|_| "原屏幕恢复基线无法保存")?; + { + let _guard = sync::context_guard(); + original_project_present(&dir)?; + photocraft_format::atomic_write(&baseline, &bytes).map_err(|_| "原屏幕恢复基线无法保存")?; + } + task["creationBinding"]["baselineKind"] = json!("template"); + task["creationBinding"]["baselineSha256"] = json!(fingerprint(&bytes)); + images::job_save(&dir, &task)?; + } + let ephemeral = photocraft_doc::DocId::fresh().0; + self.owners.insert(ephemeral, self.scope()); + self.locations.insert(ephemeral, local.into()); + let started = Instant::now(); + let offset = images::IMAGE_REQUEST_TIMEOUT.saturating_sub(window); + let result = images::retry_original_queries( + |budget| self.image_job(None, &json!({"doc":ephemeral,"taskId":task_id,"recoveryBudgetMs":budget.as_millis() as u64}), true, tx, ctx), + std::thread::sleep, + || offset.saturating_add(started.elapsed()), + ); + self.owners.remove(&ephemeral); + self.locations.remove(&ephemeral); + let result = result?; + if !matches!(result["phase"].as_str(), Some("complete" | "applied")) { + receipt["message"] = result["reply"].clone(); + return Ok(receipt); + } + // Reload AFTER all network I/O. Never save a stale pre-GET creation snapshot. + c = creation::load(&dir)?; + if let Err(message) = association(&c, &task, local, &self.scope(), &self.origin) { + receipt["state"] = json!("conflict"); + receipt["message"] = json!(message); + receipt_save(&dir, &task_id, &receipt)?; + return Ok(receipt); + } + if c["generation"]["screens"][&screen].is_object() && c["generation"]["screens"][&screen]["taskId"] != task_id { + receipt["state"] = json!("conflict"); + return Ok(receipt); + } + receipt["state"] = json!("recovered"); + receipt["message"] = json!("已取回原图片"); + c["generation"]["recovery"] = json!({"taskId":task_id,"screenId":screen,"run":task["creationBinding"]["run"],"planHash":task["creationBinding"]["planHash"],"state":"recovered","applied":false,"recoveryApplyCount":0,"binding":task["creationBinding"]}); + self.creation_record_image(&dir, &mut c, &screen, &result)?; + receipt_save(&dir, &task_id, &receipt)?; + if apply { self.apply_recovered_canvas(local, true) } else { Ok(receipt) } + } + fn apply_recovered_canvas(&self, local: &str, apply: bool) -> R { + let dir = self.dir().join("projects").join(local); + let c = creation::load(&dir)?; + let staged = c["generation"]["recovery"].clone(); + if !staged.is_object() { + return Ok(Value::Null); + } + let task = uuid(&staged["taskId"])?; + let job = dir.join("image-jobs").join(task); + let mut receipt = json!({"origin":self.origin,"accountId":self.auth["account"]["id"],"projectLocalId":local,"taskId":task,"screenId":staged["screenId"],"state":staged["state"],"applied":staged["applied"],"recoveryApplyCount":staged["recoveryApplyCount"],"message":"已取回原图片"}); + if staged["applied"] == true { + let _guard = sync::context_guard(); + original_project_present(&dir)?; + let live = creation::load(&dir)?; + if live["generation"]["recovery"]["taskId"] != task || !settle_applied_recovery_locked(&dir, &live)? { + receipt["state"] = json!("conflict"); + return Ok(receipt); + } + receipt["state"] = json!("already-applied"); + return Ok(receipt); + } + if !apply { + return Ok(receipt); + } + if c["generation"]["pending"].is_object() + || c["generation"]["planHash"] != staged["planHash"] + || creation::plan_hash(&c)? != staged["planHash"] + || c["generation"]["run"] != staged["run"] + || staged["binding"]["localProjectId"] != local + || staged["binding"]["accountScope"] != self.scope() + || staged["binding"]["origin"] != self.origin + || c["generation"]["screens"][staged["screenId"].as_str().ok_or("原屏幕身份缺失")?]["taskId"] != task + { + receipt["state"] = json!("conflict"); + return Ok(receipt); + } + let canonical = dir.join("canvas.pcraft"); + let before = read_canvas(&canonical)?; + let switched_before_restart = staged["outputSha256"].as_str().is_some_and(|sha| sha == fingerprint(&before)); + let baseline = job.join("before-canvas.pcraft"); + let baseline_valid = if baseline.exists() { + let bytes = read_canvas(&baseline)?; + staged["binding"]["baselineSha256"] == fingerprint(&bytes) + } else { + false + }; + if !baseline_valid && !switched_before_restart { + receipt["state"] = json!("conflict"); + receipt["message"] = json!("原图片已取回;恢复基线无法确认,已保留图片等待应用"); + return Ok(receipt); + } + let bytes = if switched_before_restart { + before.clone() + } else { + let baseline_doc = photocraft_format::load_path(&baseline).map_err(|_| "原屏幕恢复基线无法读取")?; + let current = photocraft_format::load_path(&canonical).map_err(|_| "本机草稿无法读取")?; + // Loading reserves persisted layer/document/smart-content identities. + // Mint replacements afterwards, beyond every ID on the unchanged screens. + let desired = creation::canvas(&c, &dir, 0)?; + let result = if staged["binding"]["baselineKind"] == "initial" && same_canvas(&baseline, &canonical)? { + Ok(desired) + } else { + patch_screen(current, &baseline_doc, &desired, staged["binding"]["sequence"].as_u64().ok_or("原屏幕顺序缺失")? as usize) + }; + let doc = match result { + Ok(doc) => doc, + Err(message) => { + receipt["state"] = json!("conflict"); + receipt["message"] = json!(message); + return Ok(receipt); + } + }; + let mut ids = std::collections::HashSet::new(); + if doc.walk().iter().any(|(_, _, layer)| !ids.insert(layer.id)) { + return Err("恢复图层身份重复,未修改原画布".into()); + } + photocraft_format::save_to_bytes(&doc, &Default::default()).map_err(|_| "恢复画布序列化失败")? + }; + // This critical section is shared with autosave/sync. Canonical and creation + // revisions must still be exactly those checked before staging the result. + let _guard = sync::context_guard(); + original_project_present(&dir)?; + let mut live = creation::load(&dir)?; + if live["revision"] != c["revision"] || fingerprint(&read_canvas(&canonical)?) != fingerprint(&before) { + receipt["state"] = json!("conflict"); + return Ok(receipt); + } + photocraft_format::atomic_write(&job.join("recovered-canvas.pcraft"), &bytes).map_err(|_| "恢复结果无法保存")?; + // Write an intent before the canonical switch; a restart can compare the exact + // output bytes rather than importing a second copy of the same screen. + live["generation"]["recovery"]["outputSha256"] = json!(fingerprint(&bytes)); + write_json(&dir.join("creation.json"), &live)?; + let mut context = read_json(&dir.join("context.json"))?; + if context["recoveryAppliedTask"] != task { + context["revision"] = json!(context["revision"].as_u64().unwrap_or(0).saturating_add(1)); + context["recoveryAppliedTask"] = json!(task); + } + context["syncDirty"] = json!(true); + context["status"] = json!("draft"); + write_json(&dir.join("context.json"), &context)?; + photocraft_format::atomic_write(&canonical, &bytes).map_err(|_| "本机恢复画布无法保存")?; + photocraft_format::atomic_write(&dir.join("creation-canvas.pcraft"), &bytes).map_err(|_| "生成恢复画布无法保存")?; + live["generation"]["recovery"]["applied"] = json!(true); + live["generation"]["recovery"]["recoveryApplyCount"] = json!(1); + live["generation"]["recovery"]["state"] = json!("already-applied"); + live["revision"] = json!(live["revision"].as_u64().ok_or("创作版本无效")?.saturating_add(1)); + write_json(&dir.join("creation.json"), &live)?; + settle_applied_recovery_locked(&dir, &live)?; + receipt["applied"] = json!(true); + receipt["state"] = json!("already-applied"); + receipt["recoveryApplyCount"] = json!(1); + receipt["message"] = json!("已恢复原屏幕"); + write_json(&job.join("recovery.json"), &receipt)?; + Ok(receipt) + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/main.rs b/apps/detail-photocraft/apps/photocraft/src/main.rs index 6f0d569c..d9b33509 100644 --- a/apps/detail-photocraft/apps/photocraft/src/main.rs +++ b/apps/detail-photocraft/apps/photocraft/src/main.rs @@ -64,6 +64,7 @@ mod mac_window; #[cfg(any(target_os = "linux", test))] mod linux_libs; mod logging; +mod jiansun_lifecycle; mod monitor_profile; mod photoshop_settings; // Save As through the desktop portal, which reports the file type picked (#6121). @@ -478,6 +479,12 @@ fn main() -> eframe::Result { } else { None }; + // Only an actual GUI launch owns this record: CLI/Pi modes, usage failures and launches + // forwarded to an existing window returned above. Never record arguments or panic payloads. + let lifecycle = services::config_dir().and_then(|dir| jiansun_lifecycle::Lifecycle::begin(&dir, &photocraft_engine::build_info::long_version())); + if let Some(lifecycle) = &lifecycle { + lifecycle.install_panic_hook(); + } // Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that // launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -737,6 +744,9 @@ fn main() -> eframe::Result { Ok(Box::new(app)) }), ); + if let Some(lifecycle) = &lifecycle { + lifecycle.finish(result.is_ok()); + } // The window is gone: refuse further files and free the lock, so a launch that is waiting (or // the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a // process that is exiting. diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs index 53a2d78b..2d14c9a8 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs @@ -30,6 +30,7 @@ pub enum Request { Action(String, Value), Save(Arc, u64, Value), Generate(Arc, Value), + OpenedResult { binding: Value, result: Result<(Box, Value), String> }, } pub enum Event { Json(String, Result), @@ -53,6 +54,76 @@ enum CenterView { Materials, } +/// Startup may resume only an account-owned local row; it must never navigate for an +/// already active editor or a person who has started another task. +fn resume_candidate(value: &Value, account: &Value, origin: &str, projects: &[Value], quiet: bool) -> Option { + if !quiet || account["id"].as_str().is_none() || value["account"]["id"] != account["id"] || value["origin"] != origin { + return None; + } + let requested = &value["resumeProject"]; + if requested["source"] != "native" || !requested["id"].is_string() { + return None; + } + projects + .iter() + .find(|row| row["source"] == "native" && row["id"] == requested["id"] && row["cloudDeleted"] != true) + .map(|row| json!({"source":"native","id":row["id"],"autoResume":true})) +} + +fn finish_open_busy(busy: &mut Option, automatic: bool) { + if busy.as_deref() == Some(if automatic { "startup-open" } else { "open" }) { + *busy = None; + } +} + +#[cfg(test)] +mod startup_resume_tests { + use super::*; + fn source() -> Value { + json!({"account":{"id":"owner"},"origin":"https://sc.jiansun.vip","resumeProject":{"id":"local-original","source":"native"}}) + } + fn rows() -> Vec { + vec![json!({"id":"local-original","source":"native"}), json!({"id":"other-project","source":"native"})] + } + #[test] + fn startup_resumes_only_the_same_account_native_project() { + let value = source(); + let result = resume_candidate(&value, &value["account"], "https://sc.jiansun.vip", &rows(), true).unwrap(); + assert_eq!(result, json!({"id":"local-original","source":"native","autoResume":true})); + assert!(resume_candidate(&value, &json!({"id":"different"}), "https://sc.jiansun.vip", &rows(), true).is_none()); + assert!(resume_candidate(&value, &value["account"], "http://localhost:3138", &rows(), true).is_none()); + assert!(resume_candidate(&value, &Value::Null, "https://sc.jiansun.vip", &rows(), true).is_none()); + } + #[test] + fn startup_never_takes_over_an_active_editor_or_user_action() { + let value = source(); + assert!(resume_candidate(&value, &value["account"], "https://sc.jiansun.vip", &rows(), false).is_none()); + } + #[test] + fn startup_does_not_guess_missing_cloud_or_deleted_projects() { + let mut value = source(); + assert!(resume_candidate(&value, &value["account"], "https://sc.jiansun.vip", &[], true).is_none()); + let deleted = vec![json!({"id":"local-original","source":"native","cloudDeleted":true})]; + assert!(resume_candidate(&value, &value["account"], "https://sc.jiansun.vip", &deleted, true).is_none()); + value["resumeProject"]["source"] = json!("desktop"); + assert!(resume_candidate(&value, &value["account"], "https://sc.jiansun.vip", &rows(), true).is_none()); + } + #[test] + fn late_startup_success_or_error_cannot_clear_a_user_open() { + for _late_event in ["success", "error"] { + let mut busy = Some("open".to_owned()); + finish_open_busy(&mut busy, true); + assert_eq!(busy.as_deref(), Some("open")); + } + let mut busy = Some("startup-open".to_owned()); + finish_open_busy(&mut busy, true); + assert!(busy.is_none()); + let mut busy = Some("configure".to_owned()); + finish_open_busy(&mut busy, true); + assert_eq!(busy.as_deref(), Some("configure")); + } +} + /// Only bounded edits of existing, unlocked layers. No file, script or network commands. pub fn checked_edit(session: &photocraft_engine::Session, edit: &Edit) -> Result<(), String> { if edit.label.is_empty() || edit.label.chars().count() > 120 { @@ -163,6 +234,9 @@ pub struct Workspace { saving: Option<(u64, u64)>, observed_doc: Option, project_context: Value, + startup_resume_checked: bool, + startup_interacted: bool, + recovery_status: Value, draft_changed: bool, loaded_draft: Option, allow_apply: bool, @@ -266,6 +340,9 @@ impl Workspace { saving: None, observed_doc: None, project_context: json!({}), + startup_resume_checked: false, + startup_interacted: false, + recovery_status: Value::Null, draft_changed: false, loaded_draft: None, allow_apply: false, @@ -332,7 +409,7 @@ impl Workspace { return; } self.error.clear(); - self.busy = Some(method.into()); + self.busy = Some(if method == "open" && input["autoResume"] == true { "startup-open" } else { method }.into()); if self.tx.send(Request::Action(method.into(), input)).is_err() { self.busy = None; self.error = "本机服务已停止,请保留作品后重启".into(); @@ -349,6 +426,8 @@ impl Workspace { state["creation"] = self.creation.clone(); state["creationView"] = json!({"open":self.creation_open,"step":self.creation_step,"dirty":self.creation_dirty,"progress":self.creation_progress,"pendingCanvas":self.creation_pending.is_some(),"brief":self.creation_brief,"screenCount":self.creation_count,"selected":self.creation_selected}); state["projectState"] = json!({"syncDirty":self.sync_dirty,"cloudLink":self.project_context["cloudLink"],"syncedRevision":self.project_context["syncedRevision"],"status":self.project_context["status"]}); + state["recovery"] = + json!({"startupResumeChecked":self.startup_resume_checked,"userInteracted":self.startup_interacted,"lastStatus":self.recovery_status}); state } fn base_state(&self) -> Value { @@ -527,6 +606,15 @@ impl Workspace { self.send("agent", json!({"text":text,"context":{"id":context["id"],"revision":context["revision"]},"model":self.model})); } fn tick(&mut self, app: &mut PhotocraftApp, ui: &Ui) { + if !self.startup_interacted + && ui.input(|input| { + input.pointer.any_pressed() + || input.events.iter().any(|event| matches!(event, egui::Event::MouseWheel { .. })) + || input.events.iter().any(|event| matches!(event, egui::Event::Key { pressed: true, .. } | egui::Event::Text(_) | egui::Event::Paste(_))) + }) + { + self.startup_interacted = true; + } if self.draft_changed { self.sync_dirty = true; } @@ -539,6 +627,10 @@ impl Workspace { } Event::CreationCanvas(doc, meta) => creation::canvas_received(self, app, doc, meta), Event::Json(method, result) => { + if method == "startup-open" && (self.startup_interacted || !app.session.documents().is_empty()) { + finish_open_busy(&mut self.busy, true); + continue; + } if self.busy.as_deref() == Some(method.as_str()) { self.busy = None; } @@ -601,6 +693,33 @@ impl Workspace { self.projects = v["projects"].as_array().cloned().unwrap_or_default(); self.connection_received(&v); self.error = v["message"].as_str().unwrap_or("").into(); + if method == "restore" && !self.startup_resume_checked { + self.startup_resume_checked = true; + let quiet = !self.startup_interacted + && app.session.documents().is_empty() + && self.library + && !self.material + && !self.creation_open + && !self.config + && app.ui.dialogs.is_empty() + && app.file_dialog.is_none() + && self.busy.is_none() + && self.error.is_empty(); + if let Some(project) = resume_candidate(&v, &self.account, &self.origin, &self.projects, quiet) { + self.send("open", project); + } + } + } + "recovery-status" => { + if v["accountId"] == self.account["id"] && v["origin"] == self.origin { + self.recovery_status = v.clone(); + let local = v["projectLocalId"].as_str().unwrap_or(""); + if !local.is_empty() + && app.session.active().is_some_and(|st| self.open_projects.get(&st.doc.id.0) == Some(&self.local_identity(local))) + { + self.creation_progress = v["message"].as_str().unwrap_or("").into(); + } + } } "projects" => { self.projects_received(app, v.as_array().cloned().unwrap_or_default()); @@ -784,8 +903,17 @@ impl Workspace { } } Event::Opened(doc, meta) => { + if meta["autoResume"] == true + && (self.startup_interacted + || !app.session.documents().is_empty() + || meta["accountId"] != self.account["id"] + || meta["origin"] != self.origin) + { + finish_open_busy(&mut self.busy, true); + continue; + } self.handover(); - self.busy = None; + finish_open_busy(&mut self.busy, meta["autoResume"] == true); self.library = false; self.material = false; let file = meta["path"].as_str().map(str::to_owned); @@ -793,6 +921,9 @@ impl Workspace { self.open_projects.insert(doc.id.0, self.local_identity(local)); } app.session.add_document(*doc, file); + if let Some(active) = app.session.active() { + let _ = self.tx.send(Request::Action("project-activate".into(), json!({"doc":active.doc.id.0}))); + } // Opening a persisted canvas is not an edit. The local history // starts at 1, while this project's saved revision may be newer. if let Some(revision) = meta["revision"].as_u64().filter(|v| *v <= 9_007_199_254_740_991) diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs index a670f45f..020ab2dd 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs @@ -144,7 +144,7 @@ impl Workspace { let query = text_edit(ui, egui::TextEdit::singleline(&mut self.project_query).hint_text("搜索项目").desired_width(240.0)); self.project_geometry["search"] = agent_panel::rect_value(query.rect); ui.add_space(16.0); - if matches!(self.busy.as_deref(), Some("restore" | "projects" | "open" | "project-delete")) { + if matches!(self.busy.as_deref(), Some("restore" | "projects" | "open" | "startup-open" | "project-delete")) { ui.horizontal(|ui| { ui.spinner(); ui.label(match self.busy.as_deref() { diff --git a/docs/client-web-closeout-acceptance-2026-10-22.md b/docs/client-web-closeout-acceptance-2026-10-22.md index 0eee3a71..9fc82a14 100644 --- a/docs/client-web-closeout-acceptance-2026-10-22.md +++ b/docs/client-web-closeout-acceptance-2026-10-22.md @@ -82,3 +82,11 @@ Web 摘要不展示内部任务请求号;不能声称 Web 已提供该未知 - 本轮应用发布不运行 DDL、品牌导入或修复 SQL;精确 Git 源码本机构建 runner,仅传成品镜像。现有 direct 流程为短暂停机替换并带回退,不承诺零停机;切换前须复核上传、转码和队列空闲。 本地证据:`output/native-final-acceptance-20261022/` 中的 `candidate-verification.json`、`known-first-report.json`、`known-first-native-delete-report.json`、`web-dual-delete-receipt.json`、`recovered-second-original-report.json`、`post-generation-edit-sync-report.json`、`three-screen-offline-report.json`、`post-generation-restore-b-report.json`(实际使用新设备 C)、`web-three-screen-readonly-receipt.json`;事务证据在 `output/native-closeout-recheck-20261022/`。最终提交、镜像身份和部署健康回执按该 SHA 保存至本轮 `release-/`,不以候选测试代替部署证据。 + +## 后续原生重启恢复与窗口收尾 + +晚间补充客户端启动只读恢复、原项目补屏、B 编辑隔离、有界生命周期及已应用终态保护。最终 exe `ff0397e2f23196f053c801d7ebbc628f7eaf1f0267a2d889f000208b13282cb3` 已实际通过首次自动补屏、再次重启不重复应用、B 改字保存后正常打开 A 三项 GUI 验收。全部测试窗口正常退出,只留用户原 PID 51704;桌面启动器指向新包且沿用原 profile,下次用户重开生效。 + +必须区别历史人工 GET 取回和本轮自动恢复:本轮公开接口定位及结果 GET 均 200,但原图公网下载为 404,保留待核查,不重发。正向自动恢复使用客户此前已保存、哈希一致的原图缓存及真实原任务,不能称为新的网络下载成功。历史退出原因仍未知,旧未知任务文件不变;本次没有 Web 构建或重新部署。 + +详细身份、测试范围、账务只读核查归属及缓存收尾见[原生客户端重启恢复验收](native-client-restart-recovery-acceptance-2026-10-22.md)。本轮清理约 3.791 GiB 专用构建与测试产物,原运行包、数据库、素材、项目和草稿保留;另有自动审批拒绝删除的 201.5 MiB 测试缓存如实保留。 diff --git a/docs/native-client-restart-recovery-acceptance-2026-10-22.md b/docs/native-client-restart-recovery-acceptance-2026-10-22.md new file mode 100644 index 00000000..f13c23f2 --- /dev/null +++ b/docs/native-client-restart-recovery-acceptance-2026-10-22.md @@ -0,0 +1,65 @@ +# 原生客户端退出与原请求恢复验收(2026-10-22) + +## 范围 + +本轮处理测试窗口未及时关闭、重启后的原请求恢复、后台恢复与当前编辑隔离,以及已应用图片仍可重复点击的问题。仅修改 PhotoCraft 原生客户端和相关说明;不修改客户设置、Key、模型参数、价格、计费、云端项目或 Web 生产服务。 + +历史第二屏退出原因仍未知:18:16—18:21 的 Windows Application 1000/1001 没有 PhotoCraft 崩溃记录,原进程退出码未留存。不能由源站 HTTP 200 推断客户端已消费响应,也不能据此归责于用户、中转、超时或内存不足。 + +## 恢复约束 + +- 启动时使用同一授权账户及原 Key,只通过 98k 公开 GET 定位和读取原请求,不新建生成任务。`unknown`、404、410 或结果不可取回均不授权重发。 +- 旧任务缺少请求号或时间戳时,只有唯一原任务、项目、屏幕、方案、提示词、参考图及 Key 指纹全部匹配才补充绑定。没有旧时间戳的任务使用“本次启动核查窗口”,不伪称知道原派发剩余时间。 +- 启动只读扫描采用共享 180 秒窗口;处理中和临时失败有界退避,单次请求与下载受剩余窗口限制。窗口结束后保留原任务,下次仍只读核查;不持后台网络锁阻塞保存。 +- 原结果先留存在原项目,正常打开原项目时才补入未编辑的目标屏;保留其他已编辑的中文、图层身份及图片。目标屏冲突时保留结果供接手。 +- 后台恢复不能切换或覆盖用户正在编辑的 B 项目;用户在启动期间主动新建、切换或输入后,不再自动打开旧项目。只有界面接受的实际打开才更新最近项目。 +- 删除、落盘及应用共用项目上下文锁并复核目录/回收站,不复活已删除项目;新任务的当前指针不被旧结果抢占。 +- 原图片应用成功后,任务归档标为 `applied`。重启修复旧 `complete` 记录,迟到 `complete/unknown` 不能降级;不再展示重复应用按钮,旧点击也不能再次插图。 + +## 验收方式与边界 + +原第二屏幂等键为 `42e84e04-4738-48c0-b3c0-31ee097b7a35`,客户请求号为 `cc6a9ed9-863b-419f-bd1a-b126833227fb`,原图 SHA256 为 `efe830bc5d086f25e72568eb0e09e8155376b694831d9b0d8339eb263c875e36`。没有新生成 POST。 + +必须区分以下证据,不能合并称为“公开网络自动恢复成功”: + +| 场景 | 实际结果 | +| --- | --- | +| 当日早些时候人工触发原请求 GET 恢复 | 曾成功取回原图;这是历史手动恢复证据 | +| 本轮全新 profile E 自动启动、公开网络读取 | 定位 GET 200、结果 GET 200;公开图片下载 HTTP 404。保留原任务与首屏,未应用第二屏、未重发 | +| 原客户本机已保存的同一原图缓存 | 隔离副本保留“第二屏缺失”状态,使用原客户已有、哈希一致的图片及原任务响应验证自动补回;不伪装为网络下载成功 | +| 再次重启 | 不增加 GET、图片、任务目录、创建状态或画布修订;全部有效文档内容保持一致 | +| B 项目编辑期间恢复 A | B 实际改字并保存后,后台仅暂存 A;B 内容、焦点及最近项目不变。正常打开 A 才补入原第二屏 | + +`.pcraft` 每次打开会分配新的运行时 `document.id`,随后普通自动保存可能导致 ZIP 文件字节哈希改变。重复恢复验收逐个比较所有原始 ZIP 内容及完整清单,仅归一化该运行时 ID;图层身份、文字、几何、字体和图像载荷均严格比较。原始不恰当的整包字节断言失败保留在回执中,不以修改产品规避。 + +98k 开发对话另行只读核查确认:公开图片代理令牌未过期,但其远程原图也返回 404;无法进一步判定远程文件的正常保留期、删除或故障。该同步图片请求无可用于刷新结果的原生任务查询路径。本项目不读取 98k 源码、数据库、服务器或对方本地诊断文件,也不猜测新接口、不重新生成。 + +截至 21:18:58(UTC+8),98k 侧只读账务对比确认原 Key 没有新增幂等执行记录,原请求仍为一笔合同、一笔用量、一笔已入账 ¥0.14、零活动预留。这是对方提供的独立账务证据,不冒称本项目读取过其数据库。 + +## 测试证据 + +- 生产恢复 helper 定向测试 28 项、Clippy `-D warnings` 通过;本地真实 HTTP 服务观测 15 GET、0 POST,覆盖有界窗口、鉴权、原身份绑定、删除竞争、当前指针保护、异步打开及应用终态。 +- 同一原生文档/引擎的补屏测试 4 项通过,覆盖保留已编辑中文与图层 ID、后续屏位移、冲突拒绝。 +- 生命周期与原子写入测试 20 项通过,并验证五次真实无界面子进程退出/重启。运行日志为有界当前/前次记录;历史缺失退出码不能补造。 +- 启动自动打开纯 helper 测试 4 项及 Clippy 通过;不等同于完整 UI crate 单测。 +- 最终完整离线锁定 Release 构建通过(5 分 08 秒),编译前后及打包时所有原生源文件哈希一致,强制嵌入 Windows 资源。 +- 最终 exe SHA256 为 `ff0397e2f23196f053c801d7ebbc628f7eaf1f0267a2d889f000208b13282cb3`,85,348,035 字节;源码清单 SHA256 为 `d29f474937de6ac427ec28292a0f3067e671650634f4c409a19fe2496994320f`。 +- 该最终 exe 的实际 GUI 三项均通过:首次自动补屏 PID 44984、再次重启 PID 43972、B 编辑隔离 PID 42004。三次均保存且正常退出码 0,原用户 PID 51704 未关闭;启动后的有界生命周期记录也均为正常结束。 +- 三项均实测 `applied` 归档、没有重复应用按钮;再投递旧 `image-use` 操作也不改变图层树。前两项原图一致且应用次数保持 1;三项没有新增图片任务目录、生成或云同步。 +- 实际 1440×5400 原生窗口截图保留“照见生活”、原产品图片和第三屏未生成占位;不将这份两张原图的恢复用例宣称为完成三屏生成。B 实际改字并保存,只有用户式正常打开 A 后才补入 A。 + +最终回执:`auto-final-cached-{first,repeat,other}-report.json`、对应 `-exit.json` / `-lifecycle.json` 和截图;`final-applied-review.json` 记录独立只读复核通过。截图按 Operate 模式复核,保留原生布局,未新增说明面板或残留“应用到画布”入口;这不是移动端或完整系统辅助技术验收。 + +本地证据目录:`output/native-auto-recovery-20261022/`;定向 helper、原生补屏和生命周期测试证据位于 `output/native-final-acceptance-20261022/`。包含凭证的 profile、令牌或私密文件不得作为公开附件分发。 + +## 窗口与缓存收尾规则 + +保留用户原客户端 PID 51704;本轮曾遗留的四个测试 PID 37836、29276、29016、42496 已确认保存且空闲后正常退出。后续隔离验收逐次启动、逐次退出,不重启或关闭用户窗口。桌面启动器复用当前已打开的正常实例,下次用户自行关闭后再打开才使用新程序。 + +本机既有桌面快捷方式仍指向 `output/photocraft-development-20261021/open-workbench.ps1`,仅将其程序目标改为 `output/native-auto-recovery-20261022/portable-auto-recovery-final/photocraft.exe`,原 profile 路径未变。Windows PowerShell 5 语法检查通过,实际再点快捷方式仍仅 PID 51704。该运行窗口继续使用旧 exe `04e71775…`;不能说它已热更新为新版本。启动器前版本及完整身份见 `installed-launcher-receipt.json`。 + +最后复核旧未知任务 `6986651c-dff8-4af9-8870-2eefed8df21d` 的文件 SHA256 仍为 `64091f2b78b83124f3d22bf2075878f79c343c656692b5cc9b96dea380e4ffcd`,没有重发或改写。 + +只清理本轮明确生成且不再使用的构建目录和补屏测试链接产物,保留运行包、原素材、项目、草稿、授权及证据。已被自动审批拒绝删除的 `lifecycle-unit-harness/target` 不通过替代工具重试;拒绝原因仅为 `blocked by policy`,如仍占空间必须在交付时如实披露。 + +本次精确删除专用 `build-target` 和 17 份已退出的原生补屏测试链接产物,合计 4,070,318,070 字节(3.791 GiB);同期 D 盘实际可用空间增加 3.567 GiB,两个口径不混用。上述被拒绝目录当前仍为 211,288,273 字节(201.5 MiB),未重试删除。清理回执为 `cleanup-owned-build-receipt.json`。 diff --git a/docs/photocraft-client-design.md b/docs/photocraft-client-design.md index 820f65ce..631b86e2 100644 --- a/docs/photocraft-client-design.md +++ b/docs/photocraft-client-design.md @@ -58,6 +58,7 @@ 4. 用户点击“接手”、切换文档、修改图层或执行撤销后,停止剩余自动编辑。旧建议不会覆盖新修改;继续前重新核对上下文。 5. Agent 与用户共用引擎的撤销、重做历史。撤销一步不影响此前无关人工操作;已经成功的步骤不因后续失败而被当作未执行。 6. 模型派发前保存请求身份;超时、断线、退出后的结果未知保持 `unknown`,只按原请求只读核查,绝不自动重发。接手画布不代表上游模型已取消。 + 重启核查先暂存到原项目,打开时核对账号、原任务、屏幕、方案和当前画布版本。只恢复未编辑的目标屏;其他屏的人工修改与图层身份必须保留。后台结果不切换当前项目,重复启动不重复插图;等待原结果不能阻塞本机保存。 7. 本地草稿、账户同步、工程文件和导出各有独立状态。任何联网失败都保留已保存的本机作品与输入草稿。 8. 实际生成结果在所属对话轮次中展示等比缩略图,点击打开默认居中的原生图片预览;只读本人项目任务内校验通过的产出,不接受任意本机路径或用占位图冒充结果。 @@ -71,7 +72,7 @@ - 画布、图片处理、预览和导出在用户设备上执行。平台负责鉴权、目录、知识、私有文件传输与项目服务;不宣称平台没有资源开销。 - 以本机工程为主要工作副本:`.pcraft`、工作素材缓存、撤销历史与生成任务日志留在用户设备;个人 Key 的模型请求由客户端直连 98k。生产机不承担客户端的画布合成、PSD 导出或自动重跑任务。 - 服务端保留项目索引、权限、品牌/模板快照版本和必要的任务摘要(请求号、状态、所属项目与版本);完成后接收成果。为跨设备与本机故障恢复保留可选工程备份及版本同步,不能只上传一张最终图片。 -- 大文件传输按需、增量、流式进行,避免一次在生产机展开整份工程或解码长图;后续可评估授权直连对象存储。原生工程云备份、版本同步与跨设备恢复仍待接通,此处是职责设计,不能写成当前已经完成。 +- 大文件传输按需、增量、流式进行,避免一次在生产机展开整份工程或解码长图;后续可评估授权直连对象存储。原生工程云备份、版本同步及跨设备恢复的定向验收见[客户端与 Web 收尾记录](client-web-closeout-acceptance-2026-10-22.md);增量及对象存储直传仍需单独验证。 - 默认宽度 1440px,兼容 750px;最多 12 屏、总高 30000px。缩放只影响显示,不改变真实坐标。大图逐份加载、任务受控并发。 ## 验收与完成口径