dsh 插件开发学习

30-外部事件触发会话

读完这篇你能:知道一条外部事件从进门到变成一个会话,中间经过哪三个角色;明白它为什么不是消息队列,以及”重复交付开出重复会话”意味着你要自己做什么。

前置:29-接入 MCP 服务。约 30 分钟。

示例代码:dsh-webhook-lab

DSH 版本:0.2.0-rc.2

29-接入 MCP 服务是”你问它答”:模型要用了才去调工具。这一篇讲反过来——外面发生了什么,让它自己触发一次分析。

PR 被打开了、告警响了、工单状态变了。这些事件不需要有人坐在那里问,它们应该自己变成一个会话。

目标

  • 分清 webhook 这条缝的三个角色各拥有什么。
  • 理解 fire-and-forget 分发意味着哪些能力不存在。
  • 会写一条规则,并知道它要自己负责哪两件事。
  • 知道一次会话创建按什么顺序发生、中途失败会怎样。

DSH 设计

webhook(网络钩子)是一种常见约定:外部系统出了事——代码仓库来了个新 issue、监控告警响了——它主动向你暴露的一个网址 POST 一份数据。DSH 把「收到这样一份数据」到「开出一个会话去处理它」之间的路切成三个角色。这里的会话(session)指一段有持久记录、由模型持续推进的对话。

先看角色怎么分。三个角色的划分首先是信任边界,其次才是功能分工。适配器(adapter)面对公网投递,拥有身份验证:先确认数据包没被中间人改过(验签),再解析内容。规则(rule)是受信任的程序化代码,跑在自己的进程里,拿到一份已验证的数据后决定「这条要不要管」,严格程度由写规则的人自己负责。运行时(ctx.webhookRuntime)拥有回调的生命周期与会话创建,是唯一被允许把一份投递变成会话的角色。这条边界的好处直接可见:新接一个事件来源只需要新写一个适配器,运行时和已有规则都不用动。

再看分发方式。它是 fire-and-forget(发了不等),这是刻意的取舍:这是一条投递通道,不是工作流引擎。运行时分发一份投递时,先快照当下匹配的规则,再各自独立调度,并在任何回调结束之前就返回。它没有队列、重试、去重、执行状态、崩溃重放,也没有完成结果——这些能力每一样都意味着持久化投递、定义重放语义、回答「处理完没有」,每一样都是另一个子系统的体量。代价直接写在账上:同一份投递重复到达,可能开出重复会话;想幂等、想重试、想知道办完没有,都得在规则里自己做。比如防重,最常见的写法是规则自己记一笔账:

const seen = new Set()
// 规则的 run 里:见过的交付直接说「不管」
if (seen.has(delivery.deliveryId)) return null
seen.add(delivery.deliveryId)

运行时唯一的活动账本是一份随进程消失的私有记账。进程一重启,「正在处理中」这回事就没有了。

最后看分发之后的事。会话创建完全复用普通会话语义,因为特殊化没有收益。规则说「要管」之后,运行时建出来的就是一个普通根会话:普通的工作目录、普通的持久化、普通的生命周期,和人在界面里开的会话同款。触发它的第一条消息也只是一条普通的用户角色消息,只是额外带着「来自 webhook」的来源信息。消息被会话收件箱接纳的那一刻就算提交,运行时不做特殊刷写、不等待对话轮次,之后的一切都走普通会话的持久化与生命周期。这条缝因此一行自己的会话管理代码都不用写。

mermaid 图表

把信任边界放在角色之间、把「投递之后的事」推回给普通会话——这条缝因此能保持只有「登记规则」和「分发投递」两个方法的体量。

三个角色

webhook 子系统做的一件事是:把已通过身份验证的外部交付,转换成可选的普通根会话。

“可选的”是关键——交付进来不代表一定要开会话,这中间有一道判断。

三个角色,边界划得很清楚:

mermaid 图表
角色 拥有什么
提供方适配器 身份验证、通用 JSON 接收
受信任的程序化规则 条件判断、外部调用
ctx.webhookRuntime 回调生命周期、基于 Workspace 的会话创建

注意”受信任的程序化规则”这个措辞:规则里可以执行任意代码。这不是一个沙箱化的插件点,它是你自己写的、跑在你进程里的代码——所以条件判断的严格程度完全取决于你。

交付与规则

一份已验证的交付长这样:

interface VerifiedWebhookDelivery<K> {
  readonly kind: K
  readonly source: WebhookSourceId
  readonly deliveryId: WebhookDeliveryId
  readonly event: WebhookEventOf<K>
  readonly receivedAt: number
}

WebhookRuleId、WebhookSourceId、WebhookDeliveryId 都是不透明字符串,各自有一个同名的品牌工厂函数(规则的 id 写 WebhookRuleId('...'),不能直接给裸字符串)。关于交付 id 有一句必须知道的定义:

交付 id 仅用于来源信息:runtime 既不存储也不对它去重。

也就是说这个 id 是给你做溯源用的,不是给你做幂等的。后面还会回到这一点。

