Files
AgentCompany/docs/proposals/M2-ipc-contracts.md
T

11 KiB
Raw Blame History

Proposal M2: 104 IPC handler 契约化(集中 channel 定义 + 类型化 invoke)

来源:问题.md M2(行 227-234)、附录 D(行 403-408) 状态:proposed 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs) Owner: TBD 估计工时:2 周 Blocking dependencies:无(独立于其他 proposal)

问题陈述

主进程注册的 IPC handler 总数 104(grep -c "ipcMain\.handle\|registerHandle(" 全 main 目录),分散在 22 个文件:

  • ipc-agent-coordination-handlers.ts
  • ipc-automation-operations-handlers.ts
  • ipc-browser-handlers.ts
  • ipc-budget-handlers.ts
  • ipc-content-operations-handlers.ts
  • ipc-core-admin-handlers.ts
  • ipc-core-agent-graph-handlers.ts
  • ipc-core-common.ts
  • ipc-core-handlers.ts
  • ipc-core-operations-handlers.ts
  • ipc-core-read-handlers.ts
  • ipc-core-task-graph-handlers.ts
  • ipc-core-work-graph-handlers.ts
  • ipc-credentials-handlers.ts
  • ipc-domain-common.ts
  • ipc-domain-handlers.ts
  • ipc-oauth-handlers.ts
  • ipc-onboarding-handlers.ts
  • ipc-recovery-handlers.ts
  • ipc-skill-handlers.ts
  • ipc-support.ts
  • ipc-thread-handlers.ts

所有 channel 名都是字符串字面量:ipcMain.handle("agents/list", ...)、ipcRenderer.invoke("agents/list", ...)。preload 和 main 之间无 schema 约束,renderer 里任何拼写错误都是 runtime 静默失败。

根因分析

Linus 视角:无类型约束的大面积 RPC surface 是可观测性黑洞。

当前问题:

  1. 字符串拼写错误:renderer 写 "agents/lsit"(typo),preload invoke 不报错,main 没有 handler → Promise 永不 resolve 或超时
  2. 参数 shape 漂移:main 改了 handler 接收的参数结构,renderer 还按旧 shape 发——JSON 反序列化成功但字段丢失,产出异常由业务层感知
  3. 返回值类型假设:renderer 期望 { ok: true, data: ... },main 返回 data 裸值或 null——typeof 判断错了又是静默 bug
  4. 静态 grep 困难:改 channel 名要 grep 字符串,漏一处就断一个功能
  5. 新 channel 命名漂移:core-admin / core-agent-graph / core-common 这种前缀暴露"core 是啥都不清楚"的命名漂移——审查时得打开文件才知道属于哪个域

Karpathy 视角补充:AI coding agent 写 renderer 代码时,没有 IDE 自动补全 channel 名,每次都 grep 猜一次——写错概率很高。即使写对了 channel,payload shape 也是 tribal knowledge。

设计方案

核心:单一 contract 源

新建 src/shared/ipc-contracts.ts(renderer 和 main 都能 import):

// 所有 channel 集中定义
export const IpcChannels = {
  "agents/list": {
    input: z.object({ companyId: z.string() }),
    output: z.object({ agents: z.array(AgentRecordSchema) }),
  },
  "tasks/save": {
    input: TaskRecordSchema,
    output: z.object({ taskId: z.string() }),
  },
  "approvals/resubmit": {
    input: z.object({ approvalId: z.string() }),
    output: z.object({ ok: z.literal(true) }),
  },
  // ... 104 条
} as const;

export type IpcChannelName = keyof typeof IpcChannels;
export type IpcInput<K extends IpcChannelName> = z.infer<typeof IpcChannels[K]["input"]>;
export type IpcOutput<K extends IpcChannelName> = z.infer<typeof IpcChannels[K]["output"]>;

main 侧:强制契约注册

// src/main/ipc-core-common.ts(改造)
export function registerIpcHandler<K extends IpcChannelName>(
  channel: K,
  handler: (input: IpcInput<K>) => Promise<IpcOutput<K>> | IpcOutput<K>
): void {
  const contract = IpcChannels[channel];
  ipcMain.handle(channel, async (_evt, rawInput) => {
    const input = contract.input.parse(rawInput);  // validation
    const output = await handler(input);
    return contract.output.parse(output);  // validation
  });
}

registerIpcHandler 不接受 raw string——channel 名必须是 IpcChannels 的 key。TypeScript 会在编译期拒绝 typo。

preload 侧:强制类型化 invoke

// src/preload/index.ts(改造)
async function invokeIpc<K extends IpcChannelName>(
  channel: K,
  input: IpcInput<K>
): Promise<IpcOutput<K>> {
  return await ipcRenderer.invoke(channel, input);
}

contextBridge.exposeInMainWorld("api", {
  invoke: invokeIpc,
});

renderer 侧使用

// Before
const result: any = await window.api.invoke("agents/list", { companyId });

// After
const result = await window.api.invoke("agents/list", { companyId });
// result 被推导为 { agents: AgentRecord[] }

IDE 自动补全 channel 名;input 写错类型直接红线;output 不需要断言。

命名规范化

现有 channel 名混乱(core-admin / core-agent-graph / agents/list / tasks.save 等),一次性规整:

  • Format: <domain>/<verb>(小写 / slash 分割)
  • Domain 列表固定:agents / tasks / runs / approvals / messages / documents / skills / automations / browser / credentials / oauth / onboarding / recovery / threads / companies / connectors / budgets
  • 不再有 core-* 前缀——哪个域就叫哪个域

迁移时保持旧 channel 名作为 alias 几个版本,telemetry 观察老名字消失再删。

