11 KiB
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.tsipc-automation-operations-handlers.tsipc-browser-handlers.tsipc-budget-handlers.tsipc-content-operations-handlers.tsipc-core-admin-handlers.tsipc-core-agent-graph-handlers.tsipc-core-common.tsipc-core-handlers.tsipc-core-operations-handlers.tsipc-core-read-handlers.tsipc-core-task-graph-handlers.tsipc-core-work-graph-handlers.tsipc-credentials-handlers.tsipc-domain-common.tsipc-domain-handlers.tsipc-oauth-handlers.tsipc-onboarding-handlers.tsipc-recovery-handlers.tsipc-skill-handlers.tsipc-support.tsipc-thread-handlers.ts
所有 channel 名都是字符串字面量:ipcMain.handle("agents/list", ...)、ipcRenderer.invoke("agents/list", ...)。preload 和 main 之间无 schema 约束,renderer 里任何拼写错误都是 runtime 静默失败。
根因分析
Linus 视角:无类型约束的大面积 RPC surface 是可观测性黑洞。
当前问题:
- 字符串拼写错误:renderer 写
"agents/lsit"(typo),preload invoke 不报错,main 没有 handler → Promise 永不 resolve 或超时 - 参数 shape 漂移:main 改了 handler 接收的参数结构,renderer 还按旧 shape 发——JSON 反序列化成功但字段丢失,产出异常由业务层感知
- 返回值类型假设:renderer 期望
{ ok: true, data: ... },main 返回data裸值或null——typeof 判断错了又是静默 bug - 静态 grep 困难:改 channel 名要 grep 字符串,漏一处就断一个功能
- 新 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 计划
- Phase 1(3 天):建
src/shared/ipc-contracts.ts,先把 10-20 个最核心的 handler 迁移 - Phase 2(1 周):剩余 80+ handler 分批迁移,每批一个 PR(按 handler 文件分)
- Phase 3(2 天):命名规范化——renderer 代码统一改新 channel 名
- Phase 4(2 天):main 侧老 channel 名走 alias 层,telemetry 观察
- 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 列表归零 |
验证标准
- Typecheck:全项目
pnpm typecheck通过 - Grep 归零:
grep -rn 'ipcMain\.handle\s*(\s*"' src/main/应归零(全部走registerIpcHandler) - Renderer 零 any:renderer 层 IPC 调用处无
as any/// @ts-ignore - Runtime validation:故意给 handler 发错 payload,应被 zod parse 拒绝(不是进入业务逻辑再 crash)
- 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 走同一套契约风格,减少认知负担。