高级治理阅读约 24 分钟最后核对 2026-09-20

Hooks V2 实操手册

SparkWork Hooks V2 完整实操手册:先辨析产品里三套同名机制——桌面 Hooks V2(7 个生命周期事件、通知/提示音/调用工具三种动作)、设置页底部的经典通知(V1 四个节点、布尔开关),以及 spark-engine 里那套没有任何界面的 shell 命令 hook(4 个闭集事件,退出码 2 或输出 block 决策可真正拦截,桌面 Spark 执行器同样会加载项目目录下的 .spark/settings.json)。再讲清 Hooks V2 的真实行为:七个事件的发射时机与 payload、确定性事件 ID 与两级去重、条件路径白名单与八个操作符的真实语义(eq 比较 null 恒为 false、contains 不支持数组)、三种值表达式与模板占位符规则、作用域优先级与基于执行哈希的绑定授权、什么改动会让授权失效、事件到执行的四段管线与三个定时器、超时/重试/幂等/并发的准确语义,以及 20 行排查对照。含如实记录的已知缺口:high-write 工具无法开放、条件无法通过界面移除、失败事件不清理、部分协议能力界面配不到。

Hooks 让你在 Agent 生命周期的固定节点上自动做一件事:弹系统通知、响一声,或者调用统一工具目录里的工具。 它由宿主确定性触发,不依赖模型在提示词里自觉调用;也已明确是观察型机制——动作发生在事件之后,不能改变已经发生的结果。

这一页是实操手册:先分清产品里有三套叫「Hook」的东西,再讲清七个事件、条件表达式、输入映射、三种动作、 作用域与授权、执行管线、重试与幂等,最后给出真实排查路径。

1. 三套 Hook:先分清你在配哪一套

产品里同时存在三套互不相干的机制,都叫「Hook」。混写是这一块最常见的错误, 所以先做对照,后面各章默认只讲桌面端的Hooks V2。

1.1 三者对照

维度桌面 Hooks V2经典通知(V1)spark-engine shell hooks
事件数7 个生命周期事件4 个固定节点4 个闭集事件
事件名turn.started/permission.requested/ question.requested/response.committed/ turn.completed/turn.failed/turn.cancelledpermission_request/ask_user_question/ session_end/session_failUserPromptSubmit/PreToolUse/PostToolUse/ Stop
动作系统通知、提示音、调用工具系统通知、提示音(布尔开关)执行 shell 命令
有界面吗设置 → Hooks设置 → Hooks 底部「经典通知(兼容)」没有任何界面
配置存在哪SQLite 四张hook_*表app_settings的hooks/config,回退hooks/data~/.spark/settings.json与项目内.spark/settings.json、.spark/settings.local.json
能改结果吗不能不能能:退出码 2 或{"decision":"block"}可拦截

1.2 桌面 Hooks V2 的定位

协议文件开头的注释把设计边界写得很直白:首期只做观察型事件,即「事件发生后执行动作,不能改变已发生的结果」。 宿主在生命周期事实落库之后再发射事件,动作失败也不会把 Turn 变成失败——Hook 执行失败被隔离在会话主流程之外。

另一条关键决策是「不依赖模型自行决定是否调用」:事件由HookLifecycleBridge 在真实业务点发射, 例如permission.requested是PermissionService 真正进入等待审批时发出的,而不是模型读提示词后选择调用的。

1.3 spark-engine 的 shell hooks:一个常见盲区

spark-engine/src/hooks/下是另一套实现:Claude Code 风格的 shell 命令 hook, 与桌面 Hooks V2没有共享代码,也不互相 import。它的动作配置形如命令字符串, 单条命令上限 8192 字符,超时上限 600000 毫秒、默认 60000 毫秒,每个 matcher 最多 16 条命令、每个事件最多 32 个 matcher。

它的配置文件按 scope 升序全部执行(不是覆盖合并),matcher 数组直接拼接:

{userSettingsDir}/settings.json // scope: user <cwd>/.spark/settings.json // scope: project <cwd>/.spark/settings.local.json // scope: local

阻塞判定:命令退出码为2,或 stdout 输出 {"decision":"block"},即视为拦截,并携带 reason; 输出 {"decision":"approve"}视为批准。任一条被拦截就立即返回,不再执行后续 hook。其他失败一律不阻塞。matcher只对PreToolUse/PostToolUse生效。

最需要知道的一点:桌面端用 Spark 引擎跑会话时也会加载这套 shell hooks——spark-engine-executor.ts调用 createDefaultEnvWithMcp({ cwd: workspaceRoot }), 内部 buildDefaultEnv再调loadHookRunner。也就是说,项目目录下的.spark/settings.json里的 shell 命令会被执行,而桌面端没有任何界面管理它。 排查「桌面端为什么跑了我没配的命令」时,先去项目目录看这几个文件。

注意:本页后续所有「事件」「作用域」「重试」等描述,指的都是桌面 Hooks V2,不适用于这一节。

2. 界面入口与一次完整配置

2.1 三处入口

入口位置能做什么
设置 → 系统 → Hooks设置页左侧「系统」分组,排在「本地日志」与「存储与备份」之间,图标是铃铛应用级总开关、定义增删改、作用域绑定与授权、运行记录、经典通知兼容区
Agent 编辑页 → Hook 区块助手 → Agents → 打开某个 Agent 后右栏只看该 Agent 的生效来源(Agent 覆盖 / 继承应用级),并就地启用、停用
会话检查器 → Hooks会话右侧检查器面板内(默认收起,可展开)看当前会话的最终生效列表与覆盖关系,会话级临时停用或恢复

三处的定位不同:设置页管定义,Agent 页与会话检查器管生效与覆盖。 一个常见误解是「在 Agent 页能新建 Hook」——不能,那里没有新建入口,定义只能在设置页建。

2.2 设置页的真实结构

「设置 → Hooks」自上而下的顺序是固定的:

  1. 标题Hooks+ 副文案「在 Agent 生命周期的明确节点上自动执行动作:通知、提示音或统一工具目录中受治理的工具调用。」+ 应用总开关(Switch)。
  2. 工具条:**「新建 Hook」**按钮,右侧提示「总开关关闭时暂停新的自动执行,已入队任务保留待恢复。」
  3. 定义列表。空态文案「还没有 Hook 定义」,并给一条示例:「例如:新建一个「回答已提交」Hook,把最终回答正文发送到 Webhook 工具。」
  4. 运行记录面板。
  5. 「经典通知(兼容)」区块(V1 双轨区)。

