diff --git a/docs/notes/01-system-architecture-source-deep-dive.md b/docs/notes/01-system-architecture-source-deep-dive.md new file mode 100644 index 0000000..cde3917 --- /dev/null +++ b/docs/notes/01-system-architecture-source-deep-dive.md @@ -0,0 +1,731 @@ +# Codex + TanStack Start:源码驱动的系统架构深挖 + +> 基线:`main@294ecae6` +> +> 本文不是对 `01-system-architecture.md` 的简单扩写,而是从真实源码出发,把架构概念还原成工程决策。统一学习模板:**源码位置 → 为什么需要 → 概念定义 → 当前实现 → 生产风险 → 工业级范式 → 复习检查**。 + +--- + +## 1. 从源码而不是目录树建立系统边界 + +关键调用链: + +```text +src/features/chat/components/chat-page.tsx + ↓ UI event +src/features/chat/use-chat-controller.ts + ↓ streamChat() +src/server-functions/chat.ts + ↓ server-only seam +src/server-functions/chat.runtime.server.ts + ↓ +src/server-functions/chat-stream.ts + ↓ CodexRuntime +src/server/codex/codex-app-server.server.ts + ↓ stdio JSON messages +codex app-server +``` + +响应反向经过: + +```text +Codex notification + ↓ +CodexThreadEvent + ↓ normalize +ChatEvent + ↓ adapt +ChatStateEvent + ↓ reduce +ChatState + ↓ render +React UI +``` + +这条链路说明本项目真正的架构单元不是“前端 / 后端”两个盒子,而是五种不同责任: + +1. **Presentation**:React 只展示应用状态。 +2. **Orchestration**:Controller 管理异步副作用与生命周期。 +3. **Application Contract**:`ChatEvent` 定义浏览器允许理解什么。 +4. **Runtime Adapter**:`CodexRuntime` 隔离供应商协议。 +5. **Infrastructure**:child process、stdio、本地认证、workspace。 + +### 为什么要这样分 + +因为这五层的变化频率不同: + +```text +UI 改版 高频 +React 状态结构 中频 +产品事件语义 中低频 +Codex 协议 外部变化 +进程 / OS / 认证 基础设施变化 +``` + +如果把它们直接揉在一起,任何一层变化都会向上扩散。 + +### 工业级判断 + +一个架构边界是否合理,不看文件夹名字,而看: + +```text +这一层是否只依赖比自己更稳定的抽象? +这一层是否只暴露下游真正需要的语义? +外部供应商变化是否能被限制在局部? +``` + +--- + +## 2. Browser / Server / Runtime:这是信任边界,不只是部署边界 + +### 源码 + +`src/server-functions/chat.ts`: + +```ts +export const streamChat = createServerFn({ method: 'POST' }) + .validator(validateChatRequest) + .handler(async function* ({ data }) { + try { + yield* streamNormalizedChatEvents(data, streamCodexTurn) + } catch (error) { + console.error('Codex chat stream failed', error) + yield { + type: 'error' as const, + message: 'Codex turn failed. Check the server logs for details.', + } + } + }) +``` + +这里最重要的不是 `createServerFn` 的语法,而是: + +```text +Browser 可以 import 函数形状 +≠ +Browser 可以执行 Runtime 实现 +``` + +真实的 Codex 逻辑继续放在 `*.server.ts` 和 `src/server/codex/**` 中。 + +### 为什么需要 server-only seam + +Runtime 依赖: + +- `node:child_process` +- `node:readline` +- 本机工作区路径 +- 本地 Codex 登录态 +- stdin/stdout/stderr +- 原始 Agent event + +这些都不属于浏览器信任域。 + +如果直接让 client graph 依赖 Runtime: + +```text +最轻:浏览器构建报 Node API 错误 +中等:bundle 泄漏内部实现和路径 +最重:原始协议、认证信息或工具结果越过安全边界 +``` + +### 工业级范式 + +```text +Client-importable RPC declaration + ↓ +Server-only integration seam + ↓ +Infrastructure adapter +``` + +不要仅依赖“团队约定不要 import”,而要通过文件边界、构建规则和测试使违规 import 尽早失败。 + +--- + +## 3. `CodexRuntime`:依赖倒置真正发生的位置 + +Runtime 抽象本质上是: + +```ts +interface CodexRuntime { + streamTurn(input: StreamCodexTurnInput): AsyncGenerator +} +``` + +### 定义 + +这是典型的 **Port / Adapter** 设计: + +```text +Application owns the port +Infrastructure implements the adapter +``` + +应用只要求: + +```text +给我一个 turn 的事件流 +``` + +而不要求: + +```text +必须 spawn codex +必须使用 JSON-RPC +必须经 stdio +必须叫 item/agentMessage/delta +``` + +### 为什么重要 + +未来 Runtime 可以变成: + +```text +CodexAppServerRuntime ─┐ +ClaudeRuntime ├─> CodexRuntime-like application port +PiRuntime ┤ +RemoteRuntime ┘ +``` + +真正可替换的前提不是“以后再重构”,而是现在 UI 和 Application 层没有偷偷依赖供应商 shape。 + +### 生产避坑 + +接口过薄也可能失去语义。如果未来需要: + +- interrupt +- resume +- history read +- capability negotiation +- approval +- multi-agent + +不要不断给 `streamTurn()` 塞可选参数。更好的方向是把 Runtime 能力显式建模: + +```text +ThreadPort +TurnPort +InterruptPort +HistoryPort +CapabilityPort +``` + +按真实演进拆分,而不是提前设计一个巨型接口。 + +--- + +## 4. `ChatEvent`:application-owned contract 为什么是架构核心 + +### 错误方案 + +如果 React 直接写: + +```ts +if (event.method === 'item/agentMessage/delta') { + // update UI +} +``` + +前端就已经绑定 Codex。 + +协议一旦改名、加层级、换 Runtime,UI 全部跟着变化。 + +### 当前设计 + +```text +Codex protocol + ↓ Runtime mapping +CodexThreadEvent + ↓ normalizer +ChatEvent + ↓ client adapter +ChatStateEvent + ↓ reducer +ChatState +``` + +这其实连续建立了两层 Anti-Corruption Layer。 + +### `ChatEvent` 的三个职责 + +**1. 稳定应用语义** + +```text +assistant.started +assistant.delta +assistant.completed +activity.started +turn.completed +error +``` + +这些是产品语义,不是供应商语义。 + +**2. 数据最小化** + +浏览器不需要看到: + +- reasoning 原文 +- command 全量 stdout/stderr +- MCP 原始 arguments/result +- 本地路径 +- 子进程错误细节 + +**3. 兼容多 Runtime** + +多个 Provider 只需要各自归一化成同一 `ChatEvent`。 + +### 工业级范式 + +Application contract 应遵守: + +```text +最小 +稳定 +可版本化 +可测试 +默认不暴露敏感字段 +不携带供应商特有对象 +``` + +不要把“第三方 event type 原样拷贝一份 TypeScript union”误认为 application contract。 + +--- + +## 5. Thread / Turn / Item:领域模型必须与 UI Message 分离 + +正确关系: + +```text +Thread +├── Turn +│ ├── reasoning item +│ ├── command item +│ ├── tool item +│ └── agentMessage item +└── Turn +``` + +而 React 常见的数据结构是: + +```text +messages[] +``` + +二者不是一回事。 + +### 为什么不能把 Thread 当 `Message[]` + +因为一个 Turn 以后可能包含: + +- 0 个或多个 assistant message +- 多个 tool activity +- interrupted 状态 +- usage +- approval +- files changed +- runtime error + +所以: + +```text +Message = UI projection +Turn = Agent work lifecycle +Thread = Long-lived Agent context +``` + +### 生产价值 + +提前分清以后,做下面功能时不会推翻模型: + +```text +Stop / Interrupt +Retry turn +Branch conversation +Tool timeline +Replay +Multi-agent handoff +Thread history +``` + +--- + +## 6. Controller / Reducer / UI:副作用与确定性状态的分层 + +当前: + +```text +useChatController + - RPC + - Date.now() + - localStorage + - async iterator + - generation guard + +chatReducer + - state + action -> next state + +ChatPage + - render + DOM effect +``` + +这是比“组件拆小”更重要的分层。 + +### `useReducer` 为什么适合这里 + +流式 Agent UI 是一个事件驱动状态机: + +```text +idle + ↓ turn.started +running + ├─ assistant.started + ├─ assistant.delta × N + ├─ activity.* + └─ turn.completed / error +``` + +如果全部用分散 `setState`: + +```text +setMessages +setStatus +setError +setActivities +setThreadId +``` + +跨状态不变量很难集中维护。 + +Reducer 则把状态变化变成显式 event transition。 + +### 当前值得修正的源码反例 + +`chat.reducer.ts` 的 fallback 分支仍直接执行: + +```ts +createdAt: Date.now() +``` + +这意味着 reducer 不再完全确定: + +```text +same state + same action +可能得到不同 result +``` + +更好的设计是让 adapter/controller 在 reducer 外注入时间。 + +### 原则 + +> Reducer 只转换事实,不创造事实。 + +时间、随机 ID、网络、storage、日志都属于 reducer 外部。 + +--- + +## 7. `useCallback`、`useRef`、闭包:不要把性能 API 与正确性混为一谈 + +`useChatController()` 当前: + +```ts +const stateRef = useRef(state) + +useEffect(() => { + stateRef.current = state +}, [state]) + +const sendMessage = useCallback(async (content: string) => { + if (stateRef.current.status === 'running') return + // ... +}, []) +``` + +### `useCallback` 的定义 + +它缓存的是: + +```text +function identity +``` + +不是函数执行结果。 + +它真正有价值的场景通常是: + +- callback 传给 memoized child +- callback 作为其它 Hook dependency +- 外部系统要求稳定 handler identity + +不是“所有函数都包一下性能更好”。 + +### 为什么这里需要理解 stale closure + +空依赖 callback 会捕获首次 render 的值。 + +所以代码没有直接读: + +```ts +state.status +``` + +而是读: + +```ts +stateRef.current.status +``` + +这是典型 latest-value ref 模式。 + +### 风险 + +当越来越多状态被塞进 `stateRef.current`,代码会绕开 React dependency model。 + +工业级判断: + +```text +先问 callback 为什么必须稳定 +如果没有明确消费者要求稳定 +优先使用普通函数/正确依赖 +不要为了 [] 依赖而引入一堆 ref +``` + +--- + +## 8. `useMemo`:为什么当前项目没有强行使用反而是正确的 + +### 定义 + +```ts +const value = useMemo(() => compute(input), [input]) +``` + +它缓存的是**计算结果**。 + +主要解决: + +1. 昂贵纯计算重复执行; +2. 下游依赖 value reference identity。 + +### 它不是什么 + +`useMemo` 不是: + +- 业务正确性机制 +- 永久缓存 +- 所有派生值的默认写法 +- 自动让代码更快的开关 + +例如: + +```ts +const isRunning = status === 'running' +``` + +没有理由写成: + +```ts +const isRunning = useMemo(() => status === 'running', [status]) +``` + +memo 本身也有 dependency tracking 和 cache 管理成本。 + +### 工业级范式 + +```text +先写直接纯计算 + ↓ +Profiler / measurement + ↓ +确认热点或引用稳定问题 + ↓ +局部 useMemo + ↓ +再次测量 +``` + +对于本项目,更值得先关注的是高频 token delta 触发的整体 render 次数,而不是对廉价派生值做微优化。 + +--- + +## 9. 持久化边界:UI transcript 不是 Agent truth + +`chat.storage.ts` 只持久化: + +```ts +{ + threadId, + messages, +} +``` + +没有持久化: + +```text +status +activities +error +runtime process +raw events +``` + +这是正确的最小持久化边界。 + +### 为什么 + +刷新后真正需要恢复的是: + +```text +用户可见 transcript ++ +继续连接 Runtime thread 的 threadId +``` + +而 `running/error/activity` 属于瞬时状态。 + +### 版本 envelope + +源码使用: + +```ts +{ + version: CHAT_STORAGE_VERSION, + conversation +} +``` + +这是一个很值得保留的生产范式。任何长期客户端持久化都应默认考虑 schema evolution,而不是直接 `JSON.stringify(state)`。 + +--- + +## 10. V0 read-only:安全必须是组合属性 + +Runtime 固定: + +```text +approvalPolicy: never +sandbox: read-only +networkAccess: false +``` + +Workspace 还通过: + +```text +realpath +relative +allowedRoot +``` + +把浏览器提交的路径限制在允许根目录内。 + +Server Function 最后又做错误脱敏: + +```text +server log = 详细错误 +browser event = 通用错误信息 +``` + +因此安全不是一个 `readOnly=true`: + +```text +Capability restriction ++ Path confinement ++ No interactive escalation ++ Data minimization ++ Error sanitization +``` + +### 工业级原则 + +> Agent execution security 与 data exposure security 是两条独立边界。 + +Sandbox 防止 Agent 做危险事;Application Contract 防止敏感数据进入浏览器。缺一不可。 + +--- + +## 11. 调试路径:永远沿边界定位,不要从 UI 猜 Runtime + +推荐顺序: + +```text +1. UI 是否触发 sendMessage +2. Controller 是否 dispatch turn.started +3. ServerFn validator 是否通过 +4. streamTurn 是否启动 +5. app-server initialize 是否成功 +6. thread/start 或 resume 是否成功 +7. turn/start 是否成功 +8. 是否收到 item/agentMessage/delta +9. normalizer 是否产出 ChatEvent +10. adapter 是否产出 ChatStateEvent +11. reducer 是否正确 fold +12. UI 是否正确 render +``` + +对每一层只问两个问题: + +```text +输入是什么? +输出是什么? +``` + +这比“多打几个 console.log”更容易形成可重复的排障方法。 + +--- + +## 12. 架构演进方向 + +当前 V0 可以自然演进为: + +```text +V0 process-per-turn + read-only + ↓ +显式 Abort / interrupt + ↓ +长连接 Runtime supervisor + ↓ +结构化 Tool/Command timeline + ↓ +server-side durable conversation store + ↓ +multi-runtime adapter + ↓ +capability negotiation / approval policy + ↓ +multi-agent orchestration +``` + +演进时应守住三条不变量: + +1. Browser 不理解供应商原始协议。 +2. Application event contract 由应用自己拥有。 +3. Runtime 权限永远由服务端决定。 + +--- + +## 13. 复习检查表 + +- [ ] 能画出 Browser → ServerFn → Runtime → app-server → Event → Reducer 的完整链路。 +- [ ] 能解释 `*.server.ts` 为什么是信任边界。 +- [ ] 能区分 Thread、Turn、Item、UI Message。 +- [ ] 能解释 `CodexRuntime` 为什么是 Port,而 `CodexAppServerRuntime` 是 Adapter。 +- [ ] 能解释 `ChatEvent` 为什么不能等于 Codex 原始 event。 +- [ ] 能区分 Transport Contract 与 State Contract。 +- [ ] 能说明 reducer 为什么不应调用 `Date.now()`。 +- [ ] 能解释 `useCallback` 缓存 function identity,而 `useMemo` 缓存 value。 +- [ ] 能说明 latest-value ref 的价值与代价。 +- [ ] 能解释 browser transcript 为什么不是 Agent thread truth。 +- [ ] 能说明 read-only sandbox 与数据脱敏为什么是两条安全线。 +- [ ] 能沿完整链路定位“UI 不流式”的故障层。 + +--- + +## 14. 思考题 + +1. 如果把 `ChatEvent` 删除,让 React 直接消费 `CodexThreadEvent`,第一年和第三年的维护成本分别会发生什么? +2. 如果未来一个 Turn 同时有两个 assistant item,当前 `messages[]` 模型是否仍然成立? +3. `generationRef` 能阻止旧流污染 UI,但为什么它不等价于真正的 Runtime cancel? +4. 如果 Runtime 从 process-per-turn 改成长连接 supervisor,哪些边界应保持完全不变? +5. 如果要开放文件写入,除了把 sandbox 改成 writable,还需要新增哪些 capability、approval、audit 和 UI 边界? +6. 什么证据出现时,你才会在本项目里引入 `useMemo`?