fix(desktop): recover original image tasks and close test clients

This commit is contained in:
WuYongJie 2026-10-22 21:38:00 +08:00
parent 71eb8ac1f3
commit f927edcfb2
13 changed files with 1722 additions and 72 deletions

View File

@ -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` 中保存自己的未保存测试内容、正常退出并等待进程结束,最后
复核用户原窗口仍在。只处理本轮明确创建的实例,不按程序名批量结束客户端。
完成后清理本轮专用构建缓存及测试二进制,保留必要源码、回执和验收证据;删除被自动审批
拒绝时记录准确路径与原因,不换工具重试。桌面常规启动沿用单实例机制,已有窗口优先激活。

View File

@ -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<T: Send + 'static>(requests: mpsc::Sender<T>, 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<String> {
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::<u64>().is_ok()).map(str::to_owned).ok_or("原项目本机身份未能确认".into())
}
fn adopt_open_mapping(owners:&mut HashMap<u64,String>,locations:&mut HashMap<u64,String>,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<u64, String>,
locations: HashMap<u64, String>,
last_saved: HashMap<u64, u64>,
recovery_attempts: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
recovery_serial: Arc<std::sync::Mutex<()>>,
}
impl Backend {
fn new(root: PathBuf) -> R<Self> {
@ -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<Event>, ctx: &egui::Context) -> R<Value> {
@ -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<Value> {
@ -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<Event>, 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::<u64>().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<Document>,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<Value> {
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();

View File

@ -5,7 +5,7 @@ use base64::engine::general_purpose::STANDARD;
#[path = "jiansun_creation_model.rs"]
mod model;
fn load(dir: &Path) -> R<Value> {
pub(super) fn load(dir: &Path) -> R<Value> {
let file = dir.join("creation.json");
if file.exists() {
let c = read_json(&file)?;
@ -17,11 +17,47 @@ fn load(dir: &Path) -> R<Value> {
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<Value> {
}
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<String> {
pub(super) fn plan_hash(c: &Value) -> R<String> {
Ok(hash(&json!({"input":model::input_hash("plan",c)?,"screens":c["plan"]["screens"]}).to_string()))
}
@ -726,7 +764,7 @@ fn definition(c: &Value) -> R<Value> {
}
Ok(json!({"version":2,"name":c["brief"]["name"],"screens":screens}))
}
fn canvas(c: &Value, dir: &Path, doc: u64) -> R<Document> {
pub(super) fn canvas(c: &Value, dir: &Path, doc: u64) -> R<Document> {
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;

View File

@ -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<Instant>, cap: Duration) -> R<Duration> {
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<bool> {
let file = dir.join("image-task.json");
@ -29,10 +35,62 @@ fn png(bytes: &[u8]) -> R<Vec<u8>> {
}
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<bool> {
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<String> {
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<String> {
}
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<Value> {
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<Value>, mut wait: impl FnMut(Duration), mut elapsed: impl FnMut() -> Duration) -> R<Value> {
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<url::Url> {
Ok(u)
}
impl Backend {
fn image_validate(&mut self, deadline: Option<Instant>) -> 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<Arc<Document>>, input: &Value, recover: bool, tx: &mpsc::Sender<Event>, ctx: &egui::Context) -> R<Value> {
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, &current).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<Value>, Vec<String>, 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::<u16>().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() {

View File

@ -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<u64>,
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<Self> {
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<Record>,
_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<Arc<Self>> {
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<Self> {
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<Self>) {
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<Option<Record>> {
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);
}
}

View File

@ -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");

View File

@ -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<bool> {
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(&current, &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<std::collections::HashSet<String>>, key: String, permitted: R<()>) -> R<bool> {
permitted?;
Ok(attempts.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key))
}
fn rememberable_project(local:&str)->R<bool> {
if local.parse::<u64>().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<Vec<u8>> {
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<bool> {
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<Document> {
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<Value> {
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<Event>, 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<Event>, ctx: &egui::Context) -> R<Value> {
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<Event>, ctx: &egui::Context) -> R<Value> {
// 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<Value> {
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)
}
}

View File

@ -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.

View File

@ -30,6 +30,7 @@ pub enum Request {
Action(String, Value),
Save(Arc<Document>, u64, Value),
Generate(Arc<Document>, Value),
OpenedResult { binding: Value, result: Result<(Box<Document>, Value), String> },
}
pub enum Event {
Json(String, Result<Value, String>),
@ -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<Value> {
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<String>, 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<Value> {
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<u64>,
project_context: Value,
startup_resume_checked: bool,
startup_interacted: bool,
recovery_status: Value,
draft_changed: bool,
loaded_draft: Option<u64>,
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)

View File

@ -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() {

View File

@ -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-<SHA>/`,不以候选测试代替部署证据。
## 后续原生重启恢复与窗口收尾
晚间补充客户端启动只读恢复、原项目补屏、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 测试缓存如实保留。

View File

@ -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`。

View File

@ -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。缩放只影响显示,不改变真实坐标。大图逐份加载、任务受控并发。
## 验收与完成口径