每条定义显示:名称、停用标记(若enabled=false会显示「定义已停用」标签)、 副行「事件中文名 · 动作描述 · rev N」,以及「编辑」「删除」两个按钮。

总开关切换后的提示文案是固定的两句:

  • 开启:「已恢复自动执行」
  • 关闭:「已暂停新的自动执行(不撤回已发生的外部副作用)」

2.3 七步配完一个 Hook

以下步骤与界面控件一一对应,照着做即可跑通第一个 Hook。

  1. 打开 设置 → Hooks,确认顶部总开关是开启状态。
  2. 点「新建 Hook」,在弹出的 Modal(宽度 720)里填名称与描述。
  3. 选事件。下拉里每项长这样:回答已提交 · response.committed, 选中后字段下方会显示该事件的中文说明。
  4. 选动作。三个分段选项:系统通知/提示音/ 调用工具。 选「调用工具」时下方会出现工具下拉与输入映射区。
  5. (可选)加触发条件。「触发条件(可选)」折叠区里打开「仅当条件满足时执行」, 选操作符与事件路径。需要更复杂的组合条件时见第 4 章——界面只暴露 5 个叶子操作符。
  6. (调用工具时)填输入映射。每行是一个三元组:参数名 / 值类型(路径·常量·模板)/ 值。 点「添加映射」继续加行。空映射时提示「暂无映射,工具将以空参数调用」。
  7. 先「预览」再「创建」。预览只做求值、不执行动作,会显示条件判定结果与「将发送的字段」。 保存后再去「作用域绑定」区点「添加绑定」完成授权——没有绑定授权的定义不会执行。

想验证真实效果,用编辑器底部的**「测试运行」。它会走完整链路并产生独立记录, 因此会弹二次确认「执行测试运行?」,正文写明「测试运行会按当前配置真实执行动作(例如向外部工具发送数据), 并产生独立的测试运行记录。此操作不可撤销。」。测试运行不受绑定授权门控**, 它用当前定义的快照直接入队,所以没绑定也能测,但会产生真实外部副作用。

3. 七个生命周期事件

3.1 事件表与真实发射点

七个事件全部有生产发射点,没有「声明了但从不触发」的事件。触发时机都是「事实先落库,再发事件」。

事件界面文案何时发射
turn.startedTurn 开始 · Turn 已建立并准备进入执行管线Turn 注册事务持久化完成之后
permission.requested权限请求 · 权限请求进入等待审批真实权限请求已创建并进入等待,先于推送给宿主监听器
question.requestedAgent 提问 · Agent 提问进入等待用户输入两条提问回调分支(受监督 / 无人值守)各一处
response.committed回答已提交 · 最终回答成功落库,正文已确定assistant 消息为 complete 且 isFinal
turn.completedTurn 完成 · Turn 成功终态已持久化状态为 completed
turn.failedTurn 失败 · Turn 进入不可恢复失败终态状态为 error
turn.cancelledTurn 已取消 · 用户或系统明确取消 Turn状态为 cancelled

response.committed与turn.completed 不能互相替代:前者是「最终可见回答成功持久化」, 后者是「Turn 成功终态持久化」。一次 Turn 可能先发前者再发后者。

还有一个细节:permission.requested的turn.id可能是字符串 'unknown'—— 这是全仓唯一一处把 turn 标识写成非 turnId 的地方。如果你的 Hook 条件里按 turn.id 做匹配,需要为它留一个例外。

3.2 事件 ID 与两级去重

事件 ID 是确定性派生,不是随机 UUID:

eventId = "hev_" + sha256(eventName + ":" + sourceId)

各事件的稳定源 ID:

  • turn.started/turn.completed/turn.failed/ turn.cancelled→ turnId
  • response.committed→ messageId
  • permission.requested→ requestId
  • question.requested→ questionId,缺省回退 requestId,再缺省才用随机 UUID

这带来两级去重:

层级唯一键重复投递的行为
事件(outbox)hook_events.event_id主键INSERT OR IGNORE:静默忽略,不报错、不替换、不比对内容
运行(队列)UNIQUE (event_id, hook_id)已存在则返回 null,不新建。迁移注释写明「跨 revision 也不自动重跑」

「不比对内容」这条要留意:即使第二次投递带着不同的 envelope,也会被静默丢弃,原来那行的内容不会被覆盖, 也不会有任何告警。代码里没有检测「同 ID 不同内容」的机制。

3.3 发射端短路:未使用 Hook 的应用零额外写入

每次要发事件前,宿主先查「这个事件有没有启用中的定义」。一个都没有就直接返回,不写 outbox。 所以没用 Hook 的应用不会因为这套机制产生任何额外写入。

注意这个短路判据是「有没有启用中的定义」,与「应用级总开关」无关。 总开关关闭时,只要还存在启用的定义,事件照样会写进 outbox——详见第 12 章。

3.4 每个事件能读到什么

信封(envelope)是所有条件与映射的取值来源,结构固定:

{ "schemaVersion": 1, "eventId": "hev_...", "eventName": "turn.failed", "occurredAt": "2026-09-19T...", "source": "host", "session": { "id": "...", "title": "..." }, "turn": { "id": "..." }, "agent": { "id": "...", "name": "..." }, "workspaces": [ { "id": "...", "name": "..." } ], "primaryWorkspaceId": "...", "payload": { ... } }

各事件的 payload 形状:

事件payload
turn.started{}空对象
permission.requested{ requestId, toolName, action, riskLevel }
question.requested{ questionId, questions: [{ label?, title?, description? }] }
response.committed{ response: { messageId, finalText } }
turn.completed/turn.failed/turn.cancelled{ message? }

只有response.committed带正文,且只含最终展示正文——不含推理过程、系统提示词或工具原始结果。agent字段在某些系统维护任务里可能不存在(无可解析 Agent 时省略), 会话不存在则整条事件不发。

4. 条件表达式

条件决定「这个 Hook 这次要不要执行」。不匹配时不会报错,而是写一条skipped+ condition_not_matched的运行记录, 让「最终生效列表」保持可解释。

4.1 路径语法与白名单

条件与映射里读事件字段用path表达式,语法是固定正则:

^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$

首段必须以字母或下划线开头,后续段允许纯数字——这就是数组下标的实现方式,例如 workspaces.1.name。__proto__、constructor、$foo都会因为首段规则被判非法。