WebhookEventMap 可以按提供方种类合并扩展,WebhookEventOf<K> 会挑出已知的提供方事件、否则接纳通用无损 JSON——这个设计有一个直接好处:树外的适配器不需要修改 runtime 包。你自己写一个内部系统的适配器,不用去动 DSH 的代码。

规则本身只有三个字段:

interface WebhookRule<K> {
  readonly id: WebhookRuleId
  readonly kind: K
  run(
    delivery: Readonly<VerifiedWebhookDelivery<K>>,
    signal: AbortSignal,
  ): WebhookSessionRequest | null | Promise<WebhookSessionRequest | null>
}

run 返回 null 表示”这条交付我不管”;返回一个 WebhookSessionRequest 才是”请开个会话去处理它”;它也可以是 async 的。

有一句关于 signal 的话要划下来:异步工作如果应该在注册卸载时停止,规则就必须自己观察 signal。runtime 提供了这个信号,但不会替你检查——规则里那些 await 之后如果不看 signal.aborted,卸载之后它们还会继续跑。

分发:fire-and-forget

这是这条缝最容易被误读的地方。

dispatch() 做三件事:快照匹配的规则、彼此独立地调度每一个、在任何回调结算之前返回。

第一条”快照”意味着规则列表在分发那一刻就定下来了。第三条是字面意思:调用方拿到控制权时,回调还在跑。

然后是那份”没有”清单,值得逐条读:

runtime 没有队列、重试、去重、执行状态、崩溃重放、Agent 状态监听器或完成结果。

以及它的直接后果:

重复交付可能创建重复 Session。

这两句合起来定义了这个子系统的性格:它是一条投递通道,不是一套工作流引擎。想要”同一条交付只处理一次”,得自己在规则里做幂等(比如按业务 id 落一个标记);想要”处理失败后重试”,得自己在规则里重试;想要”知道处理完了没”,得自己找个地方记。

唯一的活动账本是私有的 teardown 记账,而且随进程消失——进程一重启,那些”正在处理中”的信息就没有了。

失败处理有一个细节值得记:抛出与拒绝按规则分别被包含。一条规则炸了,不会影响同一批里别的规则。这是对的——一批交付里某一条处理不了,不该牵连其他的。

注销这边做得很干净:注册的 disposer 会先移除规则,再中止并排空活动调用。顺序很重要——先摘掉,后续交付就不会再进到正在卸载的代码里;再排空,已经在跑的那几个能被收住。

会话创建

WebhookSessionRequest 要求的字段:绝对 workspacePath、标题、文本提示词、agent preset、permission preset。可选 model 用来指定明确的提供方/模型路由与输出 token 上限;省略时它会快照包含推理强度的完整当前部署选择,直到首个请求记下持久 header。

顺序上,非 null 的结果会在异步预检之前先生成快照,然后:

  1. 验证 permission preset 与 agent preset;
  2. 解析或创建规范 Workspace;
  3. 创建 Session(cwd 等于 Workspace 路径)的 Agent;
  4. 发布之前挂载所选 agent preset;
  5. 应用权限、标题与初始 follow-up 之前,持久附加 Session。

follow-up 是普通的持久 user-role 消息,只是带着 source.kind: "webhook" 和提供方/来源/交付/规则这些来源信息。它的 inbox 插入被接受时提交 webhook 操作。runtime 不执行特殊 flush,也不等待轮次——之后走的就是普通的会话持久化与 Agent 生命周期。

中途失败的处理分两段:

  • 附加失败:在提示词出现之前释放新 Agent。
  • 附加之后、提示词被接纳之前失败:尝试脱离 Workspace 并释放 Agent,而且不会取代原始错误。

还有一个容易被当成 bug 的行为:预检期间自动创建的 Workspace 会保留。理由是——另一个并发调用者可能已经在用它了。删掉一个可能正被别人使用的 Workspace,代价比留一个空的更大。

mermaid 图表

GitHub 适配器

@deepseek-ai/dsh-webhook-github 是一条现成的适配器,做法值得看一遍:

  • 在注入的 WebServer 上注册精确路由;
  • 每次请求解析凭据引用(不是启动时解析一次);
  • 在解析之前验证 body 是未改动的 application/json——先确认东西没被中间人动过,再解析;
  • 在内存分发之后立即返回 202。

它的规范化事件保证是一个已签名的无损 JSON 对象。但注意下一句:规则负责验证自己消费的事件特定字段。

也就是说”已签名”回答的是”这确实是 GitHub 发来的、内容没被篡改”,不回答“这个 action 是 opened 还是 closed“、”issue.title 是不是字符串”。业务字段的校验在规则这一侧。

最后一条是个不错的工程习惯:官方评审指南把这条路由挂在一个隔离的第二个 WebServer 上,因此暴露 webhook 入口不会暴露浏览器 API。webhook 入口天生是”公网可达”的,浏览器 API 不是——两者混在同一个监听上,等于让前者替后者开门。

一条规则

demo dsh-webhook-lab 是一条”新开 issue 就去看一眼”的规则:

export function apply(ctx: Context): void {
    // register 本身就是 effect:注销时会先移除规则,再中止并排空本规则的活动调用,
    // 所以后续交付不会进到正在卸载的代码里。
    ctx.effect(() => ctx.webhookRuntime.register({
        id: RULE_ID,
        kind: 'github',
        run: (delivery, signal) => {
            const issue = readIssueOpened(delivery)
            // 返回 null 表示「这条交付我不管」,runtime 不会做任何事。
            if (issue === undefined) return null

            // 异步工作如果应该在注销时停下,就必须自己观察 signal。
            if (signal.aborted) return null

            // 非 null 的结果 = 请 runtime 开一个会话去处理它。
            // agentPreset / permissionPreset 必须是这个 profile 里真实注册过的 id。
            return {
                workspacePath: process.cwd(),
                title: `Issue #${issue.number}: ${issue.title}`,
                prompt: `仓库里新开了一个 issue:${issue.title}。去看一眼相关代码,给出处理建议。`,
                agentPreset: 'standard',
                permissionPreset: 'read-only',
            }
        },
    }), 'dsh-webhook-lab: rule')
}

真正的判断在 readIssueOpened 里——这就是”规则负责验证自己消费的事件特定字段”:

function readIssueOpened(delivery: { event: unknown }): { number: number, title: string } | undefined {
    // GitHub 适配器把事件规范化成 { name, payload }:name 是 X-GitHub-Event 的名字,
    // payload 是验签后的原始 JSON 对象。具体字段的校验是规则的活。
    const { name, payload } = delivery.event as {
        name?: unknown
        payload?: { action?: unknown, issue?: { number?: unknown, title?: unknown } }
    }
    // 规则负责校验自己消费的事件特定字段——runtime 只保证它是无损 JSON。
    if (name !== 'issues') return undefined
    if (payload === undefined || payload.action !== 'opened') return undefined
    if (typeof payload.issue?.number !== 'number' || typeof payload.issue.title !== 'string') return undefined
    // 标题是人写的,掏空它是为了只留一句可读的提示词素材。
    const title = payload.issue.title.trim()
    if (title === '') return undefined
    return { number: payload.issue.number, title }
}

这几层判断各拦一类东西:name 与 action 拦”不是我们要的事件”,类型检查拦”字段形状不对”,trim() 后判空拦”标题是空白”——最后一层看起来多余,但它是唯一能避免开出一个标题为空的会话的地方。

mermaid 图表

验证

cd code/dsh_plugin/dsh-webhook-lab && ./install.sh

三步,第三步专门验证”它不是队列”:

# 1. 配置对不对(三行:runtime、github 适配器、规则)
dsh --profile test --dump-config | grep -A3 'id: webhook-lab'

# 2. 起一次 web,向 webhook 路由投一份已签名的 issue opened 交付
#    适配器要求 x-hub-signature-256 / x-github-delivery / x-github-event 三个头,
#    密钥走凭据引用 DSH_GITHUB_WEBHOOK_SECRET。
export DSH_GITHUB_WEBHOOK_SECRET=lab-secret
dsh --profile test web --no-open &
BODY='{"action":"opened","issue":{"number":1,"title":"登录页在 Safari 下白屏"}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$DSH_GITHUB_WEBHOOK_SECRET" | awk '{print $NF}')"
curl -i -X POST http://127.0.0.1:3080/github \
  -H 'content-type: application/json' \
  -H 'x-github-event: issues' \
  -H 'x-github-delivery: lab-1' \
  -H "x-hub-signature-256: $SIG" \
  -d "$BODY"

你应当先收到 202(分发已经发生、回调还没结算),随后才看到新会话出现。

# 3. 同一个交付再 POST 一次

你会得到第二个会话。这不是 bug——runtime 不去重,它只负责把已验证的交付发给规则。要防重,就在规则里按 delivery.deliveryId(或更可靠的业务 id,比如 issue 编号)自己记一笔。

注意事项

  • 它不是消息队列。 没有队列、重试、去重、执行状态、崩溃重放、完成结果。这几样要哪个都得自己实现。
  • 重复交付会开出重复会话。 交付 id 只用于溯源,不参与去重。防重写在规则里。
  • 规则是受信任的任意代码。 它跑在你的进程里,不是一个被沙箱隔开的插件点。条件判断的严格程度完全取决于你。
  • signal 要自己看。 runtime 提供它,但不会替你在每个 await 之后检查。不检查的异步工作在卸载之后还会继续跑。
  • dispatch() 在任何回调结算前返回。 别把它当成”处理完了”的信号;成功路径上你会先拿到 202,然后业务在后台发生。
  • 事件字段是规则的活。 适配器只保证”已签名且是无损 JSON”,不保证业务字段的形状与取值。
  • 注销顺序是有意的。 先移除规则再排空活动调用——反过来的话,卸载过程中还会有新交付进来。
  • 中途失败的处理分两段。 附加失败释放 Agent;附加之后、提示词接纳之前失败则尝试脱离 Workspace 并释放 Agent,且不取代原始错误。
  • 自动创建的 Workspace 不会因为失败被删掉。 因为并发的另一个调用者可能已经在用。这是刻意的取舍。
  • webhook 入口要和浏览器 API 分开监听。 前者天生公网可达,后者不是。挂在同一个监听上等于让前者替后者开门。