跳到主要内容

第 2 章:Monorepo 骨架与架构边界

2.1 为什么先看目录,而不是先看 Agent

Multica 的关键边界跨 Go、Next.js、Electron、React Native 和本地 CLI。若直接钻进 daemon.go,很容易把某段实现误认为全系统事实。

先看仓库骨架,能回答三个问题:

  1. 哪些逻辑运行在 Server,哪些必须运行在用户机器?
  2. 哪些前端能力能跨 Web/Desktop 共享,哪些必须由宿主实现?
  3. 数据类型、业务规则和 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/daemonpkg/agentinternal/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/handlerinternal/middleware
跨入口业务事务internal/service
事件与实时广播internal/eventsinternal/realtimeinternal/daemonws
本地执行internal/daemoninternal/daemon/execenvinternal/daemon/repocache
第三方消息internal/integrations/channellarkslack
外部应用能力internal/integrations/composio
代码托管internal/integrations/vcshandler/github.gohandler/vcs.go
调度与后台任务internal/scheduler、各类 sweeper / worker
权限与身份internal/authinternal/attribution、中间件与 Handler guard
观测internal/metricsinternal/analyticsinternal/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 文件中定义:

因此改一个字段时至少要问:

  1. 数据库是否需要迁移和 sqlc 再生成?
  2. Server Response 是否兼容旧 Daemon/Client?
  3. TypeScript Schema 是严格失败还是允许降级?
  4. 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 怎样从目录开始读源码

按入口读,而不是按文件名排序:

  1. Server:cmd/server/main.gorouter.go → 某个 Handler → Service → SQL。
  2. Daemon:cmd/multica/cmd_daemon.godaemon.New/Run → Claim → handleTask/runTask
  3. Provider:pkg/agent/agent.goNew() → 对应 adapter。
  4. Web:app/layout.tsxWebProviders → Workspace Layout → View → Core Query。
  5. 实时:业务 Service bus.Publish → Listener → Hub → use-realtime-sync.ts

2.13 本章结论

Multica 的 Monorepo 不是为了“把所有代码放一起”,而是为了维护一组明确的跨宿主契约:

  • Server 拥有持久业务事实;
  • Daemon 拥有本地执行与 Provider 进程;
  • Core 拥有可共享的前端业务逻辑;
  • UI 不知道业务;
  • Views 不知道宿主路由;
  • Web、Desktop、Mobile 各自处理平台生命周期。

下一章进入 PostgreSQL,看看这些边界如何被压缩成可查询、可迁移、可恢复的数据模型。


上一章:从 Agent 工具到托管 Agent 操作系统
下一章:领域模型、PostgreSQL 与 sqlc