docs(canvas): clarify legacy tree coordination and retry evidence
This commit is contained in:
@@ -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 <T,>(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<string>()): Promise<void> {
|
||||
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<string, unknown>, 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<CanvasCommandResult> => {
|
||||
try {
|
||||
const result = await api<CanvasCommandResult>(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<void>((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) {
|
||||
|
||||
Reference in New Issue
Block a user