Share detached JSON provenance with copy checkpoints and finish step-four verification

This commit is contained in:
2026-11-11 14:16:45 +08:00
parent 2089b63e79
commit e3a2335160
11 changed files with 149 additions and 48 deletions
+24 -7
View File
@@ -1001,8 +1001,8 @@ is suspended; the old image still contains its captured state, and the next cut
is saved after it. Request dispatch retains the durable-prefix barrier. Encoding
or scheduling failures retain the pending Root for retry and produce no partial
storage commit. Initial DTO membership copying, synchronous preparation, unknown
subtrees and individual scalar operations remain synchronous. First-time element
normalization can defer only when its detached JSON provenance is established. This is partial time slicing, not a strict whole-Root latency
subtrees and individual scalar operations remain synchronous. First-time JSON
normalization can defer only when its detached provenance is established. This is partial time slicing, not a strict whole-Root latency
bound. Grouped history entries now keep shape ownership for newly built changes and
operation-ID arrays, including undo/redo lifecycle remaps. Import runners keep it
for their own job envelopes, completed-key arrays, binding dictionaries and
@@ -1137,9 +1137,10 @@ when the same source occurs twice in one image, the second occurrence can reuse
the first completed encoding. Preparation still captures unsafe occurrences
independently; getters and mutable descendants never qualify for this reuse.
First-time element recovery encoding now uses the core's detached-JSON ownership
proof instead of synchronously normalizing every field before the first yield.
Only `detachElementData` results qualify: JSON-cloned, recursively frozen records
First-time element and planning-checkpoint recovery encoding uses the core's
detached-JSON ownership proof instead of synchronously normalizing every field
before the first yield.
Only `detachJsonData` results qualify: JSON-cloned, recursively frozen records
with immutable JSON descendants. A shallow freeze, a document factory envelope,
or an arbitrary host object does not qualify. Preparation requires the standard
Object/Array prototype chain without inherited `toJSON`; otherwise it captures
@@ -1151,6 +1152,22 @@ earlier record's meaning. Cold and cached subtrees both retain the depth limit.
This moves the first full field/row walk into the shared budget; container-member
capture and individual native/scalar operations still have synchronous work.
Step 4's current acceptance evidence, commands and explicit remaining checkpoint
encoding work are recorded in [STEP4_ACCEPTANCE.md](STEP4_ACCEPTANCE.md). Passing
Step 4's acceptance evidence, commands and checkpoint encoding boundaries
are recorded in [STEP4_ACCEPTANCE.md](STEP4_ACCEPTANCE.md). Passing
functional fixtures is not a claim that all performance limits are resolved.
The shared core `json-data.ts` factory gives element data and duplicate planner
checkpoints the same unforgeable clone provenance; element validity and schema
proofs remain separate. It replaces their existing `freeze(clone(...))` operation
without a second clone or a wire-format change. Engine-created job/source/
preparation envelopes keep shape ownership through admission, checkpoint refresh,
ready mapping, source verification and pause/resume/cancel. Fresh allocation
arrays, ready ID tables and root lists use the same shape constructors. Unknown
children still require synchronous capture; copying an envelope never grants a
deep-data fact to them. Legacy/restored data retains defensive capture until a
new planner snapshot supplies fresh provenance.
A final ready plan has already crossed its required preparation durability
barrier. Its encoded facts may therefore already exist, and the ready source
can reuse them without an artificial yield. Tests distinguish first-time
preparation slicing from ready-source byte/mapping preservation and reopening.
+14 -14
View File
@@ -1,6 +1,6 @@
# Step 4:渐进功能与呈现验收
状态:已完成最终功能入口复核,调度余项未完成,整体进度 3/7。此文件随实现提交;完整目标仍包含后续真实服务、迁移和集成验收。
状态:第4步原定功能、合作调度及规模输入验收已通过;整体进度 4/7。长任务与非抢占边界见下文,不宣称生产实时性能保证。此文件随实现提交;完整目标仍包含后续真实服务、迁移和集成验收。
## 范围与直接证据
@@ -17,12 +17,12 @@
1. `writePrefix` 是有序单 writer,逐个保存 Root,异步编码不会跳过中间 Root。
2. 外部对象、getter、custom `toJSON` 和浅冻结值在第一次让出前按原 codec 捕获;私有容器只证明成员固定,内部未知值仍需捕获。
3. core 的 JSON 脱离、深冻结元素可以延后逐字段编码。需要标准 Object/Array 原型且没有继承的 `toJSON`;每次未知值捕获后重新检查环境。外部回调不能改变已经捕获的前项,也不能让后项错误沿用旧环境。
3. core 的 `detachJsonData` 为元素和复制规划 checkpoint 提供 JSON 脱离、深冻结事实,可以延后逐字段编码。需要标准 Object/Array 原型且没有继承的 `toJSON`;每次未知值捕获后重新检查环境。外部回调不能改变已经捕获的前项,也不能让后项错误沿用旧环境。
4. 标量归一化、UTF-8 计数、history/import/pending 容器与首次元素遍历共用软预算。已完成的深不可变编码可复用,但不能省略 unsafe occurrence 的求值。
5. 编码、调度或存储失败保留待存 Root;持久化没有确认,不解除网络发送屏障。旧 Root 与编码期间的新编辑保持各自内容。
6. journal 比较先判断子值是否相同,再为变化项分配路径,避免为共享的整个前缀创建临时数组。patch 顺序与字节语义不变。
`recovery-encoding.test.mjs` 验证首次归一化确实在 yield 间发生、继承行为、外部回调、Proxy trap 次数、UTF-8、冷/热深度边界;`engine.test.mjs` 以 17000 元素验证暂停编码期间编辑、逐 Root 落盘和发送屏障。`journal-delta.test.mjs` 验证精确重放及属性顺序;浏览器检查验证真实 IndexedDB 重开。
`json-data.test.mjs` 验证事实不能从外部冻结对象或结构克隆伪造;`duplicate-checkpoint.test.mjs` 验证首次 checkpoint 的真实字段编码在 yield 间发生,后续 planner.step 不改变已捕获值;`imports-engine.test.mjs` 验证真实 preparation/source envelope、字节和重开映射。ready plan 已先持久化时允许缓存复用,不要求空 yield。`recovery-encoding.test.mjs` 验证首次归一化、继承行为、外部回调、Proxy trap 次数、UTF-8、冷/热深度边界;`engine.test.mjs` 以 17000 元素验证暂停编码期间编辑、逐 Root 落盘和发送屏障。`journal-delta.test.mjs` 验证精确重放及属性顺序;浏览器检查验证真实 IndexedDB 重开。
## 可复现命令
@@ -34,7 +34,7 @@ node tests/headless/renderer.integration.mjs --turbopack
node tests/headless/renderer.integration.mjs --hooks-only
node tests/headless/browser-storage.mjs
node tests/headless/package.integration.mjs
HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-step4-production-10000.json node tests/headless/renderer.integration.mjs --performance --production --turbopack
HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-checkpoint-production-10000.json node tests/headless/renderer.integration.mjs --performance --production --turbopack
```
性能入口须在其它构建/测试结束后单独执行;功能检查可并行。这里验证真实 Chrome、IndexedDB、Web Locks 和实际前端,但 HTTP 为隔离 fixture,不能据此声称已完成真实 Foundry 集成。
@@ -42,22 +42,22 @@ HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLES
## 本次运行结果(2026-11-11)
- Node:780 项通过;全量前端 TypeScript 检查通过。
- Node:783 项通过;全量前端 TypeScript 检查通过。
- 实际页面:webpack 51 项、Turbopack 51 项通过;公共 hooks 10 项通过。
- 浏览器存储:57 组通过;SDK:263 个打包文件,类型、独立消费和编译后的浏览器/Worker 检查通过。
- webpack 曾在全部断言结束后被 Chrome 日志管道阻止退出。`renderer.integration.mjs` 现于 Chrome 退出后释放 stdio;重新运行的 51 项检查自动正常退出。
- 浏览器存储:57 组通过;SDK:265 个打包文件,类型、独立消费和编译后的浏览器/Worker 检查通过。
- Chrome 退出后的 stdio 清理保留。此次五组并行检查中,webpack 有一次原生双击未打开恢复编辑器;同源码单独复跑51项通过。随后测试增加同步完成、两次rAF和实际可命中点击点的前置条件,保留原生事件/编辑焦点断言;两种构建器各51项均通过。未据此推定产品逻辑有错或仅是CPU争用。
- 生产规模环境:本机 arm64、macOS 15.6、Node 23.10.0、Chrome 157.0.8103.39,Next production Turbopack;无并行构建/测试、无计时代码插桩。
- 10000-child 展开分区深复制:首副本呈现 799.5ms、本地完成帧 36264.8ms、加入并同步 36469.9ms;帧间隔 P95 33.4ms/max316.6ms;25 次 longtask,max341ms/total1869ms。
- early 五次 input→两次 rAF:50.5/31.3/22.6/20.9/41.3ms;middle(5255 项)21.5ms、tail(9131 项)39.4ms。全部发生于复制活跃期间,DOM/focus 保留;中尾 CDP 往返约18.1/27.4ms。它们不含精确的派发前排队延迟。
- 服务端最终 20005 元素/3 canvas;原/新父层边、原文后续输入、副本旧快照、pan 后副本挂载通过。样本呈现时挂载35节点,全过程最大136,pan 后28;屏幕外 IME/Portal 各保留1,独立应用面板不额外保活,runStartsDuringScenario=0。
- 10000-child 展开分区深复制:首副本呈现 784.1ms、本地完成帧 27949.7ms、加入并同步 28169.9ms;帧间隔 P95 33.4ms/max383.4ms;21 次 longtask,max386ms/total1663ms。
- early 五次 input→两次 rAF:69.8/57.9/34.3/29.5/15.6ms;middle(5279 项)45.3ms、tail(9093 项)73.5ms。全部发生于复制活跃期间,DOM/focus 保留;中尾 CDP 往返约40.9/26.0ms。它们不含精确的派发前排队延迟。
- 服务端最终 20005 元素/3 canvas;原/新父层边、原文后续输入、副本旧快照、pan 后副本挂载通过。样本呈现时挂载35节点,全过程最大137,pan 后28;屏幕外 IME/Portal 各保留1,独立应用面板不额外保活,runStartsDuringScenario=0。
原始报告 `/private/tmp/headless-step4-production-10000.json` 和同名 PNG 为本次本机观察;命令与断言已保存在仓库,可独立复现。单次运行不同 adaptive 批次和 GC 负载,不能比较前轮26.2秒而宣称确定改善或回退,更不能说所有停顿已解决。
原始报告 `/private/tmp/headless-checkpoint-production-10000.json` 和同名 PNG 为本次本机观察;命令与断言已保存在仓库,可独立复现。单次运行有不同 adaptive 批次、native/GC 与系统调度负载,不能以此与前轮36.26秒比较而宣称确定提速,更不能说所有停顿已解决,或未经归因把386ms全部称为GC。
## 明确剩余项
## 收口审查
复制规划的 `createDuplicatePlanner().checkpoint()` 当前返回 `freeze(clone(state))`;它具有 JSON 脱离性质,但这项来源尚未传递给 codec。`preparation.planner` / `source.plan` 因而仍可能经未知父对象走同步 normalization。下一项是由 core 提供同元素一致的、不可伪造的 JSON 脱离来源事实,并在 runtime 自建 checkpoint envelope 中保留这项事实,补首次 checkpoint 编码、外部回调与恢复测试。不能仅以 `Object.freeze` 推定来源,也不能跳过完整 checkpoint/发送屏障。
前轮唯一明确的编码缺口已经接通:`createDuplicatePlanner().checkpoint()` 与元素复用同一 JSON 脱离工厂,runtime 自建 job/source/preparation envelope 保留 shape 来源;分配列表、ready ID表和根列表不再丢失这项边界。首次 preparation 确实分段,最终 ready plan 先经过持久屏障后复用已有编码,重开后映射保持。
此项完成并复验前,Step 4 保持未完成。本文件只确认已列入口和观察,未完成项不能转移到后端阶段来虚增进度。
第4步验收按开工时的四类职责和6ms合作调度软目标完成,不把它改成“每次GC/native/原子发布均小于6ms”。此结论覆盖原定实现与列出的复现入口;它不代表无卡顿、低端设备SLA、任意selector皆O(1)或T01–T36全部完成。首项可见、输入保留、两侧快照正确、Root按序落盘及发送屏障均有直接断言;最终长任务观测作为已知限制保留,不假称已经消除。
## 调度限制与后续分工
+2 -1
View File
@@ -1,3 +1,4 @@
import {detachJsonData} from './json-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
import { ancestors, targetElement, validateDocumentSteps, validateDocumentTopologySteps } from './document.ts';
import { duplicateTreeChange, duplicateEdgeChange, duplicateOverlay, type DuplicateOptions, type DuplicatePlan } from './duplicate.ts';
@@ -42,7 +43,7 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
return Object.freeze({
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
checkpoint():DuplicateCheckpoint{return freeze(clone(state));},
checkpoint():DuplicateCheckpoint{return detachJsonData(state);},
step():boolean{
if(sourceChecks){if(sourceChecks.next().done)sourceChecks=undefined;return false;}
// Reconstruct only the derived output cache. Index/DFS/allocation work stays at its saved cursor.
+2 -2
View File
@@ -1,12 +1,12 @@
import type {ElementRecord,ComponentDefinition} from './types.ts';
import {clone,freeze} from './values.ts';
import {detachJsonData} from './json-data.ts';
// Provenance comes from detaching JSON data, never from an external freeze or
// document envelope. This does not assert record validity or registry support.
const owned=new WeakSet<object>();
export function detachElementData(record:ElementRecord):ElementRecord{
if(record&&typeof record==='object'&&owned.has(record))return record;
const value=freeze(clone(record));
const value=detachJsonData(record);
if(value&&typeof value==='object')owned.add(value);
return value;
}
+11
View File
@@ -0,0 +1,11 @@
import {clone,freeze} from './values.ts';
// Private provenance for a fresh, recursively frozen JSON clone. A caller can
// request a detached copy, but cannot grant this fact to an existing object.
const detached=new WeakSet<object>();
export function detachJsonData<T>(input:T):T {
const value=freeze(clone(input));
if(value&&typeof value==='object')detached.add(value);
return value;
}
export const hasDetachedJsonData=(value:object)=>detached.has(value);
+27 -18
View File
@@ -1,4 +1,5 @@
import {dataRecord} from '../canvas-core/data-containers.ts';
import {dataArray,dataRecord} from '../canvas-core/data-containers.ts';
import {detachJsonData} from '../canvas-core/json-data.ts';
import {createImportReadProof,IMPORT_PROOF_BLOCK_BYTES} from './import-read-proof.ts';
import {isImportByteFailure} from './import-byte-reader.ts';
import {assertImportEvidence,importSeals,importsMaySend} from './import-verification.ts';
@@ -670,16 +671,24 @@ export async function openProjectEngine(options: EngineOptions) {
assertImportWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
integrityOwner=job.id;try{const result=editor.updateRuntime(previous=>{const imports=new Map(previous.imports??[]);if(imports.get(job.id)?.generation!==expectedGeneration)throw fault('stale_import_job');imports.set(job.id,job.version===3?job:freeze({...job,version:3 as const}));return Object.freeze({...previous,imports:readMap(imports)});});if(result.status==='rejected')throw fault(result.code,result.targets);}finally{integrityOwner=undefined;}
}
// These records are newly constructed by the engine, including source and
// preparation envelopes. Shape ownership never asserts ownership of children.
function importCheckpoint(job:ImportJob):ImportJob {
return dataRecord(freeze({...job,
...(job.source?{source:dataRecord(freeze({...job.source}))}:{}),
...(job.preparation?{preparation:dataRecord(freeze({...job.preparation,allocationIds:dataArray(freeze(job.preparation.allocationIds))}))}:{}),
}));
}
function checkpointImportSource(job:ImportJob):ImportJob{
if(job.source?.integrityVersion!==2)return job;
const context=proofReaders.get(job.id),current=editor.getSnapshot().runtime.imports?.get(job.id)?.source;
if(!context||context.fileId!==job.source.fileId)throw fault('import_source_unavailable');
const {sha256:_,integrityError:__,...source}=job.source;
return dataRecord(freeze({...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}}));
return importCheckpoint({...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}});
}
function failImportVerification(job:ImportJob,error:unknown){
const code=(error as {code?:string}).code??'import_source_verification_failed';
publishImport(freeze({...job,generation:job.generation+1,status:['cancelled','partial'].includes(job.status)?job.status:'paused',source:{...job.source!,integrityError:code},error:code}),job.generation);
publishImport(importCheckpoint({...job,generation:job.generation+1,status:['cancelled','partial'].includes(job.status)?job.status:'paused',source:{...job.source!,integrityError:code},error:code}),job.generation);
const release=importRunners.get(job.id)?.hold();release?.();report(error);
}
function verifyImportSource(id:Id,context:{fileId:Id;bytes:Blob;reader:ReturnType<typeof createImportReadProof>}):Promise<void>{
@@ -691,7 +700,7 @@ export async function openProjectEngine(options: EngineOptions) {
try{await context.reader.verify(signal);sha256=await abortableFileRead(sourceDigest(context.bytes,signal),signal);const job=current();if(job.source?.sha256&&job.source.sha256!==sha256)throw fault('import_source_changed');}
catch(error){if(signal.aborted||closing||closed||importHolds.size)return;failImportVerification(current(),error);try{await persist();}catch{}return;}
const job=current();if(job.status==='cancelled'&&!job.committedSourceKeys.length)return;const{integrityError:oldError,...source}=job.source!,next={...job,source:{...source,sha256,readProof:context.reader.getProof()}};if(oldError&&next.error===oldError)delete next.error;
publishImport(freeze(next),job.generation);await persist();
publishImport(importCheckpoint(next),job.generation);await persist();
})().catch(error=>{if(!signal.aborted&&!closing&&!closed)report(error);}).finally(()=>{if(verificationTasks.get(id)?.controller===controller)verificationTasks.delete(id);});
verificationTasks.set(id,{controller,promise});return promise;
}
@@ -713,19 +722,19 @@ export async function openProjectEngine(options: EngineOptions) {
if(preparation.version===3||preparation.version===4){
const state=await prepareDuplicateStream(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner,topologyOnly:!!indexed},{signal:controller.signal,checkpointIntervalMs:50,maxStepsPerCheckpoint:8192,checkpoint:async state=>{
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
}}),job=check();assertNativeImportTarget(job,editor.getSnapshot().document);
const stableIds=Object.fromEntries([...state.elementIds.map(([old,id])=>[`element:${old}`,id]),...state.canvasIds.map(([old,id])=>[`canvas:${old}`,id])]),next={...job,generation:job.generation+1,parserVersion:2,status:'running' as const,stableIds,source:{...job.source!,kind:'duplicate-plan' as const,plan:state,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:state.roots.map(old=>stableIds[`element:${old}`])}}};delete next.preparation;delete next.error;
publishImport(freeze(next),job.generation);return{job:getImport(id),bytes,document,indexed};
const stableIds=dataRecord(Object.fromEntries([...state.elementIds.map(([old,id])=>[`element:${old}`,id]),...state.canvasIds.map(([old,id])=>[`canvas:${old}`,id])])),next={...job,generation:job.generation+1,parserVersion:2,status:'running' as const,stableIds,source:{...job.source!,kind:'duplicate-plan' as const,plan:state,copy:dataRecord({sourceRevision:job.source!.copy!.sourceRevision,roots:dataArray(state.roots.map(old=>stableIds[`element:${old}`]))})}};delete next.preparation;delete next.error;
publishImport(importCheckpoint(next),job.generation);return{job:getImport(id),bytes,document,indexed};
}
const allocationIds=[...preparation.allocationIds];let cursor=0;
const plan=preparation.version===2?await planDuplicateResumable(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner},{signal:controller.signal,checkpoint:async state=>{
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
}}):await planDuplicateAsync(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId:()=>{const index=cursor++;if(index<allocationIds.length)return allocationIds[index];const id=createId();allocationIds.push(id);return id;}},{signal:controller.signal,checkpoint:async steps=>{
const job=check(),previous=job.preparation!;
if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return;
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
}});
check();if(preparation.version===1&&cursor!==allocationIds.length)throw fault('copy_interpreter_changed');
const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await sourceDigest(encoded,controller.signal),job=check();assertImportWritable();assertNativeImportTarget(job,editor.getSnapshot().document);
@@ -733,7 +742,7 @@ export async function openProjectEngine(options: EngineOptions) {
const fileId=createId(),runtime=editor.getSnapshot().runtime;if(!fileId||fileWrites.has(fileId)||runtime.resources.has(fileId)||[...(runtime.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]);
const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,previousFiles:[...(job.source!.previousFiles??[]),{fileId:job.source!.fileId,byteLength:job.source!.byteLength,...(job.source!.sha256?{sha256:job.source!.sha256}:{})}],kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,sha256,integrityVersion:1 as const,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error;
fileWrites.set(fileId,encoded);try{publishImport(freeze(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;}
fileWrites.set(fileId,encoded);try{publishImport(importCheckpoint(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;}
return{job:getImport(id),bytes:encoded,document:undefined,indexed:undefined};
}
function loadImport(id:Id):Promise<void>{
@@ -760,7 +769,7 @@ export async function openProjectEngine(options: EngineOptions) {
const verified=getImport(id);if(verified.generation!==latest.generation||verified.sourceFingerprint!==job.sourceFingerprint||verified.source?.fileId!==source.fileId||['completed','partial','cancelled','paused'].includes(verified.status))return;
if(importHolds.size){deferredImportLoads.add(id);return;}
if(source.sha256&&source.sha256!==sha256)throw fault('import_source_changed');
if(!source.sha256){publishImport(freeze({...verified,generation:verified.generation+1,source:{...source,sha256}}),verified.generation);job=getImport(id);source=job.source!;}else job=verified;
if(!source.sha256){publishImport(importCheckpoint({...verified,generation:verified.generation+1,source:{...source,sha256}}),verified.generation);job=getImport(id);source=job.source!;}else job=verified;
}
let copyDocument:Document|undefined,indexed:IndexedCopySnapshot|undefined;
if(source.kind==='duplicate-snapshot'){if(preparationController.signal.aborted||importHolds.size||latest.status!=='preparing')return;const materialized=await materializeCopy(id,bytes,preparationController);job=materialized.job;source=job.source!;bytes=materialized.bytes;copyDocument=materialized.document;indexed=materialized.indexed;expectedFingerprint=job.sourceFingerprint;}
@@ -792,7 +801,7 @@ export async function openProjectEngine(options: EngineOptions) {
});
importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold());
if(getImport(id).status==='running')void runner.start();
})().catch(error=>{if(preparationController.signal.aborted)return;if(!closing&&!closed){try{const job=getImport(id);if(job.source?.integrityVersion===2&&!importRunners.has(id)&&!verificationTasks.has(id)){proofReaders.get(id)?.reader.close();proofReaders.delete(id);}if(job.sourceFingerprint!==expectedFingerprint)return;if(job.source?.integrityVersion===2&&(isImportByteFailure(error)||(error as {code?:string}).code==='import_source_unavailable')){failImportVerification(job,error);void persist().catch(report);return;}if(['completed','partial','cancelled','paused'].includes(job.status))return;publishImport(freeze({...job,generation:job.generation+1,status:'paused',error:(error as {code?:string}).code??'import_failed'}),job.generation);}catch{/* Recovery/identity fences retain the original job. */}report(error);}}).finally(()=>{preparationControllers.delete(id);importLoads.delete(id);if(!['running','preparing'].includes(getImport(id).status))deferredImportLoads.delete(id);if(!closing&&!closed&&!importHolds.size&&deferredImportLoads.delete(id)&&!importRunners.has(id)&&['running','preparing'].includes(getImport(id).status))queueMicrotask(()=>{if(!closing&&!closed)void loadImport(id);});});
})().catch(error=>{if(preparationController.signal.aborted)return;if(!closing&&!closed){try{const job=getImport(id);if(job.source?.integrityVersion===2&&!importRunners.has(id)&&!verificationTasks.has(id)){proofReaders.get(id)?.reader.close();proofReaders.delete(id);}if(job.sourceFingerprint!==expectedFingerprint)return;if(job.source?.integrityVersion===2&&(isImportByteFailure(error)||(error as {code?:string}).code==='import_source_unavailable')){failImportVerification(job,error);void persist().catch(report);return;}if(['completed','partial','cancelled','paused'].includes(job.status))return;publishImport(importCheckpoint({...job,generation:job.generation+1,status:'paused',error:(error as {code?:string}).code??'import_failed'}),job.generation);}catch{/* Recovery/identity fences retain the original job. */}report(error);}}).finally(()=>{preparationControllers.delete(id);importLoads.delete(id);if(!['running','preparing'].includes(getImport(id).status))deferredImportLoads.delete(id);if(!closing&&!closed&&!importHolds.size&&deferredImportLoads.delete(id)&&!importRunners.has(id)&&['running','preparing'].includes(getImport(id).status))queueMicrotask(()=>{if(!closing&&!closed)void loadImport(id);});});
importLoads.set(id,task);return task;
}
function startNativeImport(bytes:Blob,targetCanvasId:Id,format:'ndjson'|'json-array'|'document'):Id{
@@ -800,7 +809,7 @@ export async function openProjectEngine(options: EngineOptions) {
assertImportWritable();const loadingAdmission=loadingImportAdmission(editor.getSnapshot(),targetCanvasId);const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:format==='document'?5:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),integrityVersion:2,readProof:{version:1,fileId,byteLength:bytes.size,blockBytes:IMPORT_PROOF_BLOCK_BYTES,blocks:[]},kind:format==='document'?'native-document':format==='json-array'?'native-json-array':'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
const job=importCheckpoint({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:format==='document'?5:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),integrityVersion:2,readProof:{version:1,fileId,byteLength:bytes.size,blockBytes:IMPORT_PROOF_BLOCK_BYTES,blocks:[]},kind:format==='document'?'native-document':format==='json-array'?'native-json-array':'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
fileWrites.set(fileId,bytes);
integrityOwner=id;try{const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;}finally{integrityOwner=undefined;}
@@ -821,7 +830,7 @@ export async function openProjectEngine(options: EngineOptions) {
}
const captured=editor.getSnapshot(),target=settings.viewCanvasId??(selection[0]?captured.document.elements.get(selection[0].id)?.ownerCanvasId:undefined),signal=settings.signal,controller=new AbortController(),abort=()=>controller.abort();
const loadingAdmission=target?loadingImportAdmission(captured,target,selection):undefined;
const targets=freeze(clone(selection)),offset=freeze(clone(settings.worldOffset??{x:32,y:32}));
const targets=detachJsonData(selection),offset=detachJsonData(settings.worldOffset??{x:32,y:32});
for(const selected of targets){const element=captured.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}if(![offset.x,offset.y].every(Number.isFinite))throw fault('invalid_copy_offset');
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)controller.abort();
const task=(async()=>{
@@ -831,15 +840,15 @@ export async function openProjectEngine(options: EngineOptions) {
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;
if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
const entry=canvas.parent?captured.document.elements.get(canvas.parent.entryElementId)!:undefined;
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
const job=importCheckpoint({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
fileWrites.set(fileId,bytes);const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
})();
copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task;
},
async pause(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();publishImport(freeze({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
async resume(id:Id){assertImportWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();if(getImport(id).source?.integrityVersion===2)await resetImportReader(id);await persist();await scheduler.resume();assertImportWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);},
async cancel(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}if(!sourceNeedsSeal(getImport(id)))verificationTasks.get(id)?.controller.abort();},
async pause(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();publishImport(importCheckpoint({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
async resume(id:Id){assertImportWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();if(getImport(id).source?.integrityVersion===2)await resetImportReader(id);await persist();await scheduler.resume();assertImportWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(importCheckpoint(next),current.generation);await loadImport(id);},
async cancel(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(importCheckpoint(next),job.generation);await persist();}}if(!sourceNeedsSeal(getImport(id)))verificationTasks.get(id)?.controller.abort();},
});
scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]);
for(const job of initial.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status))void loadImport(job.id);
+4 -5
View File
@@ -1,5 +1,4 @@
import {hasOwnedElementData} from '../canvas-core/element-data.ts';
import type {ElementRecord} from '../canvas-core/types.ts';
import {hasDetachedJsonData} from '../canvas-core/json-data.ts';
import type { Json } from '../canvas-core/types.ts';
import { fault, freeze, validJson } from '../canvas-core/values.ts';
import {isDataContainer} from '../canvas-core/data-containers.ts';
@@ -77,14 +76,14 @@ type Scalar = null|string|number|boolean|undefined|symbol;
type Prepared = Scalar | Encoded | {source:object;depth:number;children?:Prepared[];keys?:string[]};
// Read unknown objects synchronously, before the first suspension. Shape-owned
// containers can include external values; each such occurrence is captured now.
// Detached element JSON can also defer after checking its prototype environment.
// Detached JSON can also defer after checking its prototype environment.
function prepareEncoding(input:unknown) {
const normalize=createEncoder(),visiting=new Set<object>();
let plainEnvironment:boolean|undefined;
const capture=(value:unknown,depth:number,key:string)=>{
try{return normalize(value,depth,key);}finally{plainEnvironment=undefined;}
};
const detached=(value:object)=>hasOwnedElementData(value as ElementRecord)&&(plainEnvironment??=(
const detached=(value:object)=>hasDetachedJsonData(value)&&(plainEnvironment??=(
Object.getPrototypeOf(Object.prototype)===null&&Object.getPrototypeOf(Array.prototype)===Object.prototype&&
!Object.getOwnPropertyDescriptor(Object.prototype,'toJSON')&&!Object.getOwnPropertyDescriptor(Array.prototype,'toJSON')
));
@@ -99,7 +98,7 @@ function prepareEncoding(input:unknown) {
if(typeof value!=='object')return capture(value,depth,key);
const cached=immutable.get(value);
if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;}
// Detached element JSON has no accessors, custom prototypes or mutable
// Detached JSON has no accessors, custom prototypes or mutable
// descendants. Its full walk can wait; foreign freezes cannot grant this.
if(detached(value))return {source:value,depth};
if(!isDataContainer(value))return capture(value,depth,key);
@@ -30,3 +30,21 @@ test('checkpoint bindings, shape and source identities reject incompatible state
test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{
const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old);
});
test('a first planning checkpoint encodes cooperatively and is isolated from later planner steps',async()=>{
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.ts');
const rows=Array.from({length:1000},(_,i)=>({...document.elements.get('A'),id:'checkpoint-'+i,ownerCanvasId:'root'}));
const source=createDocument('large',[{id:'root',parent:null}],rows),planner=createDuplicatePlanner(source,registry,rows.map(row=>({id:row.id,incarnation:row.incarnation})),{createId:()=>{throw Error('not allocating yet');}},binding);
const checkpoint=planner.checkpoint(),before=JSON.stringify(checkpoint),stringify=JSON.stringify;let encoded=0,atFirst,tick=0,yields=0;
assert.equal(hasDetachedJsonData(checkpoint),true);
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('checkpoint-'))encoded++;return Reflect.apply(stringify,this,[value,...args]);};
try{
const pending=sealRecoveryImageAsync(17,1,1,checkpoint,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirst??=encoded;yields++;}});
for(let i=0;i<20000&&planner.progress().steps<=10;i++)planner.step();
assert.ok(planner.progress().steps>checkpoint.steps);
const image=await pending;assert.ok(yields>0);assert.ok(atFirst<128,'checkpoint strings were normalized before the first yield');
assert.equal(stringify(image.payload),before);assert.equal(stringify(checkpoint),before);
assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength);
}finally{JSON.stringify=stringify;}
});
+26
View File
@@ -380,3 +380,29 @@ test('retry verifies a cancelled accepted prefix without restarting insertion',a
test('legacy native array 14 and streaming document 16 retain their SHA-first interpreters',async()=>{
for(const format of ['array','document']){const f=fixture();let engine=await f.open();try{const bytes=format==='array'?new Blob([JSON.stringify([node('legacy-native')])]):new Blob([JSON.stringify({documentFormatVersion:1,projectId:'legacy-source',canvases:[{id:'legacy-source',parent:null}],elements:[{...node('legacy-native').record,ownerCanvasId:'legacy-source'}],tombstones:{}})]),id=format==='array'?engine.imports.startJsonArray(bytes,'canvas'):engine.imports.startDocument(bytes,'canvas');await engine.settle();await engine.close();const image=structuredClone(f.image());image.formatVersion=format==='array'?14:16;const job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.source.integrityVersion=1;delete job.source.readProof;delete job.source.integrityError;const decoded=decodeRecovery(image,registry);assert.equal(decoded.runtime.imports.get(id).source.integrityVersion,1);f.storage.recover=async()=>structuredClone(image);engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).parserVersion,format==='array'?3:5);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}}
});
test('engine preparation encodes in slices and the ready source retains checkpoint bytes and mappings on reopen', {timeout:20000},async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const rows=Array.from({length:1000},(_,i)=>({...node('live-checkpoint-'+i).record,incarnation:1,ownerCanvasId:'canvas'}));
const initial=createDocument('project',[{id:'canvas',parent:null}],rows),f=fixture(initial),gate=deferred();f.gate=gate;
let engine=await f.open(),unsubscribe=()=>{};const captured=[],seen=new Set();
try{
await engine.persist();
unsubscribe=engine.editor.subscribe((_,snapshot)=>{
for(const job of snapshot.runtime.imports.values()){
const candidates=[['preparation',job.preparation?.planner?job.preparation:undefined],['source',job.source?.kind==='duplicate-plan'?job.source:undefined]];
for(const [phase,part] of candidates){if(!part||seen.has(phase))continue;seen.add(phase);const before=JSON.stringify(part);let ticks=0,yields=0;
const pending=sealRecoveryImageAsync(17,1,1,part,{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
captured.push(pending.then(image=>({phase,image,before,yields}),error=>({phase,error})));
}
}
});
const id=await engine.imports.duplicate(rows.map(row=>({id:row.id,incarnation:1})));
await until(engine,snapshot=>snapshot.runtime.imports.get(id)?.source?.kind==='duplicate-plan');
await engine.imports.pause(id);unsubscribe();
const results=await Promise.all(captured);assert.deepEqual([...seen].sort(),['preparation','source']);
for(const result of results){assert.equal(result.error,undefined);if(result.phase==='preparation')assert.ok(result.yields>0,'preparation checkpoint was synchronously normalized');assert.equal(JSON.stringify(result.image.payload),result.before);}
const ids=engine.imports.getJobs().get(id).stableIds;gate.resolve();await engine.close();engine=await f.open();
assert.equal(engine.imports.getJobs().get(id).status,'paused');assert.deepEqual(engine.imports.getJobs().get(id).stableIds,ids);assert.equal(f.errors.length,0);
}finally{unsubscribe();gate.resolve();await engine.close();}
});
+14
View File
@@ -0,0 +1,14 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json-data.ts';
test('JSON detachment owns only its copied immutable result and preserves JSON semantics',()=>{
let calls=0;const external={nested:{text:'before'},date:new Date('2026-01-01Z'),custom:{toJSON(){calls++;return{value:2};}},array:[,undefined,NaN],['__proto__']:{safe:true}};
const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false);
external.nested.text='after';external.date.setUTCFullYear(2027);
assert.equal(value.nested.text,'before');assert.equal(value.date,'2026-01-01T00:00:00.000Z');assert.deepEqual(value.array,[null,null,null]);assert.equal(Object.hasOwn(value,'__proto__'),true);
assert.ok(Object.isFrozen(value.nested));assert.throws(()=>{value.nested.text='mutate';},TypeError);
assert.equal(hasDetachedJsonData(Object.freeze({...value})),false);assert.equal(hasDetachedJsonData(structuredClone(value)),false);
assert.equal(hasDetachedJsonData(Object.freeze({nested:{mutable:true}})),false);
const cycle={};cycle.self=cycle;assert.throws(()=>detachJsonData(cycle));assert.throws(()=>detachJsonData({big:1n}));
});
+7 -1
View File
@@ -312,7 +312,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length,beforeCurve+1);
console.log('PASS actual GraphPage chooses canonical editor; create/undo/redo, drag/resize/curve final coordinates, Escape and IME text history publish correctly');
await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);
const recoveryPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+50,y:r.y+30}})()`);
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'curve edit did not settle before opening recovery editor');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const recoveryPoint=await evaluate(`(()=>{const label=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]'),r=label.getBoundingClientRect();
for(let y=Math.max(0,r.top)+8;y<Math.min(innerHeight,r.bottom);y+=8)for(let x=Math.max(0,r.left)+8;x<Math.min(innerWidth,r.right);x+=8)
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
throw Error('recovery editor has no exposed native input point');
})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open');
rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery');