Agent Command 事务agent-transaction

Command 描述作品发生什么变化,Transaction 定义用户心智中的“一步”,History 保存可逆的真实 Diff。鼠标、属性面板和 Agent 不应该各写一套修改逻辑。

学习目标

  • 设计 create/update/delete 三种可校验 Command;
  • 用 before/after 构造可逆 Transaction;
  • 理解实时预览、Document 更新和 History 粒度的区别;
  • 处理 revision 冲突、actionId 幂等和原子失败;
  • 用步数与字节预算治理 History。

1. 统一写入链路

鼠标操作 ─┐
属性面板 ─┼→ CanvasCommand[] → Transaction → Document → Derived State → Renderer
AgentPlan ─┘

Derived State 包括 SpatialIndex、Working Set、持久化 Journal 和协作 Diff。它们消费同一个已提交事务,而不是监听各个 UI 入口猜测变化。

2. Command 只描述业务变化

type CanvasCommand =
  | { type: 'create'; node: CanvasNode }
  | { type: 'update'; id: string; patch: NodePatch }
  | { type: 'delete'; id: string }

Command 里没有 new Konva.Rect()context.fillRect()HTMLElement。Renderer 如何显示,是提交后的另一层职责。

执行前需要验证:ID、节点类型、允许字段、坐标范围、数量限制、关系引用和宿主业务不变量。

3. before / after 让操作可逆

操作 before after
create null 完整新节点
update 完整旧节点 完整新节点
delete 完整旧节点 null
interface Change {
  id: string
  before: CanvasNode | null
  after: CanvasNode | null
}

interface Transaction {
  actionId: string
  label: string
  revisionBefore: number
  revisionAfter: number
  changes: Change[]
}

Undo 反向应用 before,Redo 正向应用 after。History 不必理解“排列”是什么,只需要恢复真实变化。

4. 为什么三条 Command 只是一条历史

“水平排列 A、B、C”会产生三条 update,但用户认为它是一次任务:

Transaction:水平排列三张卡片
├─ A before → after
├─ B before → after
└─ C before → after

点击一次 Undo,三张卡片整体回到原位。若每条 Command 独立入栈,用户要按三次 Undo,Agent 的一次任务也可能只撤回一半。

5. 一次拖动为什么只有一条 History

pointerdown
  → 保存 transaction before

pointermove × 120
  → 实时更新 Document
  → 更新 SpatialIndex 和当前 Handle
  → History 新增 0

pointerup
  → 读取 after
  → 提交 1 条 Transaction

实时更新 Document 能保证吸附、连线和命中读取当前坐标;不为每次 move 写历史,能保证一次手势只需一次 Undo。

错误方式是每次 pointermove 复制完整大 Document。若 6,000 节点快照约 1.1 MiB,120 次 move 会制造约 132 MiB 级别的重复数据和大量 GC。

6. 原子执行

Transaction 必须全成或全败:

预检全部 Command
  → 在草稿/事务 Store 中应用
  → 校验最终 Document 不变量
  → 一次 commit revision
  → 产生 changes
  → 通知 Index/Renderer/Persistence

若第 3 条 Command 失败,前两条不能留在 Document。不要边遍历边修改正式 Store,再试图手工回滚未知副作用。

7. 并发 revision

Planner 读取 revision 41 后,用户可能又做了一次修改,Document 已是 42。此时旧计划必须拒绝:

if (expectedRevision !== document.revision) {
  throw new RevisionConflict(expectedRevision, document.revision)
}

调用方应重新读取上下文并 rebase,而不是覆盖新状态。Revision 不是锁住整个 UI,而是防止基于旧世界的计划静默写入。

8. actionId 幂等

网络超时可能发生在“服务端已提交、调用方未收到响应”。重试相同事务时:

  • 相同 actionId + 相同输入:返回第一次结果,不重复执行;
  • 相同 actionId + 不同输入:拒绝,防止 ID 复用掩盖错误;
  • 新业务意图:使用新 actionId。

可以保存输入摘要和 commit result。幂等不能只按 label 判断。

9. Undo/Redo 如何更新全链路

Undo
  → 反向应用 changes.before
  → 更新 Document revision
  → 增量更新 SpatialIndex
  → 更新/销毁当前 Renderer handles
  → 原事务进入 Redo Stack

Redo
  → 应用 changes.after
  → 同步相同派生层

只移动 Konva Node 不是真正 Undo。Camera 通常不进入作品 History;若需要返回上个视角,应单独设计 View History。

10. History 也需要预算

maxTransactions:最多保留多少步
maxHistoryBytes:before/after 合计字节预算
mergePolicy:连续输入或拖动如何合并

删除图片节点时,History 只保存 node + assetId,不复制 Blob。但资产 GC 必须考虑 Undo/Redo 是否仍能恢复它。

History 是可裁剪的编辑能力,不是作品真相。快照损坏或 revision 不匹配时可以丢弃,Document 仍要保留。

11. 操作 Demo

  1. 点击“排列三张卡片”,观察三条 update 只形成一个 Transaction;
  2. 一次 Undo/Redo 验证三节点整体变化;
  3. 修改 B 颜色,Undo 后执行另一操作,观察旧 Redo 分支清空;
  4. 模拟相同 actionId 重试,确认 revision 只增加一次;
  5. 用旧 expectedRevision 提交,确认无部分写入;
  6. 刷新后从 Workspace 恢复 History,继续 Undo。

12. 验收标准

一次用户/Agent 意图 = 一个原子 Transaction
失败事务不产生部分 Document 变化
Undo/Redo 同步 Document、Index、Renderer
旧 revision 被明确拒绝
相同 actionId 不重复执行
History 受步数和字节预算约束
Camera/hover/播放器运行态不污染作品 History

常见错误

  • UI、Agent、脚本分别直接改 Store;
  • 每条批量 Command 独立入栈;
  • 每个 pointermove 保存完整快照;
  • 只在 Renderer 上 Undo;
  • revision 冲突时强行覆盖;
  • actionId 重试重复创建节点;
  • History 里复制 Blob 或运行时对象;
  • 删除节点立即永久清理仍被 History 引用的素材。

思考题

Agent 一次创建 100 个节点,第 73 个因坐标越界失败。你会选择先执行再回滚,还是在草稿事务中预检并一次提交?分别列出对 SpatialIndex、Asset 引用和 History 的影响。