From 33f870d16af02d8a5a8b688a83d46f52128e1fbe Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:48:59 +0800 Subject: [PATCH] docs(canvas): clarify legacy tree coordination and retry evidence --- src/components/canvas/use-canvas-tree.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/use-canvas-tree.ts b/src/components/canvas/use-canvas-tree.ts index cc5bf06..271c81d 100644 --- a/src/components/canvas/use-canvas-tree.ts +++ b/src/components/canvas/use-canvas-tree.ts @@ -41,7 +41,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo /** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */ function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);} /** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。 - * X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */ + * X-Expected-Account经代理转发,由上游核对目标账号;本地前后检查不能证明已经发出的写入未生效。 */ const api:CanvasRequest=async (path:string,options?:RequestInit)=>{ if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed'); if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy'); @@ -88,6 +88,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo function resolveRecovery(boardId: string, restore: boolean) { if(migrationBlocked())return; const draft = pendingRecovery.current.get(boardId); + // update 没有成功返回值;若其它准入条件拒绝恢复,下面仍会移除候选/记录,宿主须先保证可恢复。 if (restore && draft) update(boardId, { type: "replace", document: draft }); const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next); localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); @@ -107,6 +108,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo if (writer) writer.adopt(value); else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => { if (!mounted.current) return; + // 显示的是最近通知的 writer 状态,不是遍历全部 writer 后算出的项目汇总;错误只在显式路径清除。 setStatus(state.status); if (state.error) setError(state.error); })); } @@ -195,6 +197,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo /** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */ async function ensure(boardId: string, recursive = false, ancestors = new Set()): Promise { if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle"); + // ensure 自身不复用 loading/在途 Promise;并行调用同一缺失 board 可能重复读取。loading 只服务下方可见子图 effect。 let document = current.current.get(boardId); if (!document) { const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })(); @@ -253,6 +256,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo if(migrationBlocked())return; for (const [boardId, document] of current.current) try { if (pendingRecovery.current.has(boardId)) continue; + // canvasPayload 会过滤未上传占位及其边;此草稿记录不能保存原文件或保证刷新后恢复上传。 const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { setError("local_backup_failed"); } @@ -334,6 +338,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo function accept(result: CanvasCommandResult) { const next = new Map(current.current); const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()]; + // 每项 install 都立即改变 writer 基线;后项校验/合并抛错时,先前 writer 不自动回滚,Map 可能还没 publish。 for (const value of values) { const decoded=decodeSnapshot(value),local=current.current.get(value.board_id),base=writers.current.get(value.board_id)?.document; const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document; @@ -356,7 +361,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo return [...new Set(values.map((value) => value.board_id))]; } /** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。 - * execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */ + * execute闭包复用同一body对象/key/version,但每次发送重新JSON编码;这些恢复信息只在内存,不保证刷新后继续。 */ async function command(boardId: string, path: string, payload: Record, participants?: string[], record = true, versionBoard = boardId) { if (retryOperation.current) throw new Error("canvas_busy"); return exclusive(async () => { @@ -365,11 +370,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } await flush(); const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) }; + // body 只浅展开 payload,嵌套值仍共享引用;外部原地修改可能改变重试字节,不同于新版 FrozenRequest.body。 const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1; /** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */ const execute = async (): Promise => { try { const result = await api(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) }); + // accept 和历史/补读也在这个 try 内;采用中途失败不等于服务端命令未执行,只有下方分类决定是否保留重试。 const changed = accept(result); // 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。 if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } })); @@ -464,6 +471,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo // A definite rejection performed zero writes. A retry may prepare fresh // versions; ambiguous transport failures must retain the exact body/key. // 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。 + // 此处把所有 ApiError<500 当确定拒绝,没有核对 terminal 回执身份;它是旧协议假设,不是新版证据判定。 if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; } showMembers(); } finally { @@ -474,6 +482,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo /** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */ function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) { if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy")); + // Promise 只有成功出队才 resolve,没有 reject/卸载取消;失败留队或页面离开后不能把它当已完成屏障。 return new Promise((resolve) => { members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers(); }); @@ -498,6 +507,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo * 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */ function movePendingImage(source: string, target: string, id: string) { if(migrationBlocked())throw new Error("canvas_busy"); + // 这里仅检查迁移门闩,未复用 exclusive 的 paused/locked/待重试准入;调用者必须先满足更完整的交互条件。 const from = current.current.get(source)!, to = current.current.get(target)!; const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference"); const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!; @@ -553,6 +563,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo stack.pop();destination.push(entry);tick((v)=>v+1);return; } } + // 分支中的多次 await 不持有跨全流程历史锁,也不重新比对 stack 顶项;宿主应串行发起历史动作。 // 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。 if ("id" in entry) { if (isRedo) {