Migration 计划

  1. Phase 1(3 天):建 src/shared/ipc-contracts.ts,先把 10-20 个最核心的 handler 迁移
  2. Phase 2(1 周):剩余 80+ handler 分批迁移,每批一个 PR(按 handler 文件分)
  3. Phase 3(2 天):命名规范化——renderer 代码统一改新 channel 名
  4. Phase 4(2 天):main 侧老 channel 名走 alias 层,telemetry 观察
  5. Phase 5(2 周后):删掉 alias,彻底移除旧名

风险与回滚

风险 触发条件 回滚方法
zod 开销 每次 IPC 都跑 parse 大 payload 场景 benchmark;必要时用更轻的 validator 或 conditional parse
renderer 类型推导失败 某个 schema 递归 / 复杂 拆 schema 或用 type assertion 临时绕过
契约漂移 有人改 schema 没改 call site typecheck 会直接报 —— 这是目标
遗漏的 channel grep 漏了某个 registerHandle Phase 1-2 分批时每批跑完 grep,剩余 channel 列表归零

验证标准

  1. Typecheck:全项目 pnpm typecheck 通过
  2. Grep 归零:grep -rn 'ipcMain\.handle\s*(\s*"' src/main/ 应归零(全部走 registerIpcHandler)
  3. Renderer 零 any:renderer 层 IPC 调用处无 as any / // @ts-ignore
  4. Runtime validation:故意给 handler 发错 payload,应被 zod parse 拒绝(不是进入业务逻辑再 crash)
  5. Telemetry:迁移期间监控 unknown-channel 事件,迁完应归零

不在范围

  • 不改 IPC 传输机制:仍用 Electron 原生 ipcMain.handle / ipcRenderer.invoke
  • 不做跨进程 pub-sub:channel 还是 request-response;事件推送走独立 webContents.send 通道
  • 不做 versioning:本次一次性切换,不做 v1/v2 并存(成本 > 收益)
  • 不引入 gRPC / protobuf:zod schema + TypeScript 足够
  • 不做权限控制:所有 channel 对 renderer 开放,权限层面走独立 proposal

参考文件清单

  • src/main/ipc-*.ts(22 个 handler 文件)
  • src/preload/index.ts(contextBridge expose)
  • src/renderer/**/*.ts(消费者,数百处 invoke 调用)
  • src/shared/types.ts(已有部分 schema,可复用)

附:为什么现在修

AI coding agent 最大的错误来源之一就是 IPC 调用——字符串 channel + any payload,一旦没查到正确的字符串就瞎猜,猜错就是静默失败,反复几次才能找对。把契约集中化以后,AI 写代码时 IDE 直接告诉它 channel 名和 payload shape,一次对。

另一个触发因素:M12(renderer state audit)要做前提必须先有 M2。当前 renderer 的状态一致性问题一部分就来自 IPC 契约不明——消息传错、payload 错、renderer 把错数据当对数据渲染。先修 M2 再做 M12。

附:gating matrix

Gate 条件 放行判断人
G1 src/shared/ipc-contracts.ts 覆盖 104 个 channel Linus
G2 所有 main 侧 handler 通过 registerIpcHandler 注册 Linus
G3 Preload invokeIpc 类型化后,renderer 层 as any 归零 Linus
G4 命名规范化后 alias 保留 2 周观察,无旧名残留 Owner
G5 Runtime zod parse 错误率监控 7 天 Karpathy

附:channel 命名规范化 before/after 示例

Before After
"core-admin:list-profiles" "profiles/list"
"core-agent-graph:save" "agents/save"
"core-task-graph:delete" "tasks/delete"
"core-work-graph:list-runs" "runs/list"
"domain:approvals/resubmit" "approvals/resubmit"
"oauth:start" "oauth/start"
"browser:take-action" "browser/action"

"core-*" 前缀彻底消失——哪个 domain 就叫哪个 domain。

附:为什么不选其他方案

方案 A(当前 proposal):集中 contract + zod runtime validation + codemod 迁移。

方案 B(仅加 TypeScript 类型不做 runtime validation):main / renderer 进程间 payload 反序列化后类型已失效——runtime 仍可能错 shape。zod 必须。

方案 C(gRPC / protobuf):Electron IPC 不需要这种重量级框架;zod 足够。

方案 D(只规范命名不做契约化):核心问题是缺契约,改名只是治标。

方案 A 一次性解决类型、命名、runtime validation 三件事。

附:zod vs 其他 validator

选项 优点 缺点
zod 类型推导原生 + 广泛使用 runtime 开销 ~5μs/parse
io-ts fp-ts 生态 学习曲线
yup 成熟 TS 推导不如 zod
typebox 速度最快 + JSON Schema API 偏底层

选 zod:项目已有 zod@4.3.6 依赖,无需新增;推导质量好。

附:Event push 通道(非 invoke)

Electron 还有 webContents.send → ipcRenderer.on 的单向事件推送。本 proposal 同样契约化:

export const IpcEvents = {
  "runs/updated": z.object({ runId: z.string(), status: RunStatusEnum }),
  "approvals/updated": z.object({ approvalId: z.string() }),
  "tasks/updated": z.object({ taskId: z.string() }),
  // ...
};

// main 侧
emitEvent<K extends IpcEventName>(channel: K, payload: IpcEventPayload<K>) {
  const validated = IpcEvents[channel].parse(payload);
  webContents.send(channel, validated);
}

// renderer 侧
subscribeEvent<K extends IpcEventName>(channel: K, handler: (payload: IpcEventPayload<K>) => void): () => void {
  const wrapped = (_evt, raw) => handler(IpcEvents[channel].parse(raw));
  ipcRenderer.on(channel, wrapped);
  return () => ipcRenderer.off(channel, wrapped);
}

Event 和 invoke 走同一套契约风格,减少认知负担。