路径必须落在白名单内,白名单由三部分组成:

  1. 公共路径:schemaVersion、eventId、eventName、occurredAt、source、session、turn、agent、workspaces、primaryWorkspaceId,以及它们下面的常规字段。
  2. 按事件划分的 payload 路径,例如turn.failed允许 payload.message。
  3. 两条与事件无关的下标正则,这两条不检查事件名。

判定顺序是:正则 → 公共集合 → 两条下标正则 → 该事件的 payload 列表。两条下标正则放行的是:

workspaces.<n>.(id|name) payload.questions.<n>.(label|title|description)

因为这两条不绑定事件名,所以payload.questions.0.title在任何事件下都被判为合法路径, 哪怕该事件的 payload 根本没有questions。这是静态允许、运行时取不到的典型情形。

turn.started更特殊:它的 payload 路径列表是空数组, 所以这个事件连payload本身都读不到。想让 Turn 开始时发通知可以, 但别想在条件里判断 Turn 开始时的内容——那时确实也没有内容。

session/turn/agent/workspaces/ payload可以整对象取值, 取到的对象会原样透传。

白名单是静态的,只做语法与权限校验,不检查数组下标是否越界。workspaces.9.name静态合法,但若事件只有 1 个 workspace,运行时取到的就是 undefined。 后者是否算错误取决于用在哪:条件里退化为假,映射里抛错,模板里替换成空串。

4.2 八个操作符的真实语义

协议层支持 8 个操作符。下面这张表是实测结果,不是推断——其中三条与直觉不符,是最容易写错的地方。

表达式结果说明
eq(0,0)、eq(false,false)true标量走严格相等
eq("0",0)、eq(true,1)false没有隐式类型转换
eq(null,null)false任一侧是 object 就提前返回 false,而typeof null正是"object"
notEq(null,null)true与上一条互为反相,会把「两边都缺」误判成「不相同」
eq([1,2],[1,2])、eq({a:1},{a:1})false数组也算 object
contains("a1b",1)true数字被字符串化为"1"
contains([1,2],1)false数组经可比文本转换后为 null,contains 不支持数组
startsWith(12,1)true同样是字符串前缀语义
exists(path payload.message)true判定为「取值不为 null/undefined」
exists(path agent.missing)false路径取不到值
exists(const "x")falseexists的左侧必须是 path 表达式,用常量会被静态校验判非法
未知操作符false落到默认分支

由此得到两条实操结论:

  • 判空只能用exists,不能用eq … null——后者恒为 false,条件永远不匹配。
  • contains只做字符串包含。想匹配数组元素,只能先把数组映射成模板字符串再比较。

组合操作符and/or/not 只在协议层存在,界面配置不到(见下一节)。

4.3 界面只暴露五个叶子操作符

定义编辑器里的操作符下拉只有五项,与协议的 8 个不对等:

界面文案操作符
等于eq
不等于notEq
包含contains
前缀为startsWith
字段存在exists

编辑器载入定义时只认叶子条件(有left且没有 conditions的那种)。 用 API 或迁移脚本写进去的嵌套条件,在界面上会显示成「条件开关关闭」。

这不是「条件被删掉了」。更新走的是合并语义,编辑器省略condition 字段时服务端会保留原条件, 所以会出现「界面显示无条件、实际仍在过滤」的现象。要真正移除条件,目前没有可用路径——这是已知缺口,见第 14 章。

同一处还会丢类型:条件比较值和映射常量载入时统一转成字符串,提交时也恒为字符串。 协议允许 string | number | boolean | null四种常量,但经界面往返后只剩字符串。 想写数字或布尔常量,需要通过迁移或 API。

4.4 限制与边界

项限制在哪一层强制
and/or的条件条数1 ~ 16只在协议校验;求值与静态校验都不检查条数
条件嵌套深度没有上限递归求值,只受调用栈限制
值表达式的键必须恰好一个const/path/template三选一,协议强制
映射参数名1 ~ 120 字符协议
模板字符串至少 1 字符,无上限协议
name/description1 ~ 120 / ≤ 2000协议
timeoutMs1000 ~ 120000协议
maxAttempts/backoffMs1 ~ 10 / 0 ~ 600000协议

静态校验函数只查 5 类问题:工具引用的三个必填字符串、条件里的路径、映射里的路径、通知标题与正文里的路径。 它不查工具是否真实存在,也不查条件条数与嵌套深度。

5. 输入映射

输入映射把事件字段转成工具入参。它是这个子系统与外部世界之间的唯一数据通道,因此值得单独讲清。

5.1 三种值表达式

类型写法用途
常量{ const: "固定值" }写死一个值,例如固定 channel
路径{ path: "payload.response.finalText" }直接取事件字段,保持原始类型
模板{ template: "回答:${payload.response.finalText}" }把多个字段拼成一段文本

界面上每行是一个三元组:左边填参数名,中间选「路径 / 常量 / 模板」,右边填对应内容。 映射区上方的提示是「事件字段 → 工具入参;映射的字段将随动作离开 Spark Work」——这句话应当被认真对待, 因为映射出去的字段会真的发到外部。

path保持原始类型(对象原样透传);template永远返回字符串。

5.2 模板占位符

模板用${...}嵌入路径,正则等价于:

/\$\{([^}]+)\}/g

行为细则:

  • 占位符内的路径会先做 trim,所以${ payload.message }与 ${payload.message}等价。
  • 取值是 null 或 undefined 时替换为空串,不是字面量"undefined"。
  • 取到对象或数组时用 JSON 序列化,其余用字符串转换。
  • 整个模板总是返回字符串,不会因为占位符只有一个就还原成原始类型——这点与 path不同。
  • 不存在的路径在保存前的静态校验就会被拦下(判非法),所以线上很少见到「模板生成空串」的情况。

模板没有转义机制。反斜杠不是转义符,\${eventId} 里的占位符照样会被展开, 反斜杠原样保留。因此无法在模板里输出字面量 ${...}。

未闭合的${(没有右花括号)不会被匹配,原样输出;空占位符 ${}因为正则要求至少一个字符也不会被匹配。

5.3 映射失败会发生什么

映射求值抛错时,运行会记成failed+mapping_failed,并且不会调用动作——这一点在集成测试里有明确断言。所以映射写错不会造成「半个副作用」。

两类错误共用一个异常类型,但消息不同,都带字段名:

情形消息形态
路径不在白名单「映射路径越权:<path>」
路径合法但取不到值「映射字段 <field> 在事件中不存在」

注意一个反直觉之处:像payload.nil这种「合法前缀 + 不存在的子字段」也会被判成越权, 因为它不在白名单里。真正的「取值为 undefined」只发生在静态合法但运行时数据缺失时,例如越界的数组下标。

6. 动作:通知、提示音、调用工具

三种动作都受同一个超时约束,超时时间取自定义的timeoutMs。动作执行器本身不写数据库,只返回执行结果。

6.1 系统通知

通知的标题与正文都是值表达式,会先求值再发。两条兜底规则:

  • 标题为空或纯空白时,回退到按事件名硬编码的文案,格式是 Spark Work - 中文短语。七条映射:任务开始、需要审批、需要您的输入、回答已生成、任务完成、任务失败、任务已取消。
  • 正文为空或纯空白时,整个字段不传(而不是传空串)。

实际发送时的行为:

  • 会话窗口已聚焦时通知会被抑制——由未读角标服务统一判定,避免你在看这个会话时还弹它的通知。
  • 系统不支持通知时(Notification.isSupported()为假)记一条警告并跳过。
  • 通知本身以静音方式发送,点击后会恢复并聚焦主窗口,同时推送一条跳转事件让界面切到对应会话。

一个容易误判的点:宿主处理函数会返回布尔值表示「是否真的发出去了」, 但执行器不读取这个返回值,一律记成功。所以「通知被抑制」或「系统不支持通知」时, 运行记录仍然是succeeded。排查「为什么没弹通知」时不能只看运行状态,要去看会话是否聚焦、系统通知权限是否打开。

6.2 提示音

提示音是最简单的动作:调用 Electron 的shell.beep()。 它不接受任何自定义参数——没有音量、没有音效选择、没有自定义音频文件。 与通知同理,运行记录反映的是「动作执行器跑完了」,不代表你确实听见了声音(系统可能静音)。

6.3 调用工具与工具候选

「调用工具」是唯一会产生外部副作用的动作类型。它保存的不是工具显示名,而是一份稳定引用:

{ "sourceKind": "connector" | "custom-tool" | "tool-package", "sourceId": "…", "version": "…", // 可选 "toolName": "…", "qualifiedName": "…" }

运行时按sourceKind+sourceId+ (toolName 或qualifiedName二选一)匹配。version不参与匹配,但参与版本漂移校验。

哪些工具能被选:

风险等级可选不可选原因文案
read可选—
low-write可选—
high-write不可选「high-write 工具默认不开放给 Hook 自动调用」
destructive不可选「观察型 Hook 禁止 destructive 工具」

不可选的条目仍会列在下拉里,只是标成禁用并附上原因,所以你能看到「为什么这个工具用不了」。 除风险等级外的其他维度(幂等性、effect、版本、账号是否已连接)都不影响可选性。

下拉里的工具来自统一工具目录,调用时会带上invocationSource: 'hook'归因, 并在统一工具审计里写入 Hook 与运行的对应关系,所以事后能追出「这个外部调用是哪个 Hook 发起的」。

编辑器里的展示形态是:选项文案&lt;工具名&gt;(不可选:&lt;原因&gt;),选中后展示四个标签 「风险 / 幂等 / effect / 版本」。

工具调用会被拦在哪些情况:

错误码触发条件
trust_required绑定处于待重新授权状态,或信任哈希与当前定义不一致
tool_not_found工具目录里找不到匹配项(也可能是一次性目录快照不一致)
tool_version_changed定义里存的版本与当前工具版本都非空且不相等
policy_blocked风险等级为 destructive,或为 high-write 且未开放

失败归一化规则:网关返回失败但没给错误码时降级为action_failed; 抛出超时错误映射为timeout;其余异常映射为action_failed。

另外,宿主网关做「调用前复核」时传的上下文少于真正调用时传的,两者看到的工具目录快照可能不同。 如果你的 Hook 依赖连接器账号,可能出现「复核通过但调用时找不到工具」或反之,排查时注意这个差异。

7. 作用域与绑定授权

定义只是「模板」,必须绑定到作用域并完成授权才会真正执行。这个设计解决的是「我改了一行配置,它就能悄悄往外部发数据」的风险。

7.1 四种作用域与优先级

四个作用域,界面上的真实标签是:

作用域界面标签scopeId
application应用(全局)固定为空串
workspace项目(主 Workspace)主 workspace 的 id
agentAgentAgent id
session会话会话 id

application的scopeId被强制写成空串,这样唯一约束(hook_id, scope_kind, scope_id)才能可靠工作。

优先级是确定性的:session > agent > workspace > application。 同一个 Hook 命中多个作用域时,取优先级最高的那个作为「最终生效绑定」, 其余记入 shadowedBy仅作展示。界面上会显示成「覆盖 X/Y」。

一个容易误解的表述:四者不是继承树,而是「当前执行上下文的匹配集合」。 优先级只决定同一 Hook 的多个绑定里哪个算数,不做逐层配置合并。

实际匹配规则:application要求scopeId === '';workspace要求信封里有primaryWorkspaceId且相等;agent要求信封里有agent且 id 相等;session 直接比对会话 id。 所以信封里缺agent时,Agent 作用域的绑定不会命中。

7.2 三种绑定状态

状态界面文案含义会执行吗
active生效中已授权,且信任哈希与当前定义一致会
needs_review待重新授权定义执行属性变了,旧授权失效不会,但会留一条 blocked 记录
disabled已停用绑定被关掉不会,且不留记录

状态由「是否启用」与「是否授权」两个布尔量决定:启用且已授权是active, 启用但未授权是needs_review,未启用一律disabled(与是否授权无关)。

needs_review的绑定仍会进入队列,只是被执行前的复核拦下并记为 blocked+trust_required。 这是刻意的——让「授权缺失」在审计里可见,而不是静默不执行。

7.3 授权流程

在「作用域绑定」区点「添加绑定」时,会弹出确认框,标题是「授权执行该 Hook?」,正文列出三件事:

  1. 授权对象:Hook 名称与它监听的事件
  2. 动作:调用工具 X / 系统通知 / 提示音
  3. 将随动作外发的字段:以标签形式列出输入映射里配置的全部参数名

确认框还会写明「授权与当前定义的执行哈希绑定;之后任何执行属性(事件/条件/映射/动作/策略)变化都会使授权失效。」,确认按钮是「确认授权」。

新增绑定即自动授权并启用:点「添加绑定」会一次性带上启用标记与当前执行哈希,成功后提示「绑定已创建并授权」。 非 application 作用域必须填 scopeId,否则提示「请填写作用域对象 ID」。

后续的授权保留规则有两条路径:

  • 显式重新授权:传入的哈希等于当前定义哈希才通过。传了不匹配的哈希会被当成「明确的重新授权意图」,降级为待重新授权,而不是静默保留旧的。
  • 保留既有信任:不传授权参数、且既有绑定信任的哈希仍等于当前定义哈希时,视为继续信任。

第二条是为了让「单纯切换启用开关」不失效——开关属于非执行性变更,不该让既有授权作废。 界面上的表现是:待重新授权时显示「重新授权」按钮,其他情况显示启用开关。

绑定列表按 application → workspace → agent → session 排序显示。授权信息行显示 「授权哈希 <前 12 位>…」或「未授权」,有授权时间时追加日期。

7.4 三种「没有运行记录」的情形

排查「事件发生了但看不到记录」时,先分清是哪一类:

情形有记录吗在哪能看到
条件不匹配有:skipped+condition_not_matched运行记录面板
授权缺失或策略拒绝有:blocked+ 对应错误码运行记录面板
定义停用 / 绑定停用没有只能从定义或绑定的启用状态判断
该事件没有任何启用中的定义没有(事件都没写进 outbox)看不到

前两类是「跑了但没成功」,后两类是「根本没进队列」。这个区分能省下大量排查时间。

8. executionHash:什么改动会让授权失效

这是整个子系统最容易被写错、也最影响使用体验的一块。搞懂它,你就知道为什么「只改了个名字,授权怎么没了」这种问题不会发生——以及反过来,「只把超时从 15 秒改成 20 秒,为什么绑定全部要重新授权」是对的。

8.1 参与哈希的七个字段

执行哈希是规范化后的 SHA-256,输入只有 7 个键:

参与哈希不参与哈希
eventNameid
conditionname
action(含 target 的每个字段,因此target.version也参与)description
inputMappingenabled
timeoutMsrevision
retryPolicy(mode / maxAttempts / backoffMs)createdAt/updatedAt
concurrencyPolicy任何多传的额外键

「不参与」是编译期保证而不是运行时过滤:计算函数的入参类型就直接是这 7 个字段的 Pick 类型,id/name/enabled根本传不进去。

规范化规则:键递归排序并按字典序序列化、丢弃值为 undefined 的键、数组保持顺序、标量用 JSON 序列化。所以键的书写顺序不影响哈希,同语义定义必然同哈希。

一处细节差异:condition省略与显式null得到相同哈希,inputMapping省略与{}也相同; 但timeoutMs 省略与显式 15000 会得到不同哈希,因为它是直接透传的。 仓库内所有调用方都会在计算前补默认值,所以生产路径不受影响;只有自己手搓哈希输入的代码需要留意。

8.2 什么改动会让授权失效

更新定义时的逻辑是:算出新哈希 → 与旧的比较 → 只有变了才 +1 revision 并写新哈希,同时把该定义下所有绑定置为待重新授权。

你改了什么哈希变吗revision绑定要重新授权吗
名称、描述不变不变不用
启用开关不变不变不用
事件变+1要
条件变+1要
动作或工具引用变+1要
输入映射变+1要
超时变+1要
重试策略(任一字段)变+1要
并发策略变+1要

保存后界面会给出对应反馈:

  • 新建:「Hook 定义已创建;启用前请在「作用域绑定」中完成授权」
  • 更新且哈希变化:「执行属性变化,N 个绑定需重新授权后才会执行」

这里的N 是「被改成待重新授权」的绑定行数,并且不含已停用的绑定—— 已经被你停用的绑定不会被计入,所以 N 为 0 不代表这个定义没有绑定。

授权失效是 fail-closed 的:集成测试里明确断言,改掉timeoutMs后运行会变成blocked+trust_required,而且工具调用次数为 0—— 在重新授权之前,不会有任何外部调用发生。

9. 执行管线

9.1 四段链路

生命周期事实(SessionService / PermissionService) ↓ HookLifecycleBridge 构造信封 hook_events(outbox,幂等入队) ↓ HookDispatcher 解析绑定、建运行快照(同一事务里标 resolved) hook_runs(队列 + 调度) ↓ HookWorker 定时领取、前置复核、执行 HookActionExecutor(超时包裹下的通知 / 提示音 / 工具调用) ↓ 写终态或重新入队

几个关键性质:事件先落库再派发,派发失败会把事件释放回待处理状态而不是丢掉; 「建运行快照 + 标记事件已解析」在同一个事务里,不会出现半完成状态; 每条运行有确定性幂等,同一个事件对同一个定义只产生一条记录。

9.2 四张表

表作用关键约束
hook_definitions定义(事件、条件、动作、映射、策略、revision、execution_hash)主键id;索引(event_name, enabled)
hook_bindings作用域绑定与授权UNIQUE (hook_id, scope_kind, scope_id);hook_id外键级联删除
hook_events事件 outbox主键event_id;状态pending / resolving / resolved / failed
hook_runs运行记录与调度队列UNIQUE (event_id, hook_id);状态 8 值

所有 JSON 字段(条件、动作、映射、重试策略、信封、各类快照与摘要)都以文本列存储,共 11 个。 时间列全部是 ISO 字符串。execution_hash没有索引也没有唯一约束,它只作为授权凭据使用。

删除语义值得单独记住:

  • 删定义 → 绑定级联删除,运行记录保留(它持有定义快照,界面文案也这么写)。
  • 运行记录唯一的按时间之外的清理路径是删除会话,会按 session_id 清掉运行与事件。
  • 队列里已解析的事件保留 7 天后回收,每次最多 500 条。

界面上的删除确认会显示「将删除 N 个作用域绑定;历史运行记录(M 条)默认保留定义快照,不随定义删除。」, 其中 M 来自另一次查询,不是删除接口的返回值。

9.3 三个定时器

整个子系统由三个定时器驱动,理解它们就能解释大多数「为什么没立刻执行」的疑问:

组件间隔职责
Worker 轮询2 秒领取并执行队列里的运行;单次最多连续处理 16 条
Dispatcher 维护扫描5 秒回收过期租约、派发积压事件、按保留期清理
Compensator 补偿扫描60 秒按稳定事实源补发崩溃窗口内可能丢失的事件

除定时器外还有两个即时触发点:事件落库时会立刻叫醒派发器(一次最多 8 条), 以及启动时与重新开启总开关时各派发一次。

注意 Worker 没有事件驱动的唤醒通道:事件落库只叫醒派发器,不叫醒 Worker。 因此新入队的运行最坏要等约 2 秒才会被领取。写「实时执行」并不准确。

补偿扫描器补的是「事实已落库但事件没写进 outbox」这个很窄的崩溃窗口。 它按 turn 请求表与最终 assistant 消息重放事件,靠确定性事件 ID 与主键去重保证幂等。 两个限制:首次运行只初始化游标、不回溯历史(所以首次扫描要等 60 秒后);只覆盖 4 个事件——permission.requested与 question.requested的事实源在内存里, 官方注释明确说这两个事件不承诺崩溃不丢。

顺带解释一个历史问题:迁移 101 给agent_events(event_type, created_at)与turn_requests(created_at)建索引,正是为了这个 60 秒扫描。 迁移注释记录了当时的症状:这两条查询走全表扫描加临时排序,在主进程同步阻塞事件循环 0.7~1.4 秒, 造成界面间歇性转圈、点 Dock 图标无法置前。它不是 hook 表的索引,别混进上一节的表结构里。

9.4 租约与崩溃恢复

事件与运行都用租约防重复处理:

对象租约时长过期后的处理
事件(resolving)60 秒释放回pending,下次扫描重派
运行(running)5 分钟回收为终态outcome_unknown

两者处理方式不同,原因是风险性质不同:事件没被派发是安全的,重来一次即可; 而动作可能已经发出去了,无法确认,所以不能自动重投,只能标成结果未知让你人工判断。

启动时会做一次恢复:把过期的运行标为结果未知,并把滞留的事件重新派发。 这个过程在会话服务构建时执行,且整段包在异常隔离里——Hook 基础设施启动失败不会阻断会话服务, 只记一条错误日志。退出时也有对应的清理步骤。

运行在工作过程中会定期续租,且续租前会先给自己的在途运行续期,避免把正在跑的自己误判成僵尸。

10. 重试、超时与并发

10.1 超时

timeoutMs默认15000,可设范围 1000 ~ 120000。 三种动作都被同一个超时包裹,包括提示音和系统通知。 超时会被映射成错误码 timeout。

界面上「执行策略」折叠区默认收起,只有当你把某项改成非默认值时才自动展开。可编辑项与默认值:

控件范围默认
超时(毫秒)1000 ~ 120000,步进 100015000
最大尝试次数1 ~ 103
退避基数(毫秒)0 ~ 600000,步进 5001000
重试策略safe / keyed / unsafeunsafe
并发策略会话内串行 / 并行serial_per_session

10.2 三种重试策略

**默认是unsafe,意味着默认不自动重试。**这点与很多人的预期相反,所以「失败了一次就再没动静」是符合设计的。

策略界面文案自动重试条件
safesafe · 仅瞬态错误自动重试错误码是瞬态即可
keyedkeyed · 注入幂等键自动重试错误码是瞬态且成功注入了幂等键
unsafeunsafe · 失败不自动重试永不

三者共同还需满足「已尝试次数小于最大尝试次数」。

瞬态错误码只有两个:transient_failure与timeout。 这意味着action_failed即使在safe模式下也不会重试。 唯一会把工具异常归到transient_failure 的地方,是宿主按错误消息正则匹配网络类关键字 (连接重置、连接被拒、超时、DNS 解析失败、socket 挂断、network、fetch failed、aborted、timeout)。 非网络类的确定性失败一律归为 action_failed,不重试。

授权失效、策略拒绝这类blocked也不会自动重试——它们不是执行失败,而是根本没执行。

10.3 退避与 attemptCount

退避是指数增长:第 N 次尝试失败后的等待时间是

backoffMs × 2^(attemptCount − 1)

因为领取时就会把计数 +1,所以首次失败时计数已经是 1,退避恰好等于backoffMs; 第二次失败是 2 倍,依次类推。集成测试对这条有明确断言: 首次失败后状态回到排队、计数为 1,立刻再取一次会取不到(因为退避时间还没到),计数也保持不变。

两个与attemptCount有关、容易看错的点:

  • 它是领取计数而不是失败计数——每次被领取就 +1,用户手动重试后继续累加。
  • 运行记录里的时长是从首次开始算的(起始时间只在第一次领取时写入), 所以重试过的运行,其时长包含退避等待时间,不是最后一次尝试的耗时。排查性能问题时别把它当单次耗时。

还有一处兜底:如果 Worker 管线本身崩了(不是动作失败),会固定 60 秒后重新入队并记为transient_failure,这个重试不乘退避倍数。

10.4 幂等键:keyed 的真实前提

keyed模式需要两件事同时成立:

  1. 定义的重试策略设为keyed;
  2. 目标工具在统一工具目录里自报幂等性为keyed。

幂等键的派生方式是:

sha256(stableStringify({ eventId, hookId, action }))

它不含尝试序号,所以同一事件的所有重试复用同一个键——这正是幂等去重能成立的前提。

降级是静默的:工具没有自报keyed时,幂等键为空, 自动重试条件恒不成立,表现为「配了 keyed 但就是不重试」, 而且不会有任何错误码或提示告诉你原因。排查时先去看工具的幂等声明。

10.5 并发策略:serial 与 parallel 的真实差别

这块有一个非常容易写错的地方,先说结论:

parallel不等于「同时执行多个动作」。Worker 只有一个实例,内部是逐条串行领取并等待完成,还有重入守卫。 也就是说,同一时刻进程内最多只有 1 个 Hook 动作在执行。parallel的作用只是移除「同 Hook + 同会话」的串行闸门, 让不同 Hook、不同会话的运行可以依次快速通过,而不是真正并行跑。

两种策略的差别:

策略调度行为
serial_per_session同一 Hook + 同一会话内,存在更早入队且仍处于排队或运行中的记录时,跳过当前候选
parallel不做这个检查,直接领取

串行的判定范围是同一个 Hook 加同一个会话,不是「同一会话里的所有 Hook」。 不同 Hook 在同一会话里互不阻塞。

排队顺序用插入序(rowid)而不是时间戳,这样即使同一毫秒内插入多条也能保持稳定顺序。

一个值得注意的副作用:等待重试的运行状态是「排队」, 而串行判定把「排队」也算作阻塞者。所以一次失败进入退避等待后,会顶住同一个 Hook 在同一会话里后续的所有运行, 直到退避结束并执行完。这也是为什么把maxAttempts 调大时要留意对同会话其他触发的影响。

关于显式重试与取消的差异:自动重试会保留上次的错误码与消息; 用户手动重试则会清空错误码、错误消息、结束时间与时长。

11. 运行记录与排查

11.1 八种状态

状态界面文案含义
queued排队中已入队,等待被领取(含等待退避的重试)
running执行中已被领取
succeeded成功动作执行器正常返回
failed失败映射失败或动作失败
skipped已跳过条件不匹配(派发阶段就已判定)
blocked被阻止执行前复核拒绝(停用、待授权、工具策略)
cancelled已取消用户显式取消(仅排队中的可取消)
outcome_unknown结果未知在途被取消或租约过期——可能已产生副作用,不自动重投

调度器内部还有一个返回值字面量retry_scheduled,它不是数据库状态、也不会出现在界面上, 落库时状态是「排队中」。

11.2 面板能做什么

列表每行显示:展开箭头、状态标签、测试运行标记、事件名、时间、尝试次数、时长与错误中文标签。展开后能看到运行 ID、事件 ID、定义与修订号、会话与 Turn、错误消息,以及「输入摘要(已脱敏)」与「输出摘要(已脱敏)」两段 JSON。

操作可用条件不可用时的提示
重试状态是 失败 / 被阻止 / 已取消 / 结果未知「该运行不在可重试的终态」
取消状态是排队中「仅排队中的运行可取消」

取消不能终止正在执行的动作——只有排队中的运行能被取消。 想中断在途动作,只能关应用级总开关或退出应用,那条路径的结果是「结果未知」(见下一章)。

面板还有几个使用上的限制,值得提前知道:

  • 状态筛选只有「全部状态 + 八个状态」,没有时间范围、事件名或会话筛选,也没有分页;固定取最近 50 条。
  • 虽然接口支持按定义过滤,但面板恒为「全部 Hook」。
  • 没有实时推送:列表不会自动刷新,需要点「刷新」或操作后手动刷新。

11.3 脱敏边界

摘要两列会做脱敏与截断,规则是:

项规则
敏感键名匹配 authorization / cookie / token / secret / password / passphrase / api key / credential / private key(忽略大小写)时,值替换为***,键名保留
字符串长度超过 512 字符截断并追加…[truncated]
嵌套深度超过 6 层返回[depth-limit]
条目数量超过 64 条截断;对象被截断时额外写一个[truncated]: true

但要清楚脱敏的作用范围只有摘要两列。真实入参(映射求值后的结果)会以未脱敏形式单独存进运行记录的映射输入字段里, 而且这个字段没有任何读出口——界面不展示、域模型也不映射它,它只是留在库里。 所以不要把「摘要已脱敏」理解成「运行记录不含敏感正文」。

相应地,如果映射里包含了敏感字段,它会随动作真实发往目标工具——这是设计使然(动作正文由你的映射决定), 但意味着「把 token 映射进工具入参」是你不该做的事。

12. 应用级总开关

12.1 开关实际拦住什么

总开关存在设置里的hooks-v2 / enabled,缺省视为开启。它只在两个地方生效,且都在「领取」这一侧:

  • 派发器:关闭时不领取新的待处理事件
  • Worker:关闭时不领取新的待运行记录

它不拦的地方同样重要:

环节关闭时是否继续
事件写入 outbox继续——判据是「有没有启用中的定义」,与总开关无关
补偿扫描器继续按 60 秒重放事实
派发器维护扫描与 Worker 定时器定时器仍在跑,只是各自提前返回

由此得出一个实用结论:长期关闭总开关、但保留启用中的定义,outbox 会持续堆积。 因为清理只回收「已解析」的事件,而待处理的事件在没有派发的情况下不会被解析。 如果打算长期停用,更干净的做法是把定义停用(那样连事件都不会写)。

12.2 关闭后的行为

对象关闭后
已排队的运行保持排队,不会被取消;重新开启后按插入顺序继续执行
正在执行的动作尽力取消:中断在途动作,无法确认结果的记为结果未知,不自动重投
已发生的外部副作用不撤回

「尽力取消」和「不回滚」这两点代码注释和界面文案都写明了。界面提示是 「已暂停新的自动执行(不撤回已发生的外部副作用)」。 所以关闭总开关是「刹车」而不是「倒车」——已经发出去的请求不会因为关闭而消失。

重新开启时会立即派发一次积压事件,并恢复 Worker 领取。

13. 旧配置迁移

首次启动时会把「经典通知」的配置迁移成 Hooks V2 的内置定义,让旧用户不用重新配一遍。 迁移只在进程首次启动时执行一次,且有幂等保护。

配置来源是hooks/config,取不到时回退hooks/data——主进程的事实源是前者,后者是渲染层的历史键。

节点到事件的映射(注意第三个节点会裂成两个事件):

旧节点迁移成的事件
ask_user_questionquestion.requested
session_endturn.completed
session_failturn.failed+turn.cancelled
permission_request不迁移

permission_request明确不迁移,原因是避免与既有的权限请求回调路径重复通知。 迁移只搬 sound 与 notification 两类动作,旧节点里的两个开关都开着时会生成两个定义。

其他关键性质:

  • 定义 ID 是确定性的,形如builtin-legacy-&lt;节点&gt;-&lt;动作&gt;,所以重复执行不会产生重复定义。
  • 迁移写入的绑定自带授权(状态直接是生效中,信任哈希就是自身哈希),不需要你再确认一次。
  • 整个过程在一个数据库事务里,任一步失败就整体回滚,所有权标记保持旧值,旧通知路径不受影响。
  • 旧配置不会被删除或改写,原样保留以便回滚。
  • Agent 级配置不新建定义,只为已存在的定义写 Agent 作用域绑定;节点关闭时该绑定直接写成停用。

两个容易踩的细节:应用级配置缺enabled时按「开启」处理, 而 Agent 级配置必须显式为开启才会迁移——两处默认值不一致。 另外,没有旧配置的全新安装不会写所有权标记,这些机器上「已迁移」判定会长期为假, 经典通知区块因此会一直显示「未迁移」的提示文案。这不是故障。

界面上的「经典通知(兼容)」区块会根据所有权标记显示两种提示之一: 已迁移时说明「本区块不再生效,仅保留回滚兼容读取」,未迁移时说明两者并行执行。 该区块的四个节点各有两个开关(系统通知 / 提示音),并各有一个「测试」按钮可即时验证。 它自己写的键是hooks/data,而「是否已迁移」读的是另一个键 hooks-v2/ownership,两者不要混为一谈。

14. 已知缺口与事实边界

下面这些不是猜测,而是核对代码后确认的现状。使用前了解它们,能少走很多弯路。

14.1 策略与门控

项现状后果
high-write 工具白名单策略层有allowHighWrite选项,默认关闭,但生产组装根从未传入,界面也没有开关high-write 工具在实际使用中永久不可选,无法通过任何配置解除
tool_disabled错误码宿主网关把工具的「已启用」硬编码为真这个错误码在桌面端不可达;实际只会看到工具找不到与版本漂移两类
ambiguous_binding错误码绑定冲突时只写一行主进程警告日志,不落任何记录协议与界面文案里虽有这个码,但永远不会出现在运行记录里
permission_changed错误码没有任何产生点同样不会出现在记录里
连续失败自动暂停只在「策略拒绝」这条路径上调用;连续failed不会触发「失败 5 次自动把绑定置为待重新授权」只对 blocked 成立,动作连续失败不会自动暂停

14.2 数据与保留

项现状
失败事件永不清理保留期回收只匹配「已解析」状态;解析失败的事件会无界累积,且其错误字段没有任何读取方
运行记录没有按时间清理唯一的清理路径是删除会话
映射输入字段只写不读没有读出口——界面不展示、域模型不映射
归因字段只写不读调用关联 id 与调用 id 由 Worker 写入,但界面不可见
定义的模式版本恒为 1写入时硬编码,且不参与读取

与「不清理」相对的一面:队列里已解析的事件保留 7 天,清理操作有 10 分钟的最小间隔节流,单次最多 500 条。

14.3 协议支持但界面配不到

能力为什么配不到怎么用上
组合条件(and / or / not)编辑器只给 5 个叶子操作符迁移脚本或直接调接口
常量类型(数字 / 布尔 / null)编辑器往返统一转字符串迁移脚本或直接调接口
自定义样例信封渲染层封装只接受定义参数仅测试路径可用
按事件过滤定义列表界面调用时都不传该参数接口支持
运行记录的时间 / 事件 / 会话筛选界面只传定义与状态接口支持
high-write 白名单无开关,且组装根未接线当前不可用

另外两个 IPC 通道(校验定义、按 id 取运行)已注册并有渲染层封装,但没有任何调用方。校验能力实际由「预览」返回值覆盖,运行详情由列表返回体展开渲染。

14.4 条件无法移除

更新定义是合并语义,而合并逻辑没有任何分支能把条件置为空: 传了条件就替换,没传就保留原值,接口也不接受 null。 编辑器在关闭条件面板时会省略该字段,于是落到「保留原值」分支。

结果是:条件一旦设过就无法通过正常途径移除,界面还会显示成「条件开关关闭」, 形成「看着没条件、实际仍在过滤」的状态。规避方法是在改条件而不是删条件—— 或者删掉这个定义重建一个。这是实现层面的缺口,不是产品设计。

15. 排查表

现象先查什么
事件发生了,但一条运行记录都没有① 定义是否启用;② 绑定是否启用;③ 该事件是否有启用中的定义(没有就连事件都不写);④ 绑定作用域是否匹配当前上下文(信封里没有 agent 时 Agent 作用域不命中)
有记录,但状态是「已跳过」条件不匹配。展开看错误码是否为条件未命中,再核对条件表达式——别用eq比 null
有记录,但状态是「被阻止」看错误码:待重新授权 → 去绑定处重新授权;工具找不到 → 工具是否还在目录里;版本漂移 → 工具升级过;策略阻止 → 风险等级问题
改了定义后就不执行了看保存提示里的「N 个绑定需重新授权」。改了条件/动作/映射/超时/重试/并发都会让授权失效,重新授权即可
只改了名称,怎么也提示要重新授权不应该发生。名称与描述不参与哈希。若确实提示了,说明同时改了别的执行属性(可能是界面里顺手保存了默认值)
配了重试但失败后没有重试① 默认策略是 unsafe,确认改成 safe 或 keyed;② 错误码必须是瞬态(仅超时与网络类);③ keyed 还要工具自报幂等键
手动重试按钮点不了只有失败 / 被阻止 / 已取消 / 结果未知四种终态可重试;排队中、执行中、成功、已跳过都不可重试
想取消正在执行的运行不支持。取消只对排队中的运行有效,执行中的只能靠关总开关或退出应用
通知没弹出来① 运行记录的状态不可靠(被抑制也记成功);② 检查该会话是否正聚焦——聚焦时通知会被抑制;③ 检查系统通知权限;④ 看主进程日志里有没有「系统不支持通知」
提示音响了但记录显示失败不可能同时发生,但反过来可能:记录显示成功不代表你听见了(系统可能静音)
没配任何东西,项目里却跑了命令去项目目录看.spark/settings.json与 .spark/settings.local.json——那是 spark-engine 的另一套 shell hooks,桌面端没有界面管理它
事件发生很久才执行Worker 是 2 秒轮询且无唤醒通道,最坏约 2 秒延迟;如果还在退避等待,会按指数退避推迟
同一会话里前一个 Hook 卡住了后面的该 Hook 是会话内串行策略,且等待重试期间也会占住队列。可改成并行策略或调小重试次数
关掉总开关后数据还在涨正常。事件仍会写入 outbox,补偿器也仍在跑。要彻底停掉请停用定义
关总开关后有些记录变成「结果未知」那是尽力取消在途动作的结果。可能已产生副作用,需要人工确认后再决定是否手动重试
经典通知区块显示「未迁移」全新安装不会写所有权标记,属正常。只有当存在旧配置且启用时才会迁移并标记
界面显示无条件,但动作还是被过滤条件无法通过界面移除(见第 14.4 节)。用接口查看该定义的真实条件
工具下拉是空的工具目录不可用时会静默返回空列表,没有错误提示。检查会话服务与工具目录是否正常
运行记录面板不自动更新没有推送订阅,点「刷新」
想看历史某次运行的完整入参界面看不到——映射输入字段没有读出口。只能看已脱敏的输入摘要