第 2 章:Monorepo 骨架与架构边界
2.1 为什么先看目录,而不是先看 Agent
Multica 的关键边界跨 Go、Next.js、Electron、React Native 和本地 CLI。若直接钻进 daemon.go,很容易把某段实现误认为全系统事实。
先看仓库骨架,能回答三个问题:
- 哪些逻辑运行在 Server,哪些必须运行在用户机器?
- 哪些前端能力能跨 Web/Desktop 共享,哪些必须由宿主实现?
- 数据类型、业务规则和 UI 组件分别应该放在哪里?
根规则集中在 CLAUDE.md,其中的依赖边界比目录名字更重要。
2.2 顶层结构
multica-0.4.10/
├── apps/
│ ├── web/ Next.js 16 Web 产品
│ ├── desktop/ Electron 主进程 + React Renderer
│ ├── mobile/ Expo / React Native 独立客户端
│ └── docs/ 产品文档站
├── packages/
│ ├── core/ Headless 业务、API、Query、Store、Realtime
│ ├── ui/ 无业务依赖的视觉原子和编辑器基础件
│ ├── views/ 可复用业务页面与复合组件
│ ├── eslint-config/
│ └── tsconfig/
├── server/
│ ├── cmd/server/ API Server 入口与组合根
│ ├── cmd/multica/ CLI / Daemon 命令树
│ ├── internal/ Server、Daemon、集成与基础设施实现
│ ├── pkg/agent/ Agent CLI 协议适配
│ ├── pkg/db/ SQL、sqlc 生成代码
│ ├── pkg/protocol/ 跨进程事件契约
│ └── migrations/ PostgreSQL 演进历史
├── deploy/helm/ Kubernetes Chart
├── docker/ 镜像构建材料
├── e2e/ Playwright 端到端测试
├── scripts/ 开发、检查、安装与生成脚本
├── Makefile
├── turbo.json
└── pnpm-workspace.yaml
这不是“后端一个包、前端一个包”的普通 Monorepo。它至少有四个运行时:Server 进程、Daemon/CLI 进程、浏览器/Electron Renderer、Mobile JS Runtime;Electron 还多一个 Node 主进程。
2.3 构建系统怎样拼接它们
TypeScript 工作区
根 package.json 使用 pnpm workspace 和 Turbo:
pnpm-workspace.yaml收纳apps/*与packages/*;- Catalog 锁定 React 19.2.3、TypeScript 5.9.3、React Query、Zustand、Zod 等共享版本;
turbo.json定义build / typecheck / test / lint的依赖传播;- Mobile 在若干根任务里被显式排除,走独立校验流程。
箭头表示“被依赖”。Mobile 被单独画出,是因为它不把 Web 组件当成可移植 UI;最多复用纯类型或无平台副作用的函数。
Go 模块
Go 代码在 server/go.mod 中形成独立模块:
- Chi:HTTP 路由;
- pgx + sqlc:PostgreSQL 与生成查询;
- gorilla/websocket:浏览器和 Daemon 实时连接;
- Redis:多节点实时中继、缓存和速率限制;
- Cobra:CLI;
- Prometheus:观测;
- AWS SDK:S3、Secrets Manager 等可选基础设施。
Server 与 CLI 最终是不同入口,但共享 internal/daemon、pkg/agent、internal/cli 等代码。
2.4 Server 内部不是严格“Controller-Service-Repository”三层
Server 大致可以这样读:
并非所有 Handler 都必须先经过 Service。简单 CRUD 可能直接调用 sqlc;需要被 HTTP、Scheduler、Channel 等多个入口复用的事务,通常下沉到 Service。
典型例子:
IssueService.Create被不同入口复用,统一编号、重复检查、附件、事件、分析和分配触发;TaskService统一队列状态和跨入口任务创建;AutopilotService同时服务手动、Cron 与 Webhook。
设计取舍是务实的:不为形式整齐制造空转层,但把“必须只有一个事实源”的复杂事务集中起来。
2.5 server/internal 的责任簇
不必逐目录背诵,可以按问题分组:
| 问题 | 主要目录 |
|---|---|
| HTTP 边界与响应 | internal/handler、internal/middleware |
| 跨入口业务事务 | internal/service |
| 事件与实时广播 | internal/events、internal/realtime、internal/daemonws |
| 本地执行 | internal/daemon、internal/daemon/execenv、internal/daemon/repocache |
| 第三方消息 | internal/integrations/channel、lark、slack |
| 外部应用能力 | internal/integrations/composio |
| 代码托管 | internal/integrations/vcs、handler/github.go、handler/vcs.go |
| 调度与后台任务 | internal/scheduler、各类 sweeper / worker |
| 权限与身份 | internal/auth、internal/attribution、中间件与 Handler guard |
| 观测 | internal/metrics、internal/analytics、internal/logger |
server/pkg 则放需要更稳定、可复用契约的部分,例如 Agent adapter、协议事件、数据库生成层和 Feature Flag。
2.6 前端三包边界
packages/core:无宿主 UI 的业务内核
Core 包含:
api/:统一 ApiClient、Zod Schema、响应降级;*/queries.ts与*/mutations.ts:React Query server state;*/stores/:Zustand view/client state;realtime/:WS 连接与缓存同步;platform/:宿主注入的存储、URL、认证、错误上报;types/:共享业务类型;- 各领域的 headless hooks 和纯逻辑。
它不能依赖 react-dom、浏览器 localStorage 或直接读取 process.env,否则 Desktop/Mobile 或测试宿主会被偷偷绑定到 Web。
packages/ui:视觉原子
UI 包提供 Button、Dialog、Tooltip、Markdown、Editor 基础组件等,不允许依赖 Core 业务对象。这样它不会因为某个 API Schema 改动而变成业务层。
packages/views:共享业务表面
Views 把 Core 数据与 UI 原子组装成 Agent、Issue、Chat、Project、Skill、Squad 等页面。
它不直接依赖 Next Router、Desktop Store 或宿主全局对象,而是通过 NavigationAdapter 和其他 Provider 获取宿主能力。
这个边界的结果是:同一 AgentsPage 可以在 Web 路由和 Electron Tab 中运行,但导航、打开新窗口、返回行为由宿主决定。
2.7 Web 是 URL 驱动的宿主
apps/web 使用 Next.js App Router:
[workspaceSlug]是 Workspace 路由事实源;- Dashboard 页面大多只是把共享 View 放入 Web Layout;
WebProviders注入 Core、Navigation、认证、运行时 URL 与国际化;- API/WS URL 在服务端布局中解析后传到客户端,避免每个业务模块读取环境变量。
Workspace 变化会调用 setCurrentWorkspace(slug, id),它不是另一个路由器,而是让非路由模块知道当前命名空间并触发持久 Store 重水合。
2.8 Desktop 是“主进程 + 可移植 Renderer”
apps/desktop 包含:
- Electron Main:窗口、Tray、更新、深链、Daemon 生命周期和原生能力;
- Preload:把最小 IPC 接口暴露给 Renderer;
- Renderer:React Router、Tab Store、共享 Core/UI/Views;
- 独立 Issue Window 等桌面特有表面。
Renderer 不能直接假设 Node API 可用;主进程也不应该承载页面业务状态。两者通过受控 IPC 交界。
Desktop 的多 Tab 语义也是为什么 Views 需要 NavigationAdapter:Web 的“跳转 URL”和 Desktop 的“复用/新开 Tab”不是同一种操作。
2.9 Mobile 为什么独立
apps/mobile/CLAUDE.md 明确把 Mobile 视为独立客户端:
- Expo Router 管理原生路由;
- React Native 组件不能复用 DOM UI;
- Mobile 自己拥有 Query Client、Zustand、认证存储和 WebSocket 生命周期;
- 只复用无平台副作用的类型/纯函数。
这看似重复,但避免把 Web 的悬浮层、键盘、滚动容器、Cookie 和 URL 语义硬塞进原生应用。
2.10 跨进程契约在哪里
跨进程数据并不只在 OpenAPI 文件中定义:
- Go HTTP Request/Response 多位于
internal/handler; - 前端对应 Schema 位于
packages/core/api/schemas.ts; - WebSocket 事件名与 Payload 基础位于
server/pkg/protocol; - Daemon Claim 响应由
handler/daemon.go组装,对应本地daemon.Task; - Agent CLI adapter 契约位于
pkg/agent/agent.go。
因此改一个字段时至少要问:
- 数据库是否需要迁移和 sqlc 再生成?
- Server Response 是否兼容旧 Daemon/Client?
- TypeScript Schema 是严格失败还是允许降级?
- WebSocket 的缓存同步是否理解新事件?
2.11 边界上的四个反模式
在 views 里直接用 Next Router
结果是 Desktop 无法复用,测试也要模拟整个 Next Runtime。正确入口是 NavigationAdapter。
在 core 里直接用 localStorage
结果是 SSR、Electron 与测试环境不一致。正确做法是宿主提供 StorageAdapter,并使用 Workspace-aware 包装。
在 Server 内直接启动 Agent CLI
结果是中心服务需要用户的代码、凭据和 Provider 登录态,也失去本地目录能力。当前边界要求由 Daemon 执行。
在 Provider adapter 之外解析协议
结果是 daemon.go 充满 Claude/Codex/Pi 分支,统一取消、用量和终态无法维护。协议差异应收敛在 pkg/agent。
2.12 怎样从目录开始读源码
按入口读,而不是按文件名排序:
- Server:
cmd/server/main.go→router.go→ 某个 Handler → Service → SQL。 - Daemon:
cmd/multica/cmd_daemon.go→daemon.New/Run→ Claim →handleTask/runTask。 - Provider:
pkg/agent/agent.go→New()→ 对应 adapter。 - Web:
app/layout.tsx→WebProviders→ Workspace Layout → View → Core Query。 - 实时:业务 Service
bus.Publish→ Listener → Hub →use-realtime-sync.ts。
2.13 本章结论
Multica 的 Monorepo 不是为了“把所有代码放一起”,而是为了维护一组明确的跨宿主契约:
- Server 拥有持久业务事实;
- Daemon 拥有本地执行与 Provider 进程;
- Core 拥有可共享的前端业务逻辑;
- UI 不知道业务;
- Views 不知道宿主路由;
- Web、Desktop、Mobile 各自处理平台生命周期。
下一章进入 PostgreSQL,看看这些边界如何被压缩成可查询、可迁移、可恢复的数据模型。