feat: bootstrap boss control plane prototype
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.boss-data
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Boss
|
||||||
|
|
||||||
|
Boss 是一个面向多设备开发协作的 agent control plane。
|
||||||
|
|
||||||
|
它的目标是让用户通过对话入口或独立控制台,持续管理多台 Windows、Mac 上的编码代理,支持任务拆分、实时进度、需求变更、审批和审计。
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
当前仓库已经完成第一轮产品设计文档:
|
||||||
|
|
||||||
|
- [文档总览](./docs/README.md)
|
||||||
|
- [竞品对比](./docs/competitor-comparison.md)
|
||||||
|
- [系统架构](./docs/system-architecture.md)
|
||||||
|
- [MVP 功能清单](./docs/mvp-feature-plan.md)
|
||||||
|
- [技术选型](./docs/technical-selection.md)
|
||||||
|
- [消息协议与状态机](./docs/message-protocol-and-state-machine.md)
|
||||||
|
- [实施路线图](./docs/implementation-roadmap.md)
|
||||||
|
|
||||||
|
并且已经补入首版可运行原型:
|
||||||
|
|
||||||
|
- Fastify API
|
||||||
|
- 文件持久化状态存储
|
||||||
|
- SSE 实时事件流
|
||||||
|
- Web 控制台
|
||||||
|
- `boss-worker` 模拟执行器
|
||||||
|
|
||||||
|
## 当前推荐方向
|
||||||
|
|
||||||
|
- 主控面:Web 为主,聊天入口为辅
|
||||||
|
- Manager:Codex
|
||||||
|
- 设备侧 worker:Codex CLI + Claude Code
|
||||||
|
- 工具层:MCP
|
||||||
|
- 调度:持久队列或工作流引擎
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
建议直接开始:
|
||||||
|
|
||||||
|
1. 建立 Web 控制台和后端骨架
|
||||||
|
2. 实现 `boss-worker` 注册与心跳
|
||||||
|
3. 打通会话、任务树和子任务分发
|
||||||
|
4. 接入审批和中途重规划
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http://127.0.0.1:43210
|
||||||
|
```
|
||||||
|
|
||||||
|
另开终端启动 worker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run worker -- --name win-a --os windows --capability terminal --capability browser
|
||||||
|
npm run worker -- --name win-b --os windows --capability terminal --capability test
|
||||||
|
npm run worker -- --name mac-a --os macos --capability terminal --capability test --capability browser
|
||||||
|
```
|
||||||
42
docs/README.md
Normal file
42
docs/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Boss 项目设计文档
|
||||||
|
|
||||||
|
更新日期:2026-03-23
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
- [竞品对比](./competitor-comparison.md)
|
||||||
|
- [系统架构](./system-architecture.md)
|
||||||
|
- [MVP 功能清单](./mvp-feature-plan.md)
|
||||||
|
- [技术选型](./technical-selection.md)
|
||||||
|
- [消息协议与状态机](./message-protocol-and-state-machine.md)
|
||||||
|
- [实施路线图](./implementation-roadmap.md)
|
||||||
|
|
||||||
|
## 项目一句话定义
|
||||||
|
|
||||||
|
`Boss` 是一个面向多设备开发协作的 agent control plane。
|
||||||
|
|
||||||
|
它让用户通过对话入口或独立 App,持续管理多台 Windows、Mac 上的编码代理,支持:
|
||||||
|
|
||||||
|
- 协同开发
|
||||||
|
- 独立开发
|
||||||
|
- 子任务拆分
|
||||||
|
- 实时进度播报
|
||||||
|
- 中途变更需求
|
||||||
|
- 审批高风险操作
|
||||||
|
- 审计和会话恢复
|
||||||
|
|
||||||
|
## 当前建议的产品定位
|
||||||
|
|
||||||
|
- 不是单一编码助手
|
||||||
|
- 不是单机 IDE 插件
|
||||||
|
- 不是单纯云端沙箱代理
|
||||||
|
- 而是“主控端 + 多设备 worker + 对话式任务系统”
|
||||||
|
|
||||||
|
## 建议阅读顺序
|
||||||
|
|
||||||
|
1. [竞品对比](./competitor-comparison.md)
|
||||||
|
2. [系统架构](./system-architecture.md)
|
||||||
|
3. [MVP 功能清单](./mvp-feature-plan.md)
|
||||||
|
4. [技术选型](./technical-selection.md)
|
||||||
|
5. [消息协议与状态机](./message-protocol-and-state-machine.md)
|
||||||
|
6. [实施路线图](./implementation-roadmap.md)
|
||||||
216
docs/competitor-comparison.md
Normal file
216
docs/competitor-comparison.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# 多设备编码代理主控平台竞品对比
|
||||||
|
|
||||||
|
更新日期:2026-03-23
|
||||||
|
|
||||||
|
## 目标定义
|
||||||
|
|
||||||
|
本对比只关注和下面目标相关的产品或框架:
|
||||||
|
|
||||||
|
- 主控端统一管理多个编码代理
|
||||||
|
- 支持多入口触发,如聊天工具、Web、IDE、CLI
|
||||||
|
- 能把任务拆成子任务并持续回传进度
|
||||||
|
- 最好能兼容或借鉴本地设备 worker,而不只是云沙箱
|
||||||
|
|
||||||
|
不纳入重点范围的产品:
|
||||||
|
|
||||||
|
- 只做本地单机结对编程
|
||||||
|
- 只做代码补全
|
||||||
|
- 只做通用办公 agent,不强调编码任务编排
|
||||||
|
|
||||||
|
## 一页结论
|
||||||
|
|
||||||
|
- 最接近企业级成品中控:Devin、Factory
|
||||||
|
- 最适合搬运和二次开发:OpenHands
|
||||||
|
- 最适合作为主账号大脑:OpenAI Codex
|
||||||
|
- 最适合作为设备侧 worker:Claude Code、Codex CLI、Aider、Cline、Roo Code
|
||||||
|
- 最适合作为协议层:MCP
|
||||||
|
- 最适合作为 agent-to-agent 通信层:A2A
|
||||||
|
|
||||||
|
## 竞品对比表
|
||||||
|
|
||||||
|
| 产品/方案 | 类型 | 中控能力 | 子任务/多代理 | 多设备本地 worker 适配 | 聊天入口 | 自建/二开友好度 | 适合借鉴的点 | 主要短板 | 结论 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Devin Enterprise | 商业成品 | 很强 | 强 | 弱到中 | 强 | 低 | 企业级组织管理、RBAC、API、调度、外部集成 | 更偏云环境,不是为本地多设备编队设计 | 可参考“成品控制台” |
|
||||||
|
| OpenAI Codex | 商业成品 + 平台能力 | 强 | 强 | 中 | 强 | 中 | 主账号统一对话、多入口、云并行任务 | 不是现成的多物理设备 fleet 管理器 | 可作为 manager brain |
|
||||||
|
| Factory | 商业成品 | 很强 | 很强 | 中 | 很强 | 低到中 | Subagents、Missions、多入口控制 | 生态较新,闭源较重 | 值得重点借鉴交互与控制面 |
|
||||||
|
| OpenHands Cloud / SDK | 开源平台 + 托管服务 | 强 | 强 | 中到强 | 强 | 很高 | Cloud UI、SDK、远程沙箱、权限、可自建 | 仍需自己补产品化细节 | 最适合做底座 |
|
||||||
|
| GitHub Copilot coding agent | 商业成品 | 中到强 | 中 | 弱 | 中 | 低 | Issue/PR 驱动、GitHub 闭环、组织策略 | 太 GitHub-centric | 只适合借鉴局部流程 |
|
||||||
|
| Cursor Background Agents | 商业成品 | 中 | 中 | 中 | 强 | 低 | 远程 agent + Slack 入口 | 更偏账号级,不像团队总控台 | 值得借鉴轻量远程协作 |
|
||||||
|
| Claude Code | 本地 agent | 弱 | 中 | 很强 | 中 | 中 | 本地终端执行、开发体验强 | local-first,不是中控台 | 非常适合当 worker |
|
||||||
|
| Sourcegraph + Batch Changes + MCP | 平台底座 | 中 | 弱到中 | 弱 | 弱 | 中 | 批量改仓、代码搜索、分析能力 | 不是 agent control plane | 可作为代码情报层 |
|
||||||
|
| LangGraph | 编排框架 | 中到强 | 很强 | 中 | 弱 | 很高 | 状态机、工作流、持久状态 | UI、权限、执行层都要自建 | 适合完全自研时使用 |
|
||||||
|
| AutoGen | 多代理框架 | 中 | 很强 | 中 | 弱 | 高 | 多代理协作范式成熟 | 产品化要自己做 | 适合研究协作逻辑 |
|
||||||
|
| CrewAI | 工作流框架 | 中 | 很强 | 中 | 弱 | 中到高 | Flow、观测、流程编排 | 编码执行层不够强 | 可借鉴业务流程层 |
|
||||||
|
| MCP | 工具协议 | 不适用 | 不适用 | 强 | 不适用 | 很高 | 工具统一接入标准 | 不是 agent 调度协议 | 必须纳入架构 |
|
||||||
|
| A2A | agent 通信协议 | 不适用 | 强 | 强 | 不适用 | 高 | 跨 agent 通信和任务协作 | 生态仍在发展 | 建议前瞻纳入 |
|
||||||
|
|
||||||
|
## 重点产品拆解
|
||||||
|
|
||||||
|
### 1. Devin Enterprise
|
||||||
|
|
||||||
|
适合借鉴:
|
||||||
|
|
||||||
|
- 企业级组织、权限、审计、API
|
||||||
|
- 从 Slack、Teams、GitHub、Linear、Jira 等入口发起任务
|
||||||
|
- 定时 session 和共享工作流
|
||||||
|
|
||||||
|
不完全适合直接照搬的点:
|
||||||
|
|
||||||
|
- 更偏云端 Devin machine
|
||||||
|
- 对本地 Windows/Mac 设备级 agent 编队支持不是它的核心卖点
|
||||||
|
|
||||||
|
适合你们的参考角色:
|
||||||
|
|
||||||
|
- 作为“企业中控台形态”参考
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- https://docs.devin.ai/enterprise/get-started
|
||||||
|
- https://docs.devin.ai/release-notes
|
||||||
|
|
||||||
|
### 2. OpenAI Codex
|
||||||
|
|
||||||
|
适合借鉴:
|
||||||
|
|
||||||
|
- 主账号统一发起和跟踪任务
|
||||||
|
- 跨 App、CLI、IDE、GitHub、Slack、Linear 协作
|
||||||
|
- 云端并行任务和账户级工作台体验
|
||||||
|
|
||||||
|
不完全适合直接照搬的点:
|
||||||
|
|
||||||
|
- 更像围绕账户和任务的 agent 工作台
|
||||||
|
- 不是专门面向“多台真实开发设备”的设备编排系统
|
||||||
|
|
||||||
|
适合你们的参考角色:
|
||||||
|
|
||||||
|
- 作为 manager brain
|
||||||
|
- 作为主对话入口和任务拆分层
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- https://openai.com/index/codex-now-generally-available/
|
||||||
|
- https://help.openai.com/en/articles/11369540-codex-in-chatgpt
|
||||||
|
|
||||||
|
### 3. Factory
|
||||||
|
|
||||||
|
适合借鉴:
|
||||||
|
|
||||||
|
- Missions 和 Custom Droids 的任务拆解思路
|
||||||
|
- Slack、Teams、GitHub App、IDE、CLI、Web 的多入口控制
|
||||||
|
- 远程和本地结合的控制面叙事
|
||||||
|
|
||||||
|
不完全适合直接照搬的点:
|
||||||
|
|
||||||
|
- 闭源较重
|
||||||
|
- 需要较多逆向产品层理解,难直接搬运技术实现
|
||||||
|
|
||||||
|
适合你们的参考角色:
|
||||||
|
|
||||||
|
- 作为“多入口 + 子代理协同”交互范式参考
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- https://docs.factory.ai/
|
||||||
|
- https://docs.factory.ai/user-guides/droids/creating-custom-droids
|
||||||
|
|
||||||
|
### 4. OpenHands Cloud / SDK
|
||||||
|
|
||||||
|
适合借鉴:
|
||||||
|
|
||||||
|
- OpenHands SDK 已经是 CLI 和 Cloud 的底层引擎
|
||||||
|
- 有 Cloud UI、Cloud API、权限、预算、远程沙箱
|
||||||
|
- 自建和二开空间大
|
||||||
|
|
||||||
|
不完全适合直接照搬的点:
|
||||||
|
|
||||||
|
- 如果要做极强的多设备本地 worker 编排,仍需扩展 agent server 和 worker registry
|
||||||
|
|
||||||
|
适合你们的参考角色:
|
||||||
|
|
||||||
|
- 最优先底座候选
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- https://docs.openhands.dev/sdk
|
||||||
|
- https://docs.openhands.dev/usage/cloud/cloud-ui
|
||||||
|
|
||||||
|
### 5. Claude Code
|
||||||
|
|
||||||
|
适合借鉴:
|
||||||
|
|
||||||
|
- 本地执行体验强
|
||||||
|
- 非常适合挂在 Windows/Mac 上做 worker
|
||||||
|
- 终端型编码任务执行成熟
|
||||||
|
|
||||||
|
不完全适合直接照搬的点:
|
||||||
|
|
||||||
|
- 官方定位是 local-first,不是统一云端中控
|
||||||
|
|
||||||
|
适合你们的参考角色:
|
||||||
|
|
||||||
|
- 设备侧 worker 执行器
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- https://claude.com/product/claude-code
|
||||||
|
|
||||||
|
## 我们的推荐路线
|
||||||
|
|
||||||
|
### 路线 A:最快做出产品
|
||||||
|
|
||||||
|
- 底座:OpenHands SDK
|
||||||
|
- Manager:Codex
|
||||||
|
- 设备侧 worker:Claude Code / Codex CLI
|
||||||
|
- 对话入口:Web 为主,Slack/Telegram 为辅
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 先把真实可用的多设备协作跑起来
|
||||||
|
|
||||||
|
### 路线 B:完全自研控制面
|
||||||
|
|
||||||
|
- 编排:LangGraph 或 AutoGen
|
||||||
|
- Manager:Codex
|
||||||
|
- 工具层:MCP
|
||||||
|
- agent 协作层:A2A
|
||||||
|
- 设备侧 worker:你们自己的 daemon,内部再调 Claude Code / Codex CLI
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 你们希望最终拥有自己的协议、调度和产品层壁垒
|
||||||
|
|
||||||
|
### 路线 C:先验证,再重构
|
||||||
|
|
||||||
|
- 第一阶段用 OpenHands 风格快速拼装 MVP
|
||||||
|
- 第二阶段把设备注册、任务队列、审计、聊天入口收回自研
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 你们既想快,又不想被单一底座绑死
|
||||||
|
|
||||||
|
## 对你这个产品最关键的判断
|
||||||
|
|
||||||
|
如果目标是:
|
||||||
|
|
||||||
|
- 主账号持续对话
|
||||||
|
- 实时调整需求
|
||||||
|
- 控制多台真实 Windows/Mac
|
||||||
|
- 支持协同开发和独立开发两种模式
|
||||||
|
|
||||||
|
那么最好的产品结构不是“一个超强 agent”,而是:
|
||||||
|
|
||||||
|
- 一个 control plane
|
||||||
|
- 一个 manager agent
|
||||||
|
- 多个设备侧 worker
|
||||||
|
- 一套可中断、可续跑、可审计的任务系统
|
||||||
|
|
||||||
|
也就是说,真正应该对标的不是“谁最会写代码”,而是“谁最会调度一群会写代码的代理”。
|
||||||
|
|
||||||
|
## 下一步建议
|
||||||
|
|
||||||
|
完成第 1 项后,建议继续做:
|
||||||
|
|
||||||
|
1. 系统架构图
|
||||||
|
2. MVP 功能清单
|
||||||
|
3. 技术选型建议
|
||||||
|
4. 消息协议与任务状态机
|
||||||
174
docs/implementation-roadmap.md
Normal file
174
docs/implementation-roadmap.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Boss 实施路线图
|
||||||
|
|
||||||
|
更新日期:2026-03-23
|
||||||
|
|
||||||
|
## 路线目标
|
||||||
|
|
||||||
|
把 Boss 从概念验证推进到可长期迭代的产品原型。
|
||||||
|
|
||||||
|
## 阶段划分
|
||||||
|
|
||||||
|
### Phase 0:项目底座
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 建立基础仓库结构
|
||||||
|
- 搭建前后端骨架
|
||||||
|
- 建立数据库和基础实体
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 有 Web 控制台壳子
|
||||||
|
- 有后端 API 壳子
|
||||||
|
- 有 Postgres 和 Redis
|
||||||
|
- 能创建 session
|
||||||
|
|
||||||
|
### Phase 1:多设备接入
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 让 2 台 Windows 和 1 台 Mac 成为可调度 worker
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- worker 可以注册
|
||||||
|
- worker 有心跳
|
||||||
|
- 控制台能看到在线状态
|
||||||
|
- 可以手工下发简单任务
|
||||||
|
|
||||||
|
### Phase 2:对话驱动任务拆分
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 用户一句话创建任务
|
||||||
|
- manager 生成任务树
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 对话生成主任务和子任务
|
||||||
|
- 子任务可以指派到不同 worker
|
||||||
|
- UI 能看到任务树
|
||||||
|
|
||||||
|
### Phase 3:执行、事件和审批
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- worker 真正执行开发动作
|
||||||
|
- 中途回传结构化进度
|
||||||
|
- 高风险动作需要审批
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- worker 能跑 git、terminal、测试
|
||||||
|
- 进度能在 UI 和聊天入口显示
|
||||||
|
- 审批可以打断并恢复流程
|
||||||
|
|
||||||
|
### Phase 4:中途变更需求和重规划
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 支持用户实时改变需求
|
||||||
|
- manager 能生成新计划
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 用户可在原会话继续说话
|
||||||
|
- 系统可 pause/cancel/replan
|
||||||
|
- 能展示计划差异
|
||||||
|
|
||||||
|
### Phase 5:协同开发增强
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 支持研究、实现、测试分工协作
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 一个任务可以拆成研究和实现链路
|
||||||
|
- 子任务之间可引用共享上下文
|
||||||
|
- manager 能输出阶段性总结
|
||||||
|
|
||||||
|
## 推荐开发顺序
|
||||||
|
|
||||||
|
1. 建库和实体
|
||||||
|
2. worker daemon
|
||||||
|
3. Web 控制台基础页
|
||||||
|
4. manager 集成
|
||||||
|
5. 子任务调度
|
||||||
|
6. 事件流和实时订阅
|
||||||
|
7. 审批
|
||||||
|
8. 聊天入口
|
||||||
|
9. 需求变更与重规划
|
||||||
|
|
||||||
|
## 每阶段产物
|
||||||
|
|
||||||
|
### Phase 0 产物
|
||||||
|
|
||||||
|
- API skeleton
|
||||||
|
- DB schema
|
||||||
|
- 基础 UI
|
||||||
|
|
||||||
|
### Phase 1 产物
|
||||||
|
|
||||||
|
- `boss-worker`
|
||||||
|
- worker registry
|
||||||
|
- 设备管理页
|
||||||
|
|
||||||
|
### Phase 2 产物
|
||||||
|
|
||||||
|
- manager planning adapter
|
||||||
|
- task tree UI
|
||||||
|
- assignment service
|
||||||
|
|
||||||
|
### Phase 3 产物
|
||||||
|
|
||||||
|
- executor adapter
|
||||||
|
- event stream
|
||||||
|
- approval flow
|
||||||
|
|
||||||
|
### Phase 4 产物
|
||||||
|
|
||||||
|
- plan diff engine
|
||||||
|
- pause/resume/cancel controls
|
||||||
|
- session replay
|
||||||
|
|
||||||
|
### Phase 5 产物
|
||||||
|
|
||||||
|
- collaborative mode
|
||||||
|
- shared artifacts
|
||||||
|
- richer progress summaries
|
||||||
|
|
||||||
|
## 里程碑定义
|
||||||
|
|
||||||
|
### 里程碑 A:可看见
|
||||||
|
|
||||||
|
- 能看到 3 台机器在线
|
||||||
|
- 能创建任务
|
||||||
|
|
||||||
|
### 里程碑 B:可调度
|
||||||
|
|
||||||
|
- 能把任务分发给不同设备
|
||||||
|
- 能看到执行状态
|
||||||
|
|
||||||
|
### 里程碑 C:可对话改需求
|
||||||
|
|
||||||
|
- 执行中可重规划
|
||||||
|
- 任务不会失控
|
||||||
|
|
||||||
|
### 里程碑 D:可协同开发
|
||||||
|
|
||||||
|
- 多台设备能并行分工
|
||||||
|
- 主控端能统一总结
|
||||||
|
|
||||||
|
## 当前最建议的首版交付
|
||||||
|
|
||||||
|
如果你现在就准备开工,建议首版目标定成:
|
||||||
|
|
||||||
|
- Web 控制台
|
||||||
|
- 3 台 worker
|
||||||
|
- manager 拆分最多 3 个子任务
|
||||||
|
- worker 支持 git、terminal、test
|
||||||
|
- 支持审批
|
||||||
|
- 支持需求变更后重规划
|
||||||
|
|
||||||
|
做到这里,这个产品就已经不是 demo,而是一个真正可试用的原型。
|
||||||
419
docs/message-protocol-and-state-machine.md
Normal file
419
docs/message-protocol-and-state-machine.md
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
# Boss 消息协议与任务状态机
|
||||||
|
|
||||||
|
更新日期:2026-03-23
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
|
||||||
|
这份文档定义 Boss 内部最小可用消息模型,确保系统支持:
|
||||||
|
|
||||||
|
- 实时对话
|
||||||
|
- 子任务拆分
|
||||||
|
- 中途改需求
|
||||||
|
- 暂停和恢复
|
||||||
|
- 审批
|
||||||
|
- 事件回放
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
- 所有关键变化都事件化
|
||||||
|
- 会话状态来自事件流和状态快照
|
||||||
|
- 用户消息和系统动作统一进入同一条项目时间线
|
||||||
|
- worker 回传必须结构化,不能只传纯文本
|
||||||
|
|
||||||
|
## 会话模型
|
||||||
|
|
||||||
|
### 层级
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project Session
|
||||||
|
-> Conversation Thread
|
||||||
|
-> Task Tree
|
||||||
|
-> Worker Assignments
|
||||||
|
-> Approval Requests
|
||||||
|
-> Artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 对象定义
|
||||||
|
|
||||||
|
#### project_session
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ps_001",
|
||||||
|
"title": "修复登录与缓存问题",
|
||||||
|
"status": "active",
|
||||||
|
"active_objective": "先定位根因,再决定是否修改代码",
|
||||||
|
"created_at": "2026-03-23T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### message
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "msg_001",
|
||||||
|
"session_id": "ps_001",
|
||||||
|
"role": "user",
|
||||||
|
"channel": "web",
|
||||||
|
"content": "先排查登录失败,不要急着改代码",
|
||||||
|
"created_at": "2026-03-23T12:00:05Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### task
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "task_001",
|
||||||
|
"session_id": "ps_001",
|
||||||
|
"parent_task_id": null,
|
||||||
|
"title": "定位登录失败根因",
|
||||||
|
"kind": "investigation",
|
||||||
|
"status": "planning",
|
||||||
|
"priority": "high",
|
||||||
|
"risk_level": "medium"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### worker_assignment
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wa_001",
|
||||||
|
"task_id": "task_002",
|
||||||
|
"worker_id": "worker_win_a",
|
||||||
|
"status": "assigned"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 事件模型
|
||||||
|
|
||||||
|
### 统一事件格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "evt_001",
|
||||||
|
"session_id": "ps_001",
|
||||||
|
"task_id": "task_002",
|
||||||
|
"source": "worker",
|
||||||
|
"type": "task.step.started",
|
||||||
|
"timestamp": "2026-03-23T12:10:00Z",
|
||||||
|
"payload": {
|
||||||
|
"step": "run_tests",
|
||||||
|
"summary": "开始运行登录相关测试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `source` 可取 `user`, `manager`, `system`, `worker`
|
||||||
|
- `type` 是事件名
|
||||||
|
- `payload` 是结构化内容
|
||||||
|
|
||||||
|
### 推荐事件类型
|
||||||
|
|
||||||
|
#### 会话事件
|
||||||
|
|
||||||
|
- `session.created`
|
||||||
|
- `session.objective.updated`
|
||||||
|
- `session.message.added`
|
||||||
|
|
||||||
|
#### 规划事件
|
||||||
|
|
||||||
|
- `plan.created`
|
||||||
|
- `plan.updated`
|
||||||
|
- `plan.diff.generated`
|
||||||
|
|
||||||
|
#### 任务事件
|
||||||
|
|
||||||
|
- `task.created`
|
||||||
|
- `task.assigned`
|
||||||
|
- `task.started`
|
||||||
|
- `task.progress`
|
||||||
|
- `task.blocked`
|
||||||
|
- `task.paused`
|
||||||
|
- `task.resumed`
|
||||||
|
- `task.cancelled`
|
||||||
|
- `task.completed`
|
||||||
|
- `task.failed`
|
||||||
|
|
||||||
|
#### worker 事件
|
||||||
|
|
||||||
|
- `worker.heartbeat`
|
||||||
|
- `worker.capabilities.updated`
|
||||||
|
- `worker.assignment.accepted`
|
||||||
|
- `worker.assignment.rejected`
|
||||||
|
|
||||||
|
#### 执行步骤事件
|
||||||
|
|
||||||
|
- `task.step.started`
|
||||||
|
- `task.step.finished`
|
||||||
|
- `task.step.failed`
|
||||||
|
- `tool.call.requested`
|
||||||
|
- `tool.call.finished`
|
||||||
|
|
||||||
|
#### 审批事件
|
||||||
|
|
||||||
|
- `approval.requested`
|
||||||
|
- `approval.approved`
|
||||||
|
- `approval.rejected`
|
||||||
|
|
||||||
|
## 任务状态机
|
||||||
|
|
||||||
|
### 主状态机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> planning
|
||||||
|
planning --> queued
|
||||||
|
queued --> assigned
|
||||||
|
assigned --> running
|
||||||
|
running --> blocked
|
||||||
|
running --> paused
|
||||||
|
running --> waiting_approval
|
||||||
|
running --> completed
|
||||||
|
running --> failed
|
||||||
|
running --> cancelled
|
||||||
|
blocked --> running
|
||||||
|
paused --> running
|
||||||
|
waiting_approval --> running
|
||||||
|
waiting_approval --> cancelled
|
||||||
|
failed --> queued
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态说明
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `planning` | manager 正在生成任务计划 |
|
||||||
|
| `queued` | 已创建,等待调度 |
|
||||||
|
| `assigned` | 已分配给某个 worker |
|
||||||
|
| `running` | worker 正在执行 |
|
||||||
|
| `blocked` | 因外部条件缺失而卡住 |
|
||||||
|
| `paused` | 被用户或系统暂停 |
|
||||||
|
| `waiting_approval` | 等待用户审批 |
|
||||||
|
| `completed` | 成功完成 |
|
||||||
|
| `failed` | 执行失败 |
|
||||||
|
| `cancelled` | 被取消 |
|
||||||
|
|
||||||
|
## 需求变更协议
|
||||||
|
|
||||||
|
### 为什么需要单独协议
|
||||||
|
|
||||||
|
用户在对话里改需求,不应该等于简单新增一句聊天消息。系统需要知道这条消息是否会:
|
||||||
|
|
||||||
|
- 改变当前目标
|
||||||
|
- 废弃现有子任务
|
||||||
|
- 新增任务
|
||||||
|
- 触发审批
|
||||||
|
|
||||||
|
### 建议流程
|
||||||
|
|
||||||
|
1. 用户消息进入 session
|
||||||
|
2. manager 判断该消息是否属于 `objective change`
|
||||||
|
3. 如果是,生成 `plan.diff`
|
||||||
|
4. Task Service 根据 diff 执行状态迁移
|
||||||
|
|
||||||
|
### plan diff 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "ps_001",
|
||||||
|
"change_reason": "用户要求先修接口重试,不处理缓存",
|
||||||
|
"cancel_tasks": ["task_cache_001"],
|
||||||
|
"pause_tasks": ["task_ui_003"],
|
||||||
|
"continue_tasks": ["task_api_002"],
|
||||||
|
"create_tasks": [
|
||||||
|
{
|
||||||
|
"title": "修复接口重试逻辑",
|
||||||
|
"kind": "implementation",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 审批协议
|
||||||
|
|
||||||
|
### 审批请求格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "apr_001",
|
||||||
|
"session_id": "ps_001",
|
||||||
|
"task_id": "task_009",
|
||||||
|
"kind": "dangerous_command",
|
||||||
|
"summary": "准备执行 rm -rf build-cache",
|
||||||
|
"risk_level": "high",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 审批动作
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
- `approve`
|
||||||
|
- `reject`
|
||||||
|
- `approve_once`
|
||||||
|
- `approve_for_session`
|
||||||
|
|
||||||
|
MVP 建议只做:
|
||||||
|
|
||||||
|
- `approve`
|
||||||
|
- `reject`
|
||||||
|
|
||||||
|
## Worker 协议
|
||||||
|
|
||||||
|
### worker 注册
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"worker_id": "worker_mac_001",
|
||||||
|
"hostname": "mac-studio",
|
||||||
|
"os": "macos",
|
||||||
|
"shell": "zsh",
|
||||||
|
"capabilities": [
|
||||||
|
"git",
|
||||||
|
"terminal",
|
||||||
|
"playwright",
|
||||||
|
"xcode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### heartbeat
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"worker_id": "worker_mac_001",
|
||||||
|
"status": "idle",
|
||||||
|
"current_task_id": null,
|
||||||
|
"load": 0.25,
|
||||||
|
"timestamp": "2026-03-23T12:15:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 任务执行请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "task_102",
|
||||||
|
"session_id": "ps_001",
|
||||||
|
"workspace": {
|
||||||
|
"repo": "git@github.com:org/repo.git",
|
||||||
|
"branch": "boss/task-102",
|
||||||
|
"worktree_path": "/workers/worktrees/task-102"
|
||||||
|
},
|
||||||
|
"execution_mode": "independent",
|
||||||
|
"goal": "排查登录失败,输出根因和修复建议",
|
||||||
|
"constraints": [
|
||||||
|
"先不要改代码",
|
||||||
|
"优先跑测试和读日志"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度摘要协议
|
||||||
|
|
||||||
|
worker 和 manager 都不应该只回长文本。
|
||||||
|
|
||||||
|
推荐维护一份结构化摘要:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "task_102",
|
||||||
|
"summary": "已定位到接口重试在 401 时进入死循环",
|
||||||
|
"progress_percent": 60,
|
||||||
|
"current_step": "analyze_retry_logic",
|
||||||
|
"next_step": "补充最小修复方案并跑测试",
|
||||||
|
"risk": "medium"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这个结构化摘要可以直接喂给:
|
||||||
|
|
||||||
|
- Web 控制台右侧面板
|
||||||
|
- 聊天入口状态播报
|
||||||
|
- manager 汇总器
|
||||||
|
|
||||||
|
## 协同开发协议
|
||||||
|
|
||||||
|
### 模式字段
|
||||||
|
|
||||||
|
任务可声明:
|
||||||
|
|
||||||
|
- `independent`
|
||||||
|
- `collaborative`
|
||||||
|
- `research_only`
|
||||||
|
|
||||||
|
### collaborative 模式下增加字段
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shared_context_refs": [
|
||||||
|
"artifact_001",
|
||||||
|
"task_202.summary"
|
||||||
|
],
|
||||||
|
"handoff_expected": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用于支持:
|
||||||
|
|
||||||
|
- 研究任务把结论交给实现任务
|
||||||
|
- 后端任务把 API 变更交给前端任务
|
||||||
|
|
||||||
|
## UI 实时订阅模型
|
||||||
|
|
||||||
|
控制台建议订阅三个流:
|
||||||
|
|
||||||
|
- `session_feed`
|
||||||
|
- `task_feed`
|
||||||
|
- `worker_feed`
|
||||||
|
|
||||||
|
这样可以避免一个超大流承载所有内容。
|
||||||
|
|
||||||
|
## 失败处理策略
|
||||||
|
|
||||||
|
### 可重试失败
|
||||||
|
|
||||||
|
- 网络抖动
|
||||||
|
- worker 短时离线
|
||||||
|
- tool call 超时
|
||||||
|
|
||||||
|
### 不可重试失败
|
||||||
|
|
||||||
|
- 权限不足
|
||||||
|
- 任务目标冲突
|
||||||
|
- 用户明确取消
|
||||||
|
|
||||||
|
### 推荐策略
|
||||||
|
|
||||||
|
- 每个任务记录 `retry_count`
|
||||||
|
- 重试必须带原因
|
||||||
|
- 多次失败要自动升级为 `blocked`
|
||||||
|
|
||||||
|
## MVP 最小协议集
|
||||||
|
|
||||||
|
第一版只需要支持这些事件:
|
||||||
|
|
||||||
|
- `session.message.added`
|
||||||
|
- `plan.created`
|
||||||
|
- `task.created`
|
||||||
|
- `task.assigned`
|
||||||
|
- `task.started`
|
||||||
|
- `task.progress`
|
||||||
|
- `task.paused`
|
||||||
|
- `task.cancelled`
|
||||||
|
- `task.completed`
|
||||||
|
- `task.failed`
|
||||||
|
- `worker.heartbeat`
|
||||||
|
- `approval.requested`
|
||||||
|
- `approval.approved`
|
||||||
|
- `approval.rejected`
|
||||||
|
|
||||||
|
## 一句话总结
|
||||||
|
|
||||||
|
Boss 的协议核心不是“消息收发”,而是“让对话、任务、worker、审批都落在同一个可追踪状态机里”。
|
||||||
269
docs/mvp-feature-plan.md
Normal file
269
docs/mvp-feature-plan.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Boss MVP 功能清单
|
||||||
|
|
||||||
|
更新日期:2026-03-23
|
||||||
|
|
||||||
|
## MVP 目标
|
||||||
|
|
||||||
|
用最小可用范围验证下面三件事:
|
||||||
|
|
||||||
|
- 用户能持续通过对话管理一个项目
|
||||||
|
- 主控端能把任务拆给多台设备并持续汇报进度
|
||||||
|
- 用户能在中途改变需求,系统能安全重规划
|
||||||
|
|
||||||
|
MVP 不追求:
|
||||||
|
|
||||||
|
- 多租户企业 SaaS
|
||||||
|
- 完整计费系统
|
||||||
|
- 支持所有聊天平台
|
||||||
|
- 自动解决所有冲突
|
||||||
|
|
||||||
|
## MVP 范围定义
|
||||||
|
|
||||||
|
### 必须有
|
||||||
|
|
||||||
|
- 单用户项目会话
|
||||||
|
- 3 台设备接入
|
||||||
|
- manager 拆任务
|
||||||
|
- worker 在线心跳
|
||||||
|
- 实时进度事件
|
||||||
|
- 任务暂停、继续、取消
|
||||||
|
- 高风险审批
|
||||||
|
- Git worktree 隔离
|
||||||
|
- 测试结果回传
|
||||||
|
- 基础审计
|
||||||
|
|
||||||
|
### 可以延后
|
||||||
|
|
||||||
|
- 多组织管理
|
||||||
|
- 向量记忆优化
|
||||||
|
- 多模型自动路由
|
||||||
|
- 自动 PR 审核
|
||||||
|
- 自动成本优化
|
||||||
|
- 丰富报表
|
||||||
|
|
||||||
|
## 用户故事
|
||||||
|
|
||||||
|
### 用户故事 1:发起项目任务
|
||||||
|
|
||||||
|
作为用户,我希望在一个对话里描述需求,让系统自动拆任务给不同设备执行。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 用户说一句自然语言需求即可创建 project session
|
||||||
|
- manager 能生成主任务和子任务
|
||||||
|
- 子任务能分配到不同设备
|
||||||
|
|
||||||
|
### 用户故事 2:查看实时进度
|
||||||
|
|
||||||
|
作为用户,我希望随时看到每台设备当前做到了哪里,而不是只在结束时知道结果。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 每个子任务有状态、最近一步、最近日志摘要
|
||||||
|
- UI 能看到设备在线状态
|
||||||
|
- 聊天入口能返回汇总版进度
|
||||||
|
|
||||||
|
### 用户故事 3:中途改需求
|
||||||
|
|
||||||
|
作为用户,我希望在任务执行中直接说“改一下方向”,系统就能调整任务,而不是重新开一个新会话。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 新需求追加到同一个 session
|
||||||
|
- manager 能触发 replan
|
||||||
|
- 正在运行的子任务可安全暂停或取消
|
||||||
|
- 用户能看到新旧计划差异
|
||||||
|
|
||||||
|
### 用户故事 4:审批危险操作
|
||||||
|
|
||||||
|
作为用户,我希望对删文件、强推分支、运行危险命令等行为进行确认。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- worker 可发起审批请求
|
||||||
|
- 控制台和聊天入口都能完成审批
|
||||||
|
- 审批前任务挂起
|
||||||
|
- 审批结果可审计
|
||||||
|
|
||||||
|
### 用户故事 5:协同开发
|
||||||
|
|
||||||
|
作为用户,我希望多台设备能分别做调研、编码和测试,并由主账号统一汇总。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 至少支持 2 个并行子任务
|
||||||
|
- manager 可汇总结果
|
||||||
|
- 同一项目下子任务之间可引用共享上下文
|
||||||
|
|
||||||
|
## MVP 模块清单
|
||||||
|
|
||||||
|
### 1. Web 控制台
|
||||||
|
|
||||||
|
必须页面:
|
||||||
|
|
||||||
|
- 会话页
|
||||||
|
- 任务树页
|
||||||
|
- 设备页
|
||||||
|
- 审批页
|
||||||
|
|
||||||
|
最小功能:
|
||||||
|
|
||||||
|
- 发消息
|
||||||
|
- 看任务树
|
||||||
|
- 看设备在线状态
|
||||||
|
- 审批和取消任务
|
||||||
|
|
||||||
|
### 2. 聊天入口
|
||||||
|
|
||||||
|
第一阶段建议只接一个平台:
|
||||||
|
|
||||||
|
- Slack 或 Telegram 二选一
|
||||||
|
|
||||||
|
最小功能:
|
||||||
|
|
||||||
|
- 新建任务
|
||||||
|
- 查看状态
|
||||||
|
- 审批
|
||||||
|
- 取消任务
|
||||||
|
|
||||||
|
不建议第一阶段做:
|
||||||
|
|
||||||
|
- 复杂文件浏览
|
||||||
|
- 终端实时流
|
||||||
|
|
||||||
|
### 3. Session Service
|
||||||
|
|
||||||
|
最小职责:
|
||||||
|
|
||||||
|
- 创建会话
|
||||||
|
- 保存消息
|
||||||
|
- 返回会话历史
|
||||||
|
- 标记当前 active objective
|
||||||
|
|
||||||
|
### 4. Task Service
|
||||||
|
|
||||||
|
最小职责:
|
||||||
|
|
||||||
|
- 创建任务树
|
||||||
|
- 更新任务状态
|
||||||
|
- 管理依赖关系
|
||||||
|
- 触发重规划
|
||||||
|
|
||||||
|
### 5. Scheduler
|
||||||
|
|
||||||
|
最小职责:
|
||||||
|
|
||||||
|
- 根据能力分配 worker
|
||||||
|
- 处理重试和超时
|
||||||
|
- 维护 assignment 状态
|
||||||
|
|
||||||
|
### 6. Worker Daemon
|
||||||
|
|
||||||
|
最小职责:
|
||||||
|
|
||||||
|
- 注册设备
|
||||||
|
- 心跳
|
||||||
|
- 拉取任务
|
||||||
|
- 执行命令
|
||||||
|
- 回传结构化事件
|
||||||
|
|
||||||
|
### 7. 审批系统
|
||||||
|
|
||||||
|
最小职责:
|
||||||
|
|
||||||
|
- 定义危险动作
|
||||||
|
- 创建审批请求
|
||||||
|
- 接收审批结果
|
||||||
|
- 恢复或终止工作流
|
||||||
|
|
||||||
|
## MVP 页面草图
|
||||||
|
|
||||||
|
### 会话页
|
||||||
|
|
||||||
|
区域:
|
||||||
|
|
||||||
|
- 左侧项目和会话列表
|
||||||
|
- 中间对话流
|
||||||
|
- 右侧任务树与设备执行摘要
|
||||||
|
|
||||||
|
### 任务页
|
||||||
|
|
||||||
|
区域:
|
||||||
|
|
||||||
|
- 主任务卡片
|
||||||
|
- 子任务列表
|
||||||
|
- 当前负责设备
|
||||||
|
- 状态与最近事件
|
||||||
|
|
||||||
|
### 设备页
|
||||||
|
|
||||||
|
区域:
|
||||||
|
|
||||||
|
- 设备名称
|
||||||
|
- OS
|
||||||
|
- 在线状态
|
||||||
|
- 当前任务
|
||||||
|
- 最近心跳
|
||||||
|
- 工具能力
|
||||||
|
|
||||||
|
## MVP 指标
|
||||||
|
|
||||||
|
### 产品指标
|
||||||
|
|
||||||
|
- 任务可创建成功率
|
||||||
|
- 子任务成功分配率
|
||||||
|
- 需求变更后的重规划成功率
|
||||||
|
- 审批往返耗时
|
||||||
|
|
||||||
|
### 系统指标
|
||||||
|
|
||||||
|
- worker 心跳在线率
|
||||||
|
- 事件回传延迟
|
||||||
|
- 任务平均完成时间
|
||||||
|
- 失败重试成功率
|
||||||
|
|
||||||
|
### 体验指标
|
||||||
|
|
||||||
|
- 用户查看进度时的响应时间
|
||||||
|
- 对话到任务树生成耗时
|
||||||
|
- 需求变更到新计划生效耗时
|
||||||
|
|
||||||
|
## MVP 版本边界
|
||||||
|
|
||||||
|
### V0.1
|
||||||
|
|
||||||
|
- Web 控制台
|
||||||
|
- 单聊天入口
|
||||||
|
- 3 台设备
|
||||||
|
- manager 拆 2 到 3 个子任务
|
||||||
|
- 手动审批
|
||||||
|
|
||||||
|
### V0.2
|
||||||
|
|
||||||
|
- 任务模板
|
||||||
|
- 更细的设备能力调度
|
||||||
|
- GitHub PR 集成
|
||||||
|
- 更丰富的任务摘要
|
||||||
|
|
||||||
|
### V0.3
|
||||||
|
|
||||||
|
- 协同开发模式增强
|
||||||
|
- 共享上下文管理
|
||||||
|
- 更细粒度权限
|
||||||
|
|
||||||
|
## MVP 不做清单
|
||||||
|
|
||||||
|
- 不做跨团队权限模型
|
||||||
|
- 不做复杂订阅体系
|
||||||
|
- 不做自动跨仓库大规模变更
|
||||||
|
- 不做完整 IDE 插件矩阵
|
||||||
|
- 不做长周期自主运行无需监督的全自动模式
|
||||||
|
|
||||||
|
## 开工优先级
|
||||||
|
|
||||||
|
1. 设备接入和心跳
|
||||||
|
2. 对话到任务树
|
||||||
|
3. 子任务分发
|
||||||
|
4. worker 执行与事件回传
|
||||||
|
5. 审批与中断恢复
|
||||||
|
6. 聊天入口
|
||||||
342
docs/system-architecture.md
Normal file
342
docs/system-architecture.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# Boss 系统架构
|
||||||
|
|
||||||
|
更新日期:2026-03-23
|
||||||
|
|
||||||
|
## 架构目标
|
||||||
|
|
||||||
|
Boss 要解决的问题不是“如何让一个 agent 写代码”,而是“如何让一个主控端持续调度多个运行在不同设备上的编码 agent,并保持对话、状态和审计一致”。
|
||||||
|
|
||||||
|
核心目标:
|
||||||
|
|
||||||
|
- 用户始终只和一个主账号对话
|
||||||
|
- 主账号能把任务拆给多个 worker
|
||||||
|
- 每个 worker 可以运行在真实 Windows 或 Mac 设备上
|
||||||
|
- 用户可以随时改变需求
|
||||||
|
- 系统支持中断、续跑、审批、回滚、审计
|
||||||
|
|
||||||
|
## 总体架构图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User["用户"] --> Chat["聊天入口<br/>Slack / Telegram / 飞书 / 企业微信"]
|
||||||
|
User --> App["独立控制台<br/>Web / Desktop"]
|
||||||
|
|
||||||
|
Chat --> Gateway["API Gateway"]
|
||||||
|
App --> Gateway
|
||||||
|
|
||||||
|
Gateway --> Session["Session Service"]
|
||||||
|
Gateway --> Task["Task Service"]
|
||||||
|
Gateway --> Notify["Notification Service"]
|
||||||
|
|
||||||
|
Session --> Planner["Manager Agent<br/>Codex"]
|
||||||
|
Task --> Planner
|
||||||
|
Planner --> Queue["Queue / Workflow Engine"]
|
||||||
|
Queue --> Scheduler["Scheduler"]
|
||||||
|
|
||||||
|
Scheduler --> Worker1["Windows Worker A"]
|
||||||
|
Scheduler --> Worker2["Windows Worker B"]
|
||||||
|
Scheduler --> Worker3["Mac Worker"]
|
||||||
|
|
||||||
|
Worker1 --> Runtime1["Agent Runtime<br/>Codex CLI / Claude Code / MCP Tools"]
|
||||||
|
Worker2 --> Runtime2["Agent Runtime<br/>Codex CLI / Claude Code / MCP Tools"]
|
||||||
|
Worker3 --> Runtime3["Agent Runtime<br/>Codex CLI / Claude Code / MCP Tools"]
|
||||||
|
|
||||||
|
Session --> Store["Postgres"]
|
||||||
|
Task --> Store
|
||||||
|
Queue --> Store
|
||||||
|
Worker1 --> EventBus["Event Bus"]
|
||||||
|
Worker2 --> EventBus
|
||||||
|
Worker3 --> EventBus
|
||||||
|
EventBus --> Notify
|
||||||
|
EventBus --> Store
|
||||||
|
|
||||||
|
Planner --> Memory["Project Memory / Vector Store"]
|
||||||
|
Worker1 --> Git["Git Provider"]
|
||||||
|
Worker2 --> Browser["Browser / Playwright"]
|
||||||
|
Worker3 --> IDE["Local IDE / Terminal / Filesystem"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分层设计
|
||||||
|
|
||||||
|
### 1. 入口层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 接收用户自然语言
|
||||||
|
- 展示项目进度、任务树、审批请求
|
||||||
|
- 承载多端对话入口
|
||||||
|
|
||||||
|
组成:
|
||||||
|
|
||||||
|
- Web 控制台
|
||||||
|
- Desktop 控制台
|
||||||
|
- Slack / Telegram / 飞书 / 企业微信 Bot
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
|
||||||
|
- 独立 App 是主控制面
|
||||||
|
- 聊天工具只做轻入口和通知审批
|
||||||
|
- 所有入口共享同一个会话和任务状态
|
||||||
|
|
||||||
|
### 2. 会话与任务层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 维护项目级会话
|
||||||
|
- 存储用户意图、任务树、依赖关系
|
||||||
|
- 接收需求变更并触发重规划
|
||||||
|
|
||||||
|
核心对象:
|
||||||
|
|
||||||
|
- `project_session`
|
||||||
|
- `task`
|
||||||
|
- `subtask`
|
||||||
|
- `message`
|
||||||
|
- `approval_request`
|
||||||
|
- `worker_assignment`
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
|
||||||
|
- 任何需求变更都不是覆盖旧状态,而是追加一条事件
|
||||||
|
- 主控 agent 根据最新状态决定继续、暂停、取消还是重规划
|
||||||
|
|
||||||
|
### 3. Manager Agent 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 读取当前上下文
|
||||||
|
- 把用户自然语言翻译成任务树
|
||||||
|
- 给不同 worker 分派工作
|
||||||
|
- 汇总各 worker 回传结果
|
||||||
|
- 把技术执行过程重新组织成面向用户的进度播报
|
||||||
|
|
||||||
|
建议实现:
|
||||||
|
|
||||||
|
- 首选 Codex 作为 manager brain
|
||||||
|
- manager 不直接执行重活,主要负责规划、协调和总结
|
||||||
|
|
||||||
|
为什么 manager 要单独存在:
|
||||||
|
|
||||||
|
- 用户说的是需求语言,不是底层执行语言
|
||||||
|
- 设备 worker 报的是技术状态,不是业务状态
|
||||||
|
- manager 负责在这两者之间做翻译
|
||||||
|
|
||||||
|
### 4. 调度与工作流层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 排队
|
||||||
|
- 优先级控制
|
||||||
|
- 并发控制
|
||||||
|
- 超时重试
|
||||||
|
- 中断恢复
|
||||||
|
- 审批等待
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
|
||||||
|
- 长任务必须通过工作流引擎或持久队列驱动
|
||||||
|
- 不能靠单个进程中的内存状态维持任务
|
||||||
|
|
||||||
|
推荐能力:
|
||||||
|
|
||||||
|
- 任务可挂起
|
||||||
|
- 任务可人工恢复
|
||||||
|
- 子任务状态独立
|
||||||
|
- 支持依赖关系图
|
||||||
|
|
||||||
|
### 5. Worker 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 在真实设备上执行具体开发动作
|
||||||
|
- 拉代码、建 worktree、改文件、跑测试、创建 PR
|
||||||
|
- 上报结构化事件和心跳
|
||||||
|
|
||||||
|
部署方式:
|
||||||
|
|
||||||
|
- 每台设备安装一个 `boss-worker`
|
||||||
|
- worker 持久连接到 control plane
|
||||||
|
- worker 内部再调用 Codex CLI、Claude Code、Playwright、MCP 工具等
|
||||||
|
|
||||||
|
为什么 worker 必须是 daemon:
|
||||||
|
|
||||||
|
- 需要持续在线状态
|
||||||
|
- 需要接收取消和暂停命令
|
||||||
|
- 需要中途回传事件
|
||||||
|
- 需要在主控端丢线后继续执行
|
||||||
|
|
||||||
|
### 6. 工具层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 提供标准化工具访问
|
||||||
|
- 屏蔽不同设备的具体环境差异
|
||||||
|
|
||||||
|
建议接入:
|
||||||
|
|
||||||
|
- Git
|
||||||
|
- Filesystem
|
||||||
|
- Terminal
|
||||||
|
- Browser automation
|
||||||
|
- Issue tracker
|
||||||
|
- Chat connector
|
||||||
|
- CI / Test runner
|
||||||
|
|
||||||
|
建议协议:
|
||||||
|
|
||||||
|
- 工具接入优先使用 MCP
|
||||||
|
- agent-to-agent 通信前瞻引入 A2A
|
||||||
|
|
||||||
|
### 7. 存储与审计层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 保存消息、任务、事件、状态快照
|
||||||
|
- 为用户提供会话恢复与审计追踪
|
||||||
|
|
||||||
|
必须保存的内容:
|
||||||
|
|
||||||
|
- 用户消息
|
||||||
|
- manager 规划结果
|
||||||
|
- worker 事件流
|
||||||
|
- 审批记录
|
||||||
|
- 高风险工具调用
|
||||||
|
- Git 变更摘要
|
||||||
|
|
||||||
|
## 关键交互路径
|
||||||
|
|
||||||
|
### 路径 1:用户发起新任务
|
||||||
|
|
||||||
|
1. 用户在 Web 或聊天入口发起需求
|
||||||
|
2. Session Service 追加消息
|
||||||
|
3. Manager 生成任务树
|
||||||
|
4. Task Service 创建主任务和子任务
|
||||||
|
5. Scheduler 根据设备能力分配 worker
|
||||||
|
6. worker 执行并回传事件
|
||||||
|
7. manager 定期汇总进度给用户
|
||||||
|
|
||||||
|
### 路径 2:用户中途改需求
|
||||||
|
|
||||||
|
1. 用户说“先暂停 A,改成先修登录问题”
|
||||||
|
2. 新消息进入当前 project session
|
||||||
|
3. manager 读取活动中的任务树
|
||||||
|
4. manager 标记哪些子任务失效,哪些继续保留
|
||||||
|
5. Task Service 下发 `pause / cancel / replan`
|
||||||
|
6. worker 接收并安全停止当前步骤
|
||||||
|
7. manager 输出新的计划和最新进度
|
||||||
|
|
||||||
|
### 路径 3:高风险审批
|
||||||
|
|
||||||
|
1. worker 申请执行高风险操作
|
||||||
|
2. control plane 生成审批请求
|
||||||
|
3. 用户在 App 或聊天入口确认
|
||||||
|
4. 工作流恢复执行或终止
|
||||||
|
|
||||||
|
## 协同开发模式
|
||||||
|
|
||||||
|
### 模式 A:独立开发
|
||||||
|
|
||||||
|
- 一个子任务绑定一个 worker
|
||||||
|
- worker 自己完成从理解、编码到测试的闭环
|
||||||
|
- manager 只做验收和汇总
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 低耦合功能开发
|
||||||
|
- 单一 bug 修复
|
||||||
|
|
||||||
|
### 模式 B:协同开发
|
||||||
|
|
||||||
|
- manager 先做任务拆解
|
||||||
|
- 不同 worker 分别负责不同模块或阶段
|
||||||
|
- 通过 shared context 和中间产物衔接
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 横跨前后端的需求
|
||||||
|
- 需要并行研究和实现
|
||||||
|
|
||||||
|
### 模式 C:研究先行
|
||||||
|
|
||||||
|
- 多个 worker 先做调研、复现、定位
|
||||||
|
- manager 再决定由哪台设备进入实现
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 不确定问题根因
|
||||||
|
- 风险较高的问题
|
||||||
|
|
||||||
|
## 设备调度策略
|
||||||
|
|
||||||
|
推荐调度维度:
|
||||||
|
|
||||||
|
- OS 能力
|
||||||
|
- 当前负载
|
||||||
|
- 工具能力
|
||||||
|
- 项目亲和性
|
||||||
|
- 上下文热度
|
||||||
|
- 风险等级
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- 需要 Xcode 的任务优先给 Mac
|
||||||
|
- 需要 Windows 原生应用自动化的任务优先给 Windows
|
||||||
|
- 同一 repo 的后续任务优先分给最近处理过该 repo 的 worker
|
||||||
|
|
||||||
|
## 为什么不建议一开始就做的东西
|
||||||
|
|
||||||
|
- 不建议一开始做跨团队多租户 SaaS
|
||||||
|
- 不建议一开始做复杂计费系统
|
||||||
|
- 不建议一开始就做完整 A2A 联邦网络
|
||||||
|
- 不建议一开始支持太多聊天平台
|
||||||
|
|
||||||
|
第一阶段只要把下面几件事跑通:
|
||||||
|
|
||||||
|
- 主账号持续对话
|
||||||
|
- 三台机器在线
|
||||||
|
- 子任务拆分
|
||||||
|
- 实时进度回传
|
||||||
|
- 可暂停、可继续、可取消
|
||||||
|
- 可审批高风险操作
|
||||||
|
|
||||||
|
## 当前推荐架构决策
|
||||||
|
|
||||||
|
- 主控面:Web 优先,后续补 Desktop
|
||||||
|
- Manager:Codex
|
||||||
|
- Worker runtime:Codex CLI + Claude Code 混合
|
||||||
|
- 工具协议:MCP
|
||||||
|
- 任务编排:持久队列或工作流引擎
|
||||||
|
- 存储:Postgres
|
||||||
|
- 事件流:Redis Streams 或消息队列
|
||||||
|
|
||||||
|
## 架构风险
|
||||||
|
|
||||||
|
### 风险 1:多个 worker 改同一仓库互相踩踏
|
||||||
|
|
||||||
|
规避方式:
|
||||||
|
|
||||||
|
- 每个任务独立 worktree
|
||||||
|
- 每个任务独立分支
|
||||||
|
- 合并前必须做 diff 汇总和冲突检查
|
||||||
|
|
||||||
|
### 风险 2:需求变化导致长任务上下文混乱
|
||||||
|
|
||||||
|
规避方式:
|
||||||
|
|
||||||
|
- 所有需求变化事件化
|
||||||
|
- manager 在重规划前必须读取当前状态快照
|
||||||
|
|
||||||
|
### 风险 3:聊天入口承载太多复杂交互
|
||||||
|
|
||||||
|
规避方式:
|
||||||
|
|
||||||
|
- 长会话、文件树、终端流只放在独立 App
|
||||||
|
- 聊天入口只承担轻操作
|
||||||
|
|
||||||
|
### 风险 4:worker 执行权限过大
|
||||||
|
|
||||||
|
规避方式:
|
||||||
|
|
||||||
|
- 把危险命令纳入审批
|
||||||
|
- 工具分级授权
|
||||||
|
- 关键操作记录审计日志
|
||||||
314
docs/technical-selection.md
Normal file
314
docs/technical-selection.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# Boss 技术选型建议
|
||||||
|
|
||||||
|
更新日期:2026-03-23
|
||||||
|
|
||||||
|
## 选型原则
|
||||||
|
|
||||||
|
- 先保证系统能跑,再追求最优雅
|
||||||
|
- 持久状态优先于纯内存编排
|
||||||
|
- 结构化事件优先于拼字符串日志
|
||||||
|
- 尽量复用成熟 agent 运行时,而不是自己重写编码 agent
|
||||||
|
|
||||||
|
## 总体选型建议
|
||||||
|
|
||||||
|
| 层 | 推荐 | 备选 | 原因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 前端控制台 | Next.js | Remix, Nuxt | 适合快速做管理台和流式 UI |
|
||||||
|
| Desktop | Tauri | Electron | 后续需要时再补,Tauri 更轻 |
|
||||||
|
| 后端 API | NestJS 或 Fastify | Express, Go Fiber | 结构清晰,适合长期维护 |
|
||||||
|
| 工作流引擎 | Temporal | Inngest, BullMQ + 自建状态机 | 长任务、中断、审批、恢复是核心需求 |
|
||||||
|
| 队列/事件 | Redis Streams | NATS, Kafka | MVP 阶段足够轻,开发快 |
|
||||||
|
| 主数据库 | Postgres | MySQL | 事务、JSON、查询能力更适合控制平面 |
|
||||||
|
| 缓存 | Redis | KeyDB | 用于会话热数据、限流、事件流 |
|
||||||
|
| Manager Agent | Codex | Claude API, GPT-5.x Responses | 适合做规划、汇总、重规划 |
|
||||||
|
| Worker Runtime | Codex CLI + Claude Code | OpenHands agent runtime | 直接复用成熟编码 agent |
|
||||||
|
| 工具协议 | MCP | 自定义 tool API | 标准化工具接入 |
|
||||||
|
| 浏览器自动化 | Playwright | Puppeteer | 成熟稳定,适合 agent 使用 |
|
||||||
|
| 聊天入口 | Slack 或 Telegram | 飞书, 企业微信 | 文档、生态和迭代速度更合适 |
|
||||||
|
| 身份认证 | Clerk 或 Auth.js | 自建 OAuth | MVP 快速起步 |
|
||||||
|
| 监控 | OpenTelemetry + Grafana | Datadog, Sentry | 方便追踪任务链路 |
|
||||||
|
|
||||||
|
## 为什么推荐这个技术组合
|
||||||
|
|
||||||
|
### 1. Next.js 作为控制台前端
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 适合做 dashboard、对话界面、任务树、设备面板
|
||||||
|
- 容易做流式更新和服务端渲染
|
||||||
|
- 与 TypeScript 一体化
|
||||||
|
|
||||||
|
不建议现在单独起 React Native:
|
||||||
|
|
||||||
|
- 当前核心不是移动端,而是 control plane
|
||||||
|
|
||||||
|
### 2. NestJS 或 Fastify 作为后端
|
||||||
|
|
||||||
|
如果团队偏工程化:
|
||||||
|
|
||||||
|
- 选 NestJS
|
||||||
|
|
||||||
|
如果你想更轻更快:
|
||||||
|
|
||||||
|
- 选 Fastify
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 先用 Fastify 或 NestJS 中你更熟的那个
|
||||||
|
- 关键是把服务边界拆干净
|
||||||
|
|
||||||
|
### 3. Temporal 作为工作流引擎
|
||||||
|
|
||||||
|
这是当前最关键的选型之一。
|
||||||
|
|
||||||
|
Boss 不是短请求系统,而是长任务系统。你需要天然支持:
|
||||||
|
|
||||||
|
- 几分钟到几小时的任务
|
||||||
|
- 中途暂停
|
||||||
|
- 审批等待
|
||||||
|
- worker 断线重连
|
||||||
|
- 任务恢复
|
||||||
|
|
||||||
|
如果不用工作流引擎,后面会自己补很多分布式状态机逻辑。
|
||||||
|
|
||||||
|
如果 MVP 阶段不想上 Temporal:
|
||||||
|
|
||||||
|
- 可以先用 `BullMQ + Postgres 状态表`
|
||||||
|
- 但要尽快收敛到真正可恢复的工作流模型
|
||||||
|
|
||||||
|
### 4. Postgres 作为主数据库
|
||||||
|
|
||||||
|
Boss 的核心不是海量日志,而是:
|
||||||
|
|
||||||
|
- 会话
|
||||||
|
- 任务树
|
||||||
|
- 审批
|
||||||
|
- worker 注册
|
||||||
|
- 状态快照
|
||||||
|
|
||||||
|
这些都非常适合 Postgres。
|
||||||
|
|
||||||
|
### 5. Codex 作为 manager
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 你希望主账号持续对话和重规划
|
||||||
|
- manager 不需要最强本地执行,它更需要任务理解、拆解和汇总
|
||||||
|
- Codex 在多入口和云任务语境里更契合 manager 角色
|
||||||
|
|
||||||
|
推荐职责:
|
||||||
|
|
||||||
|
- 规划
|
||||||
|
- 重规划
|
||||||
|
- 汇总
|
||||||
|
- 风险解释
|
||||||
|
|
||||||
|
不建议让 manager 干太多底层执行:
|
||||||
|
|
||||||
|
- 容易把规划和执行耦合死
|
||||||
|
|
||||||
|
### 6. Claude Code / Codex CLI 作为 worker runtime
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 本地设备执行体验成熟
|
||||||
|
- 适合接 terminal、filesystem、git、browser
|
||||||
|
- 真实开发环境兼容性更好
|
||||||
|
|
||||||
|
建议策略:
|
||||||
|
|
||||||
|
- Mac 和 Windows worker 都先抽象成统一 `executor`
|
||||||
|
- 底层可配置使用 `codex` 或 `claude`
|
||||||
|
|
||||||
|
## 服务拆分建议
|
||||||
|
|
||||||
|
### 第一阶段服务
|
||||||
|
|
||||||
|
- `api-gateway`
|
||||||
|
- `session-service`
|
||||||
|
- `task-service`
|
||||||
|
- `scheduler-service`
|
||||||
|
- `worker-gateway`
|
||||||
|
- `notification-service`
|
||||||
|
|
||||||
|
### 第二阶段可拆
|
||||||
|
|
||||||
|
- `approval-service`
|
||||||
|
- `memory-service`
|
||||||
|
- `git-integration-service`
|
||||||
|
- `chat-connector-service`
|
||||||
|
|
||||||
|
## 数据存储建议
|
||||||
|
|
||||||
|
### Postgres 表
|
||||||
|
|
||||||
|
建议至少有:
|
||||||
|
|
||||||
|
- `users`
|
||||||
|
- `project_sessions`
|
||||||
|
- `messages`
|
||||||
|
- `tasks`
|
||||||
|
- `task_dependencies`
|
||||||
|
- `worker_nodes`
|
||||||
|
- `worker_capabilities`
|
||||||
|
- `worker_assignments`
|
||||||
|
- `task_events`
|
||||||
|
- `approval_requests`
|
||||||
|
- `artifacts`
|
||||||
|
|
||||||
|
### Redis 用途
|
||||||
|
|
||||||
|
- worker 在线状态
|
||||||
|
- 事件总线
|
||||||
|
- UI 实时订阅
|
||||||
|
- 节流和限流
|
||||||
|
|
||||||
|
## Worker 设计建议
|
||||||
|
|
||||||
|
### worker 进程结构
|
||||||
|
|
||||||
|
组成:
|
||||||
|
|
||||||
|
- `boss-worker` 守护进程
|
||||||
|
- `executor` 适配层
|
||||||
|
- `tool adapters`
|
||||||
|
- `sandbox policy`
|
||||||
|
- `event reporter`
|
||||||
|
|
||||||
|
### worker 本地目录策略
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 一个统一工作根目录
|
||||||
|
- 每个任务一个独立 workspace
|
||||||
|
- 每个任务一个独立 git worktree
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/boss-worker/
|
||||||
|
repos/
|
||||||
|
worktrees/
|
||||||
|
cache/
|
||||||
|
artifacts/
|
||||||
|
```
|
||||||
|
|
||||||
|
### worker 能力声明
|
||||||
|
|
||||||
|
每个 worker 启动时上报:
|
||||||
|
|
||||||
|
- OS
|
||||||
|
- shell
|
||||||
|
- 是否支持浏览器自动化
|
||||||
|
- 是否支持特定 IDE
|
||||||
|
- 是否有移动端模拟器
|
||||||
|
- 是否具备 repo 热缓存
|
||||||
|
|
||||||
|
## 协议选型建议
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 统一工具调用
|
||||||
|
|
||||||
|
适合接:
|
||||||
|
|
||||||
|
- Git
|
||||||
|
- 文件系统
|
||||||
|
- 浏览器
|
||||||
|
- Issue 系统
|
||||||
|
- CI
|
||||||
|
|
||||||
|
### A2A
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 中长期用于 agent 之间通信
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- MVP 不强依赖
|
||||||
|
- 但内部消息模型尽量向 agent-to-agent 协作靠拢
|
||||||
|
|
||||||
|
## 前后端通信建议
|
||||||
|
|
||||||
|
### 控制台实时能力
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- WebSocket 或 Server-Sent Events
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 推送任务状态
|
||||||
|
- 推送审批请求
|
||||||
|
- 推送设备在线变化
|
||||||
|
|
||||||
|
### worker 连接方式
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- WebSocket 长连接
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 需要双向通信
|
||||||
|
- 需要下发中断和恢复命令
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
### 最小权限原则
|
||||||
|
|
||||||
|
- worker 不要默认拥有所有仓库权限
|
||||||
|
- 每种工具调用都要有权限分级
|
||||||
|
|
||||||
|
### 高风险动作审批
|
||||||
|
|
||||||
|
必须纳入审批的动作:
|
||||||
|
|
||||||
|
- 删除大量文件
|
||||||
|
- 强推分支
|
||||||
|
- 执行危险 shell
|
||||||
|
- 访问敏感路径
|
||||||
|
|
||||||
|
### 审计
|
||||||
|
|
||||||
|
必须记录:
|
||||||
|
|
||||||
|
- 谁发起了什么
|
||||||
|
- 哪个 worker 执行了什么
|
||||||
|
- 哪个 agent 建议了什么
|
||||||
|
- 审批前后状态
|
||||||
|
|
||||||
|
## 推荐的 MVP 技术栈
|
||||||
|
|
||||||
|
### MVP 最稳组合
|
||||||
|
|
||||||
|
- 前端:Next.js
|
||||||
|
- 后端:NestJS
|
||||||
|
- 数据库:Postgres
|
||||||
|
- 缓存和事件:Redis
|
||||||
|
- 工作流:BullMQ,后续升级 Temporal
|
||||||
|
- Manager:Codex
|
||||||
|
- Worker:Codex CLI + Claude Code
|
||||||
|
- 聊天入口:Telegram 或 Slack
|
||||||
|
|
||||||
|
### 如果你要一步到位更稳
|
||||||
|
|
||||||
|
- 前端:Next.js
|
||||||
|
- 后端:NestJS
|
||||||
|
- 数据库:Postgres
|
||||||
|
- 缓存:Redis
|
||||||
|
- 工作流:Temporal
|
||||||
|
- Manager:Codex
|
||||||
|
- Worker:Codex CLI + Claude Code
|
||||||
|
- 监控:OpenTelemetry
|
||||||
|
|
||||||
|
## 不推荐的坑
|
||||||
|
|
||||||
|
- 不要一开始就全微服务
|
||||||
|
- 不要一开始就自研完整 agent runtime
|
||||||
|
- 不要把聊天平台当成主控制台
|
||||||
|
- 不要让多个任务共用一个工作目录
|
||||||
|
- 不要把审批做成“日志里提示一下”而不是显式状态
|
||||||
1618
package-lock.json
generated
Normal file
1618
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "boss",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"check": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"worker": "tsx src/worker.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/static": "^8.2.0",
|
||||||
|
"fastify": "^5.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
333
public/app.js
Normal file
333
public/app.js
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
const state = {
|
||||||
|
sessions: [],
|
||||||
|
messages: [],
|
||||||
|
tasks: [],
|
||||||
|
approvals: [],
|
||||||
|
workers: [],
|
||||||
|
events: [],
|
||||||
|
selectedSessionId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
sessionList: document.querySelector("#session-list"),
|
||||||
|
workerList: document.querySelector("#worker-list"),
|
||||||
|
messageList: document.querySelector("#message-list"),
|
||||||
|
taskList: document.querySelector("#task-list"),
|
||||||
|
approvalList: document.querySelector("#approval-list"),
|
||||||
|
eventList: document.querySelector("#event-list"),
|
||||||
|
sessionTitleDisplay: document.querySelector("#session-title-display"),
|
||||||
|
sessionSummary: document.querySelector("#session-summary"),
|
||||||
|
createSessionForm: document.querySelector("#create-session-form"),
|
||||||
|
sessionTitleInput: document.querySelector("#session-title"),
|
||||||
|
messageForm: document.querySelector("#message-form"),
|
||||||
|
messageInput: document.querySelector("#message-input"),
|
||||||
|
resetDemo: document.querySelector("#reset-demo"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(input) {
|
||||||
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(url, options = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedSession() {
|
||||||
|
return state.sessions.find((session) => session.id === state.selectedSessionId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasksForSelectedSession() {
|
||||||
|
return state.tasks.filter((task) => task.sessionId === state.selectedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagesForSelectedSession() {
|
||||||
|
return state.messages.filter((message) => message.sessionId === state.selectedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalsForSelectedSession() {
|
||||||
|
return state.approvals.filter((approval) => approval.sessionId === state.selectedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsForSelectedSession() {
|
||||||
|
return state.events
|
||||||
|
.filter((event) => event.sessionId === null || event.sessionId === state.selectedSessionId)
|
||||||
|
.slice(-50)
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessions() {
|
||||||
|
elements.sessionList.innerHTML = state.sessions
|
||||||
|
.map((session) => {
|
||||||
|
const active = session.id === state.selectedSessionId ? "active" : "";
|
||||||
|
return `
|
||||||
|
<button class="session-item ${active}" data-session-id="${session.id}">
|
||||||
|
<strong>${escapeHtml(session.title)}</strong>
|
||||||
|
<span>${escapeHtml(session.activeObjective || "暂无目标")}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
elements.sessionList.querySelectorAll("[data-session-id]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
state.selectedSessionId = button.dataset.sessionId;
|
||||||
|
await loadSession(state.selectedSessionId);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkers() {
|
||||||
|
elements.workerList.innerHTML = state.workers
|
||||||
|
.map(
|
||||||
|
(worker) => `
|
||||||
|
<div class="card">
|
||||||
|
<div class="row between">
|
||||||
|
<strong>${escapeHtml(worker.name)}</strong>
|
||||||
|
<span class="badge ${worker.status}">${escapeHtml(worker.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted">${escapeHtml(worker.os)}</div>
|
||||||
|
<div class="tags">
|
||||||
|
${worker.capabilities.map((capability) => `<span>${escapeHtml(capability)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionHeader() {
|
||||||
|
const session = selectedSession();
|
||||||
|
if (!session) {
|
||||||
|
elements.sessionTitleDisplay.textContent = "选择一个项目会话";
|
||||||
|
elements.sessionSummary.textContent = "创建会话后,在这里持续对话并观察任务状态。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.sessionTitleDisplay.textContent = session.title;
|
||||||
|
elements.sessionSummary.textContent =
|
||||||
|
session.lastPlannerSummary || session.activeObjective || "等待用户输入。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages() {
|
||||||
|
const messages = messagesForSelectedSession();
|
||||||
|
elements.messageList.innerHTML = messages.length
|
||||||
|
? messages
|
||||||
|
.map(
|
||||||
|
(message) => `
|
||||||
|
<article class="message ${message.role}">
|
||||||
|
<header>
|
||||||
|
<strong>${escapeHtml(message.role)}</strong>
|
||||||
|
<span>${new Date(message.createdAt).toLocaleTimeString()}</span>
|
||||||
|
</header>
|
||||||
|
<p>${escapeHtml(message.content)}</p>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<p class="muted">当前没有消息。</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTasks() {
|
||||||
|
const tasks = tasksForSelectedSession();
|
||||||
|
elements.taskList.innerHTML = tasks.length
|
||||||
|
? tasks
|
||||||
|
.map(
|
||||||
|
(task) => `
|
||||||
|
<div class="card">
|
||||||
|
<div class="row between">
|
||||||
|
<strong>${escapeHtml(task.title)}</strong>
|
||||||
|
<span class="badge ${task.status}">${escapeHtml(task.status)}</span>
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(task.description)}</p>
|
||||||
|
<div class="muted">worker: ${escapeHtml(task.assignedWorkerId || "未分配")}</div>
|
||||||
|
<div class="muted">progress: ${task.progressPercent}%</div>
|
||||||
|
<div class="muted">summary: ${escapeHtml(task.summary || "暂无")}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button data-action="pause" data-task-id="${task.id}" class="ghost">暂停</button>
|
||||||
|
<button data-action="cancel" data-task-id="${task.id}" class="ghost danger">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<p class="muted">当前没有任务。</p>`;
|
||||||
|
|
||||||
|
elements.taskList.querySelectorAll("[data-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const taskId = button.dataset.taskId;
|
||||||
|
const action = button.dataset.action;
|
||||||
|
await request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" });
|
||||||
|
await loadSession(state.selectedSessionId);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApprovals() {
|
||||||
|
const approvals = approvalsForSelectedSession();
|
||||||
|
elements.approvalList.innerHTML = approvals.length
|
||||||
|
? approvals
|
||||||
|
.map(
|
||||||
|
(approval) => `
|
||||||
|
<div class="card">
|
||||||
|
<div class="row between">
|
||||||
|
<strong>${escapeHtml(approval.summary)}</strong>
|
||||||
|
<span class="badge ${approval.status}">${escapeHtml(approval.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted">risk: ${escapeHtml(approval.riskLevel)}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button data-approval-id="${approval.id}" data-approved="true">批准</button>
|
||||||
|
<button data-approval-id="${approval.id}" data-approved="false" class="ghost danger">拒绝</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<p class="muted">当前没有待审批项。</p>`;
|
||||||
|
|
||||||
|
elements.approvalList.querySelectorAll("[data-approval-id]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const approvalId = button.dataset.approvalId;
|
||||||
|
const approved = button.dataset.approved === "true";
|
||||||
|
await request(`/api/approvals/${approvalId}/respond`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ approved, responder: "web-user" }),
|
||||||
|
});
|
||||||
|
await loadSession(state.selectedSessionId);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvents() {
|
||||||
|
const events = eventsForSelectedSession();
|
||||||
|
elements.eventList.innerHTML = events.length
|
||||||
|
? events
|
||||||
|
.map(
|
||||||
|
(event) => `
|
||||||
|
<article class="event">
|
||||||
|
<header>
|
||||||
|
<strong>${escapeHtml(event.type)}</strong>
|
||||||
|
<span>${new Date(event.timestamp).toLocaleTimeString()}</span>
|
||||||
|
</header>
|
||||||
|
<pre>${escapeHtml(JSON.stringify(event.payload, null, 2))}</pre>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<p class="muted">当前没有事件。</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderSessions();
|
||||||
|
renderWorkers();
|
||||||
|
renderSessionHeader();
|
||||||
|
renderMessages();
|
||||||
|
renderTasks();
|
||||||
|
renderApprovals();
|
||||||
|
renderEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBootstrap() {
|
||||||
|
const bootstrap = await request("/api/bootstrap");
|
||||||
|
state.sessions = bootstrap.sessions;
|
||||||
|
state.messages = bootstrap.messages;
|
||||||
|
state.tasks = bootstrap.tasks;
|
||||||
|
state.workers = bootstrap.workers;
|
||||||
|
state.approvals = bootstrap.approvals;
|
||||||
|
state.events = bootstrap.events;
|
||||||
|
if (!state.selectedSessionId && state.sessions[0]) {
|
||||||
|
state.selectedSessionId = state.sessions[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSession(sessionId) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
const details = await request(`/api/sessions/${sessionId}`);
|
||||||
|
state.sessions = state.sessions.map((session) => (session.id === sessionId ? details.session : session));
|
||||||
|
state.messages = [
|
||||||
|
...state.messages.filter((message) => message.sessionId !== sessionId),
|
||||||
|
...details.messages,
|
||||||
|
];
|
||||||
|
state.tasks = [
|
||||||
|
...state.tasks.filter((task) => task.sessionId !== sessionId),
|
||||||
|
...details.tasks,
|
||||||
|
];
|
||||||
|
state.approvals = [
|
||||||
|
...state.approvals.filter((approval) => approval.sessionId !== sessionId),
|
||||||
|
...details.approvals,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.createSessionForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const title = elements.sessionTitleInput.value.trim();
|
||||||
|
const details = await request("/api/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
state.sessions.unshift(details.session);
|
||||||
|
state.selectedSessionId = details.session.id;
|
||||||
|
await loadSession(details.session.id);
|
||||||
|
elements.sessionTitleInput.value = "";
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.messageForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!state.selectedSessionId) return;
|
||||||
|
const content = elements.messageInput.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
await request(`/api/sessions/${state.selectedSessionId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content, channel: "web" }),
|
||||||
|
});
|
||||||
|
elements.messageInput.value = "";
|
||||||
|
await loadSession(state.selectedSessionId);
|
||||||
|
await loadBootstrap();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.resetDemo.addEventListener("click", async () => {
|
||||||
|
await request("/api/demo/reset", { method: "POST", body: "{}" });
|
||||||
|
state.sessions = [];
|
||||||
|
state.messages = [];
|
||||||
|
state.tasks = [];
|
||||||
|
state.workers = [];
|
||||||
|
state.approvals = [];
|
||||||
|
state.events = [];
|
||||||
|
state.selectedSessionId = null;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = new EventSource("/api/events/stream");
|
||||||
|
stream.onmessage = async (event) => {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
state.events.push(payload);
|
||||||
|
if (payload.sessionId) {
|
||||||
|
await loadSession(payload.sessionId);
|
||||||
|
}
|
||||||
|
await loadBootstrap();
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
loadBootstrap().then(render).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
elements.sessionSummary.textContent = error.message;
|
||||||
|
});
|
||||||
|
|
||||||
92
public/index.html
Normal file
92
public/index.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Boss Control Plane</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h1>Boss</h1>
|
||||||
|
<button id="reset-demo" class="ghost">重置 Demo</button>
|
||||||
|
</div>
|
||||||
|
<p class="caption">多设备开发代理控制台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>项目会话</h2>
|
||||||
|
</div>
|
||||||
|
<form id="create-session-form" class="stack">
|
||||||
|
<input id="session-title" placeholder="新项目标题" />
|
||||||
|
<button type="submit">创建会话</button>
|
||||||
|
</form>
|
||||||
|
<div id="session-list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>设备</h2>
|
||||||
|
</div>
|
||||||
|
<div id="worker-list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<section class="panel hero">
|
||||||
|
<div>
|
||||||
|
<h2 id="session-title-display">选择一个项目会话</h2>
|
||||||
|
<p id="session-summary" class="muted">创建会话后,在这里持续对话并观察任务状态。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>对话</h2>
|
||||||
|
</div>
|
||||||
|
<div id="message-list" class="timeline"></div>
|
||||||
|
<form id="message-form" class="stack">
|
||||||
|
<textarea
|
||||||
|
id="message-input"
|
||||||
|
rows="4"
|
||||||
|
placeholder="输入需求。示例:先调研登录失败根因,不要急着改代码。"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit">发送消息</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>任务树</h2>
|
||||||
|
</div>
|
||||||
|
<div id="task-list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>审批</h2>
|
||||||
|
</div>
|
||||||
|
<div id="approval-list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>事件流</h2>
|
||||||
|
</div>
|
||||||
|
<div id="event-list" class="timeline compact"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
219
public/styles.css
Normal file
219
public/styles.css
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f3efe4;
|
||||||
|
--panel: rgba(255, 252, 245, 0.88);
|
||||||
|
--line: rgba(43, 40, 35, 0.12);
|
||||||
|
--text: #1d1c19;
|
||||||
|
--muted: #716c61;
|
||||||
|
--accent: #1f6feb;
|
||||||
|
--danger: #b42318;
|
||||||
|
--shadow: 0 18px 48px rgba(31, 28, 25, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Sans", "Source Han Sans SC", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(31, 111, 235, 0.12), transparent 24rem),
|
||||||
|
linear-gradient(180deg, #f8f5ec 0%, var(--bg) 100%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header,
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list,
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 32rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline.compact {
|
||||||
|
max-height: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item,
|
||||||
|
.card,
|
||||||
|
.message,
|
||||||
|
.event {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
padding: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
text-align: left;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item.active {
|
||||||
|
outline: 2px solid rgba(31, 111, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message header,
|
||||||
|
.event header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.manager {
|
||||||
|
background: rgba(31, 111, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.running,
|
||||||
|
.badge.busy {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.failed,
|
||||||
|
.badge.cancelled,
|
||||||
|
.badge.rejected {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.completed,
|
||||||
|
.badge.approved,
|
||||||
|
.badge.idle {
|
||||||
|
color: #0f7b55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags,
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags span {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
background: rgba(31, 111, 235, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption,
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
656
src/engine.ts
Normal file
656
src/engine.ts
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
import type {
|
||||||
|
ApprovalRequest,
|
||||||
|
AppState,
|
||||||
|
BossEvent,
|
||||||
|
Message,
|
||||||
|
Session,
|
||||||
|
SessionDetails,
|
||||||
|
Task,
|
||||||
|
WorkerNode,
|
||||||
|
} from "./types.js";
|
||||||
|
import { EventBroker } from "./event-broker.js";
|
||||||
|
import { createPlan, buildPlannerMessage, materializeTasks } from "./planner.js";
|
||||||
|
import { chooseAssignmentCandidates } from "./scheduler.js";
|
||||||
|
import { FileStore } from "./store.js";
|
||||||
|
import { createId, now } from "./utils.js";
|
||||||
|
|
||||||
|
const DATA_FILE = resolve(process.cwd(), ".boss-data", "store.json");
|
||||||
|
|
||||||
|
function isActiveTask(task: Task): boolean {
|
||||||
|
return !["completed", "failed", "cancelled"].includes(task.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BossEngine {
|
||||||
|
readonly store = new FileStore(DATA_FILE);
|
||||||
|
readonly events = new EventBroker();
|
||||||
|
|
||||||
|
getState(): AppState {
|
||||||
|
return this.store.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap(): AppState {
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(title?: string): SessionDetails {
|
||||||
|
const timestamp = now();
|
||||||
|
const session: Session = {
|
||||||
|
id: createId("session"),
|
||||||
|
title: title?.trim() || "未命名项目",
|
||||||
|
status: "active",
|
||||||
|
activeObjective: "",
|
||||||
|
lastPlannerSummary: "",
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
state.sessions.unshift(session);
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: session.id,
|
||||||
|
taskId: null,
|
||||||
|
source: "system",
|
||||||
|
type: "session.created",
|
||||||
|
payload: { title: session.title },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
return this.getSession(session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(sessionId: string): SessionDetails {
|
||||||
|
const state = this.getState();
|
||||||
|
const session = state.sessions.find((candidate) => candidate.id === sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session not found: ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
messages: state.messages.filter((message) => message.sessionId === sessionId),
|
||||||
|
tasks: state.tasks.filter((task) => task.sessionId === sessionId),
|
||||||
|
approvals: state.approvals.filter((approval) => approval.sessionId === sessionId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listSessions(): Session[] {
|
||||||
|
return this.getState().sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(sessionId: string, content: string, channel = "web"): SessionDetails {
|
||||||
|
const session = this.getSession(sessionId).session;
|
||||||
|
const message: Message = {
|
||||||
|
id: createId("msg"),
|
||||||
|
sessionId,
|
||||||
|
role: "user",
|
||||||
|
channel,
|
||||||
|
content: content.trim(),
|
||||||
|
createdAt: now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!message.content) {
|
||||||
|
throw new Error("Message content is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const mutableSession = state.sessions.find((candidate) => candidate.id === sessionId);
|
||||||
|
if (!mutableSession) {
|
||||||
|
throw new Error(`Session not found: ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableSession.activeObjective = message.content;
|
||||||
|
mutableSession.updatedAt = message.createdAt;
|
||||||
|
if (!mutableSession.title || mutableSession.title === "未命名项目") {
|
||||||
|
mutableSession.title = message.content.slice(0, 32);
|
||||||
|
}
|
||||||
|
state.messages.push(message);
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId,
|
||||||
|
taskId: null,
|
||||||
|
source: "user",
|
||||||
|
type: "session.message.added",
|
||||||
|
payload: {
|
||||||
|
channel,
|
||||||
|
content: message.content,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.applyPlan(session, message.content);
|
||||||
|
return this.getSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerWorker(input: {
|
||||||
|
name: string;
|
||||||
|
os: WorkerNode["os"];
|
||||||
|
capabilities: string[];
|
||||||
|
}): WorkerNode {
|
||||||
|
const timestamp = now();
|
||||||
|
const existing = this.getState().workers.find((worker) => worker.name === input.name);
|
||||||
|
if (existing) {
|
||||||
|
return this.updateWorker(existing.id, {
|
||||||
|
os: input.os,
|
||||||
|
capabilities: input.capabilities,
|
||||||
|
status: "idle",
|
||||||
|
load: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker: WorkerNode = {
|
||||||
|
id: createId("worker"),
|
||||||
|
name: input.name,
|
||||||
|
os: input.os,
|
||||||
|
capabilities: Array.from(new Set(input.capabilities)),
|
||||||
|
status: "idle",
|
||||||
|
currentTaskId: null,
|
||||||
|
load: 0,
|
||||||
|
lastSeenAt: timestamp,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
state.workers.push(worker);
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: null,
|
||||||
|
taskId: null,
|
||||||
|
source: "system",
|
||||||
|
type: "worker.registered",
|
||||||
|
payload: {
|
||||||
|
workerId: worker.id,
|
||||||
|
name: worker.name,
|
||||||
|
os: worker.os,
|
||||||
|
capabilities: worker.capabilities,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.syncAssignments();
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorker(
|
||||||
|
workerId: string,
|
||||||
|
input: Partial<Pick<WorkerNode, "os" | "capabilities" | "status" | "load">>,
|
||||||
|
): WorkerNode {
|
||||||
|
let updated!: WorkerNode;
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
||||||
|
if (!worker) {
|
||||||
|
throw new Error(`Worker not found: ${workerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.os) worker.os = input.os;
|
||||||
|
if (input.capabilities) worker.capabilities = Array.from(new Set(input.capabilities));
|
||||||
|
if (input.status) worker.status = input.status;
|
||||||
|
if (typeof input.load === "number") worker.load = input.load;
|
||||||
|
worker.updatedAt = now();
|
||||||
|
worker.lastSeenAt = worker.updatedAt;
|
||||||
|
updated = { ...worker };
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat(workerId: string, load = 0): WorkerNode {
|
||||||
|
let updated!: WorkerNode;
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
||||||
|
if (!worker) {
|
||||||
|
throw new Error(`Worker not found: ${workerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.lastSeenAt = now();
|
||||||
|
worker.updatedAt = worker.lastSeenAt;
|
||||||
|
worker.load = load;
|
||||||
|
if (!worker.currentTaskId && worker.status !== "offline") {
|
||||||
|
worker.status = "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: null,
|
||||||
|
taskId: worker.currentTaskId,
|
||||||
|
source: "worker",
|
||||||
|
type: "worker.heartbeat",
|
||||||
|
payload: {
|
||||||
|
workerId: worker.id,
|
||||||
|
status: worker.status,
|
||||||
|
load: worker.load,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
updated = { ...worker };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.syncAssignments();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
claimNextTask(workerId: string): Task | null {
|
||||||
|
let claimedTask: Task | null = null;
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
||||||
|
if (!worker) {
|
||||||
|
throw new Error(`Worker not found: ${workerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = state.tasks.find(
|
||||||
|
(candidate) => candidate.assignedWorkerId === workerId && candidate.status === "assigned",
|
||||||
|
);
|
||||||
|
if (!task) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.status = "running";
|
||||||
|
task.currentStep = "start";
|
||||||
|
task.summary = "任务已被 worker 接收。";
|
||||||
|
task.updatedAt = now();
|
||||||
|
worker.status = "busy";
|
||||||
|
worker.currentTaskId = task.id;
|
||||||
|
worker.updatedAt = task.updatedAt;
|
||||||
|
worker.lastSeenAt = task.updatedAt;
|
||||||
|
|
||||||
|
claimedTask = { ...task };
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: task.sessionId,
|
||||||
|
taskId: task.id,
|
||||||
|
source: "worker",
|
||||||
|
type: "task.started",
|
||||||
|
payload: {
|
||||||
|
workerId,
|
||||||
|
title: task.title,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimedTask) {
|
||||||
|
this.publishLatestEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return claimedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
reportProgress(
|
||||||
|
taskId: string,
|
||||||
|
workerId: string,
|
||||||
|
input: {
|
||||||
|
progressPercent: number;
|
||||||
|
summary: string;
|
||||||
|
currentStep: string;
|
||||||
|
nextStep: string;
|
||||||
|
},
|
||||||
|
): Task {
|
||||||
|
let updated!: Task;
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.progressPercent = Math.max(0, Math.min(100, input.progressPercent));
|
||||||
|
task.summary = input.summary;
|
||||||
|
task.currentStep = input.currentStep;
|
||||||
|
task.nextStep = input.nextStep;
|
||||||
|
task.updatedAt = now();
|
||||||
|
if (task.status === "assigned") {
|
||||||
|
task.status = "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: task.sessionId,
|
||||||
|
taskId: task.id,
|
||||||
|
source: "worker",
|
||||||
|
type: "task.progress",
|
||||||
|
payload: {
|
||||||
|
workerId,
|
||||||
|
progressPercent: task.progressPercent,
|
||||||
|
summary: task.summary,
|
||||||
|
currentStep: task.currentStep,
|
||||||
|
nextStep: task.nextStep,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updated = { ...task };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTask(taskId: string, workerId: string, summary: string): Task {
|
||||||
|
let updated!: Task;
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
||||||
|
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
if (!worker) {
|
||||||
|
throw new Error(`Worker not found: ${workerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.status = "completed";
|
||||||
|
task.progressPercent = 100;
|
||||||
|
task.summary = summary;
|
||||||
|
task.currentStep = "done";
|
||||||
|
task.nextStep = "";
|
||||||
|
task.updatedAt = now();
|
||||||
|
worker.status = "idle";
|
||||||
|
worker.currentTaskId = null;
|
||||||
|
worker.updatedAt = task.updatedAt;
|
||||||
|
worker.lastSeenAt = task.updatedAt;
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: task.sessionId,
|
||||||
|
taskId: task.id,
|
||||||
|
source: "worker",
|
||||||
|
type: "task.completed",
|
||||||
|
payload: {
|
||||||
|
workerId,
|
||||||
|
summary,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updated = { ...task };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.syncAssignments();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
failTask(taskId: string, workerId: string, errorMessage: string): Task {
|
||||||
|
let updated!: Task;
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
||||||
|
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
if (!worker) {
|
||||||
|
throw new Error(`Worker not found: ${workerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.status = "failed";
|
||||||
|
task.summary = errorMessage;
|
||||||
|
task.currentStep = "failed";
|
||||||
|
task.nextStep = "";
|
||||||
|
task.updatedAt = now();
|
||||||
|
worker.status = "idle";
|
||||||
|
worker.currentTaskId = null;
|
||||||
|
worker.updatedAt = task.updatedAt;
|
||||||
|
worker.lastSeenAt = task.updatedAt;
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: task.sessionId,
|
||||||
|
taskId: task.id,
|
||||||
|
source: "worker",
|
||||||
|
type: "task.failed",
|
||||||
|
payload: {
|
||||||
|
workerId,
|
||||||
|
errorMessage,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updated = { ...task };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.syncAssignments();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseTask(taskId: string): Task {
|
||||||
|
return this.transitionTask(taskId, "paused", "system", "task.paused", {
|
||||||
|
summary: "任务已被暂停。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTask(taskId: string): Task {
|
||||||
|
return this.transitionTask(taskId, "cancelled", "system", "task.cancelled", {
|
||||||
|
summary: "任务已被取消。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
respondApproval(approvalId: string, approved: boolean, responder: string): ApprovalRequest {
|
||||||
|
let updatedApproval!: ApprovalRequest;
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const approval = state.approvals.find((candidate) => candidate.id === approvalId);
|
||||||
|
if (!approval) {
|
||||||
|
throw new Error(`Approval not found: ${approvalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = state.tasks.find((candidate) => candidate.id === approval.taskId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task not found for approval: ${approval.taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = now();
|
||||||
|
approval.status = approved ? "approved" : "rejected";
|
||||||
|
approval.responder = responder;
|
||||||
|
approval.updatedAt = timestamp;
|
||||||
|
task.approvalStatus = approval.status;
|
||||||
|
task.updatedAt = timestamp;
|
||||||
|
task.status = approved ? "queued" : "cancelled";
|
||||||
|
task.summary = approved ? "审批已通过,重新进入队列。" : "审批被拒绝,任务已取消。";
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: approval.sessionId,
|
||||||
|
taskId: approval.taskId,
|
||||||
|
source: "system",
|
||||||
|
type: approved ? "approval.approved" : "approval.rejected",
|
||||||
|
payload: {
|
||||||
|
approvalId,
|
||||||
|
responder,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updatedApproval = { ...approval };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.syncAssignments();
|
||||||
|
return updatedApproval;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPlan(session: Session, content: string): void {
|
||||||
|
const sessionDetails = this.getSession(session.id);
|
||||||
|
const result = createPlan(sessionDetails.session, content, sessionDetails.tasks.filter(isActiveTask));
|
||||||
|
const tasks = materializeTasks(session.id, result);
|
||||||
|
const plannerMessage = buildPlannerMessage(result.summary);
|
||||||
|
const timestamp = now();
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const mutableSession = state.sessions.find((candidate) => candidate.id === session.id);
|
||||||
|
if (!mutableSession) {
|
||||||
|
throw new Error(`Session not found: ${session.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableSession.activeObjective = content;
|
||||||
|
mutableSession.lastPlannerSummary = plannerMessage;
|
||||||
|
mutableSession.updatedAt = timestamp;
|
||||||
|
|
||||||
|
if (result.pauseExistingTasks) {
|
||||||
|
for (const task of state.tasks.filter(
|
||||||
|
(candidate) => candidate.sessionId === session.id && isActiveTask(candidate),
|
||||||
|
)) {
|
||||||
|
if (["running", "assigned", "queued", "planning", "blocked"].includes(task.status)) {
|
||||||
|
task.status = "paused";
|
||||||
|
task.updatedAt = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tasks.push(...tasks);
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.approvalStatus === "pending") {
|
||||||
|
state.approvals.push({
|
||||||
|
id: createId("approval"),
|
||||||
|
sessionId: session.id,
|
||||||
|
taskId: task.id,
|
||||||
|
kind: "dangerous_action",
|
||||||
|
summary: `任务 "${task.title}" 包含高风险关键词,需要审批后才能执行。`,
|
||||||
|
riskLevel: "high",
|
||||||
|
status: "pending",
|
||||||
|
requester: "manager",
|
||||||
|
responder: null,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const managerMessage: Message = {
|
||||||
|
id: createId("msg"),
|
||||||
|
sessionId: session.id,
|
||||||
|
role: "manager",
|
||||||
|
channel: "system",
|
||||||
|
content: plannerMessage,
|
||||||
|
createdAt: timestamp,
|
||||||
|
};
|
||||||
|
state.messages.push(managerMessage);
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: session.id,
|
||||||
|
taskId: null,
|
||||||
|
source: "manager",
|
||||||
|
type: "plan.created",
|
||||||
|
payload: {
|
||||||
|
summary: result.summary,
|
||||||
|
taskIds: tasks.map((task) => task.id),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.syncAssignments();
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitionTask(
|
||||||
|
taskId: string,
|
||||||
|
status: Task["status"],
|
||||||
|
source: BossEvent["source"],
|
||||||
|
eventType: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Task {
|
||||||
|
let updated!: Task;
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.status = status;
|
||||||
|
task.updatedAt = now();
|
||||||
|
task.summary = typeof payload.summary === "string" ? payload.summary : task.summary;
|
||||||
|
|
||||||
|
if (task.assignedWorkerId) {
|
||||||
|
const worker = state.workers.find((candidate) => candidate.id === task.assignedWorkerId);
|
||||||
|
if (worker && worker.currentTaskId === task.id) {
|
||||||
|
worker.currentTaskId = null;
|
||||||
|
worker.status = "idle";
|
||||||
|
worker.updatedAt = task.updatedAt;
|
||||||
|
worker.lastSeenAt = task.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: task.sessionId,
|
||||||
|
taskId: task.id,
|
||||||
|
source,
|
||||||
|
type: eventType,
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updated = { ...task };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
this.syncAssignments();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncAssignments(): void {
|
||||||
|
const candidates = chooseAssignmentCandidates(this.getState());
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.mutate((state) => {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const task = state.tasks.find((item) => item.id === candidate.taskId);
|
||||||
|
const worker = state.workers.find((item) => item.id === candidate.workerId);
|
||||||
|
if (!task || !worker || task.status !== "queued" || worker.status !== "idle") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = now();
|
||||||
|
task.status = "assigned";
|
||||||
|
task.assignedWorkerId = worker.id;
|
||||||
|
task.updatedAt = timestamp;
|
||||||
|
task.summary = `已分配给 ${worker.name}`;
|
||||||
|
worker.status = "busy";
|
||||||
|
worker.currentTaskId = task.id;
|
||||||
|
worker.updatedAt = timestamp;
|
||||||
|
worker.lastSeenAt = timestamp;
|
||||||
|
|
||||||
|
state.events.push(
|
||||||
|
this.makeEvent({
|
||||||
|
sessionId: task.sessionId,
|
||||||
|
taskId: task.id,
|
||||||
|
source: "system",
|
||||||
|
type: "task.assigned",
|
||||||
|
payload: {
|
||||||
|
workerId: worker.id,
|
||||||
|
workerName: worker.name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.publishLatestEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishLatestEvent(): void {
|
||||||
|
const state = this.getState();
|
||||||
|
const latestEvent = state.events[state.events.length - 1];
|
||||||
|
if (latestEvent) {
|
||||||
|
this.events.publish(latestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeEvent(input: Omit<BossEvent, "id" | "timestamp">): BossEvent {
|
||||||
|
return {
|
||||||
|
id: createId("evt"),
|
||||||
|
timestamp: now(),
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
src/event-broker.ts
Normal file
19
src/event-broker.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { BossEvent } from "./types.js";
|
||||||
|
|
||||||
|
type Listener = (event: BossEvent) => void;
|
||||||
|
|
||||||
|
export class EventBroker {
|
||||||
|
private readonly listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
subscribe(listener: Listener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(event: BossEvent): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
181
src/planner.ts
Normal file
181
src/planner.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type { Session, Task } from "./types.js";
|
||||||
|
import { containsKeyword, createId, now } from "./utils.js";
|
||||||
|
|
||||||
|
interface DraftTask {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
kind: string;
|
||||||
|
requiredOs: Task["requiredOs"];
|
||||||
|
requiredCapabilities: string[];
|
||||||
|
priority: Task["priority"];
|
||||||
|
dependencyIndexes: number[];
|
||||||
|
approvalStatus: Task["approvalStatus"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannerResult {
|
||||||
|
summary: string;
|
||||||
|
tasks: DraftTask[];
|
||||||
|
pauseExistingTasks: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRequiredOs(content: string): Task["requiredOs"] {
|
||||||
|
if (containsKeyword(content, ["mac", "macos", "xcode", "ios", "swift"])) {
|
||||||
|
return "macos";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containsKeyword(content, ["windows", "win", "powershell", ".exe", "注册表"])) {
|
||||||
|
return "windows";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCapabilities(content: string): string[] {
|
||||||
|
const capabilities = ["terminal"];
|
||||||
|
|
||||||
|
if (containsKeyword(content, ["ui", "browser", "web", "playwright", "页面", "前端"])) {
|
||||||
|
capabilities.push("browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containsKeyword(content, ["test", "测试", "验证", "ci"])) {
|
||||||
|
capabilities.push("test");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(capabilities));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresApproval(content: string): boolean {
|
||||||
|
return containsKeyword(content, [
|
||||||
|
"rm -rf",
|
||||||
|
"delete",
|
||||||
|
"删除",
|
||||||
|
"force push",
|
||||||
|
"强推",
|
||||||
|
"drop database",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlan(session: Session, content: string, activeTasks: Task[]): PlannerResult {
|
||||||
|
const baseOs = inferRequiredOs(content);
|
||||||
|
const baseCapabilities = inferCapabilities(content);
|
||||||
|
const approvalStatus = requiresApproval(content) ? "pending" : "not_required";
|
||||||
|
const pauseExistingTasks = activeTasks.some((task) =>
|
||||||
|
["planning", "queued", "assigned", "running", "paused", "blocked"].includes(task.status),
|
||||||
|
);
|
||||||
|
const replan = pauseExistingTasks;
|
||||||
|
|
||||||
|
if (containsKeyword(content, ["调研", "研究", "定位", "排查", "分析"])) {
|
||||||
|
return {
|
||||||
|
summary: replan
|
||||||
|
? `已根据新要求重排调研任务:${content}`
|
||||||
|
: `已生成调研型任务树:${content}`,
|
||||||
|
pauseExistingTasks: replan,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
title: "并行收集线索与上下文",
|
||||||
|
description: `围绕目标开展初步调研:${content}`,
|
||||||
|
kind: "research",
|
||||||
|
requiredOs: "any",
|
||||||
|
requiredCapabilities: ["terminal"],
|
||||||
|
priority: "high",
|
||||||
|
dependencyIndexes: [],
|
||||||
|
approvalStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "定位问题根因或约束",
|
||||||
|
description: `在合适设备上并行定位问题:${content}`,
|
||||||
|
kind: "investigation",
|
||||||
|
requiredOs: baseOs,
|
||||||
|
requiredCapabilities: baseCapabilities,
|
||||||
|
priority: "high",
|
||||||
|
dependencyIndexes: [],
|
||||||
|
approvalStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "整理结论与执行建议",
|
||||||
|
description: "汇总两条并行调研结果,并输出下一步建议。",
|
||||||
|
kind: "summary",
|
||||||
|
requiredOs: "any",
|
||||||
|
requiredCapabilities: ["terminal"],
|
||||||
|
priority: "medium",
|
||||||
|
dependencyIndexes: [0, 1],
|
||||||
|
approvalStatus: "not_required",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: replan
|
||||||
|
? `已根据最新需求重排执行计划:${content}`
|
||||||
|
: `已生成执行型任务树:${content}`,
|
||||||
|
pauseExistingTasks: replan,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
title: "分析需求并准备执行环境",
|
||||||
|
description: `分析用户目标并确认执行边界:${content}`,
|
||||||
|
kind: "planning",
|
||||||
|
requiredOs: "any",
|
||||||
|
requiredCapabilities: ["terminal"],
|
||||||
|
priority: "high",
|
||||||
|
dependencyIndexes: [],
|
||||||
|
approvalStatus: "not_required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "实现核心变更",
|
||||||
|
description: `在匹配设备上执行主任务:${content}`,
|
||||||
|
kind: "implementation",
|
||||||
|
requiredOs: baseOs,
|
||||||
|
requiredCapabilities: baseCapabilities,
|
||||||
|
priority: "high",
|
||||||
|
dependencyIndexes: [0],
|
||||||
|
approvalStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "验证结果并整理回报",
|
||||||
|
description: "运行验证步骤,输出风险和下一步建议。",
|
||||||
|
kind: "validation",
|
||||||
|
requiredOs: "any",
|
||||||
|
requiredCapabilities: Array.from(new Set([...baseCapabilities, "test"])),
|
||||||
|
priority: "medium",
|
||||||
|
dependencyIndexes: [1],
|
||||||
|
approvalStatus: "not_required",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeTasks(sessionId: string, result: PlannerResult): Task[] {
|
||||||
|
const createdAt = now();
|
||||||
|
const placeholders = result.tasks.map((draft) => ({
|
||||||
|
id: createId("task"),
|
||||||
|
draft,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return placeholders.map(({ id, draft }) => ({
|
||||||
|
id,
|
||||||
|
sessionId,
|
||||||
|
parentTaskId: null,
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
kind: draft.kind,
|
||||||
|
status: draft.approvalStatus === "pending" ? "waiting_approval" : "queued",
|
||||||
|
priority: draft.priority,
|
||||||
|
requiredOs: draft.requiredOs,
|
||||||
|
requiredCapabilities: draft.requiredCapabilities,
|
||||||
|
dependencyIds: draft.dependencyIndexes.map((index) => placeholders[index].id),
|
||||||
|
assignedWorkerId: null,
|
||||||
|
approvalStatus: draft.approvalStatus,
|
||||||
|
progressPercent: 0,
|
||||||
|
summary: "",
|
||||||
|
currentStep: "",
|
||||||
|
nextStep: "",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPlannerMessage(summary: string): string {
|
||||||
|
return `${summary}。系统会继续调度可执行子任务,并在需要审批时暂停。`;
|
||||||
|
}
|
||||||
|
|
||||||
64
src/scheduler.ts
Normal file
64
src/scheduler.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { AppState, Task, WorkerNode } from "./types.js";
|
||||||
|
|
||||||
|
function dependenciesSatisfied(task: Task, state: AppState): boolean {
|
||||||
|
return task.dependencyIds.every((dependencyId) => {
|
||||||
|
const dependency = state.tasks.find((candidate) => candidate.id === dependencyId);
|
||||||
|
return dependency?.status === "completed";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerIsIdle(worker: WorkerNode): boolean {
|
||||||
|
return worker.status === "idle" && !worker.currentTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreWorker(task: Task, worker: WorkerNode): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (task.requiredOs === "any" || task.requiredOs === worker.os) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const capability of task.requiredCapabilities) {
|
||||||
|
if (worker.capabilities.includes(capability)) {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return score - worker.load;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseAssignmentCandidates(state: AppState): Array<{
|
||||||
|
taskId: string;
|
||||||
|
workerId: string;
|
||||||
|
}> {
|
||||||
|
const tasks = state.tasks
|
||||||
|
.filter((task) => task.status === "queued" && dependenciesSatisfied(task, state))
|
||||||
|
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||||
|
const availableWorkers = new Map(
|
||||||
|
state.workers.filter(workerIsIdle).map((worker) => [worker.id, worker]),
|
||||||
|
);
|
||||||
|
const assignments: Array<{ taskId: string; workerId: string }> = [];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
let bestWorker: WorkerNode | null = null;
|
||||||
|
let bestScore = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const worker of availableWorkers.values()) {
|
||||||
|
const score = scoreWorker(task, worker);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestWorker = worker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestWorker || bestScore < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments.push({ taskId: task.id, workerId: bestWorker.id });
|
||||||
|
availableWorkers.delete(bestWorker.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
153
src/server.ts
Normal file
153
src/server.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import { BossEngine } from "./engine.js";
|
||||||
|
|
||||||
|
const engine = new BossEngine();
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: path.resolve(process.cwd(), "public"),
|
||||||
|
prefix: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
function ok() {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/health", async () => ({
|
||||||
|
status: "ok",
|
||||||
|
sessions: engine.getState().sessions.length,
|
||||||
|
workers: engine.getState().workers.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get("/api/bootstrap", async () => engine.bootstrap());
|
||||||
|
|
||||||
|
app.get("/api/sessions", async () => engine.listSessions());
|
||||||
|
|
||||||
|
app.post("/api/sessions", async (request) => {
|
||||||
|
const body = (request.body ?? {}) as { title?: string };
|
||||||
|
return engine.createSession(body.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/sessions/:sessionId", async (request) => {
|
||||||
|
const params = request.params as { sessionId: string };
|
||||||
|
return engine.getSession(params.sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/sessions/:sessionId/messages", async (request) => {
|
||||||
|
const params = request.params as { sessionId: string };
|
||||||
|
const body = (request.body ?? {}) as { content?: string; channel?: string };
|
||||||
|
return engine.addMessage(params.sessionId, body.content ?? "", body.channel ?? "web");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/events/stream", async (_request, reply) => {
|
||||||
|
reply.raw.setHeader("Content-Type", "text/event-stream");
|
||||||
|
reply.raw.setHeader("Cache-Control", "no-cache");
|
||||||
|
reply.raw.setHeader("Connection", "keep-alive");
|
||||||
|
reply.raw.flushHeaders?.();
|
||||||
|
|
||||||
|
const unsubscribe = engine.events.subscribe((event) => {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
reply.raw.write(": ping\n\n");
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
reply.raw.on("close", () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
unsubscribe();
|
||||||
|
reply.raw.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/workers", async () => engine.getState().workers);
|
||||||
|
|
||||||
|
app.post("/api/workers/register", async (request) => {
|
||||||
|
const body = request.body as {
|
||||||
|
name?: string;
|
||||||
|
os?: "windows" | "macos" | "linux";
|
||||||
|
capabilities?: string[];
|
||||||
|
};
|
||||||
|
return engine.registerWorker({
|
||||||
|
name: body.name ?? "worker",
|
||||||
|
os: body.os ?? "linux",
|
||||||
|
capabilities: body.capabilities ?? ["terminal"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/workers/:workerId/heartbeat", async (request) => {
|
||||||
|
const params = request.params as { workerId: string };
|
||||||
|
const body = (request.body ?? {}) as { load?: number };
|
||||||
|
return engine.heartbeat(params.workerId, body.load ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/workers/:workerId/claim-next", async (request) => {
|
||||||
|
const params = request.params as { workerId: string };
|
||||||
|
return {
|
||||||
|
task: engine.claimNextTask(params.workerId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/tasks/:taskId/progress", async (request) => {
|
||||||
|
const params = request.params as { taskId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
workerId: string;
|
||||||
|
progressPercent: number;
|
||||||
|
summary: string;
|
||||||
|
currentStep: string;
|
||||||
|
nextStep: string;
|
||||||
|
};
|
||||||
|
return engine.reportProgress(params.taskId, body.workerId, {
|
||||||
|
progressPercent: body.progressPercent,
|
||||||
|
summary: body.summary,
|
||||||
|
currentStep: body.currentStep,
|
||||||
|
nextStep: body.nextStep,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/tasks/:taskId/complete", async (request) => {
|
||||||
|
const params = request.params as { taskId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
workerId: string;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
return engine.completeTask(params.taskId, body.workerId, body.summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/tasks/:taskId/fail", async (request) => {
|
||||||
|
const params = request.params as { taskId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
workerId: string;
|
||||||
|
errorMessage: string;
|
||||||
|
};
|
||||||
|
return engine.failTask(params.taskId, body.workerId, body.errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/tasks/:taskId/pause", async (request) => {
|
||||||
|
const params = request.params as { taskId: string };
|
||||||
|
return engine.pauseTask(params.taskId);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/tasks/:taskId/cancel", async (request) => {
|
||||||
|
const params = request.params as { taskId: string };
|
||||||
|
return engine.cancelTask(params.taskId);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/approvals/:approvalId/respond", async (request) => {
|
||||||
|
const params = request.params as { approvalId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
approved: boolean;
|
||||||
|
responder?: string;
|
||||||
|
};
|
||||||
|
return engine.respondApproval(params.approvalId, body.approved, body.responder ?? "user");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/demo/reset", async () => {
|
||||||
|
engine.store.reset();
|
||||||
|
return ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 43210);
|
||||||
|
await app.listen({ port, host: "0.0.0.0" });
|
||||||
61
src/store.ts
Normal file
61
src/store.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import type { AppState } from "./types.js";
|
||||||
|
|
||||||
|
function defaultState(): AppState {
|
||||||
|
return {
|
||||||
|
sessions: [],
|
||||||
|
messages: [],
|
||||||
|
tasks: [],
|
||||||
|
workers: [],
|
||||||
|
approvals: [],
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileStore {
|
||||||
|
private state: AppState;
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
this.ensureDirectory();
|
||||||
|
this.state = this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
get snapshot(): AppState {
|
||||||
|
return structuredClone(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate<T>(mutator: (state: AppState) => T): T {
|
||||||
|
const result = mutator(this.state);
|
||||||
|
this.save();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): AppState {
|
||||||
|
this.state = defaultState();
|
||||||
|
this.save();
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDirectory(): void {
|
||||||
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): AppState {
|
||||||
|
if (!existsSync(this.filePath)) {
|
||||||
|
return defaultState();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.filePath, "utf8");
|
||||||
|
return { ...defaultState(), ...(JSON.parse(raw) as AppState) };
|
||||||
|
} catch {
|
||||||
|
return defaultState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(): void {
|
||||||
|
writeFileSync(this.filePath, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
110
src/types.ts
Normal file
110
src/types.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
export type SessionStatus = "active" | "archived";
|
||||||
|
export type TaskStatus =
|
||||||
|
| "planning"
|
||||||
|
| "queued"
|
||||||
|
| "assigned"
|
||||||
|
| "running"
|
||||||
|
| "blocked"
|
||||||
|
| "paused"
|
||||||
|
| "waiting_approval"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
export type WorkerStatus = "idle" | "busy" | "offline";
|
||||||
|
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||||
|
export type RiskLevel = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: SessionStatus;
|
||||||
|
activeObjective: string;
|
||||||
|
lastPlannerSummary: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
role: "user" | "manager" | "system";
|
||||||
|
channel: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
parentTaskId: string | null;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
kind: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: "low" | "medium" | "high";
|
||||||
|
requiredOs: "any" | "windows" | "macos" | "linux";
|
||||||
|
requiredCapabilities: string[];
|
||||||
|
dependencyIds: string[];
|
||||||
|
assignedWorkerId: string | null;
|
||||||
|
approvalStatus: "not_required" | ApprovalStatus;
|
||||||
|
progressPercent: number;
|
||||||
|
summary: string;
|
||||||
|
currentStep: string;
|
||||||
|
nextStep: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
os: "windows" | "macos" | "linux";
|
||||||
|
capabilities: string[];
|
||||||
|
status: WorkerStatus;
|
||||||
|
currentTaskId: string | null;
|
||||||
|
load: number;
|
||||||
|
lastSeenAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalRequest {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
taskId: string;
|
||||||
|
kind: string;
|
||||||
|
summary: string;
|
||||||
|
riskLevel: RiskLevel;
|
||||||
|
status: ApprovalStatus;
|
||||||
|
requester: string;
|
||||||
|
responder: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossEvent {
|
||||||
|
id: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
taskId: string | null;
|
||||||
|
source: "user" | "manager" | "system" | "worker";
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
sessions: Session[];
|
||||||
|
messages: Message[];
|
||||||
|
tasks: Task[];
|
||||||
|
workers: WorkerNode[];
|
||||||
|
approvals: ApprovalRequest[];
|
||||||
|
events: BossEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDetails {
|
||||||
|
session: Session;
|
||||||
|
messages: Message[];
|
||||||
|
tasks: Task[];
|
||||||
|
approvals: ApprovalRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
15
src/utils.ts
Normal file
15
src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export function createId(prefix: string): string {
|
||||||
|
return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function now(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containsKeyword(input: string, keywords: string[]): boolean {
|
||||||
|
const normalized = input.toLowerCase();
|
||||||
|
return keywords.some((keyword) => normalized.includes(keyword.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
130
src/worker.ts
Normal file
130
src/worker.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]) {
|
||||||
|
const options = {
|
||||||
|
name: "",
|
||||||
|
os: "linux",
|
||||||
|
capabilities: ["terminal"],
|
||||||
|
server: "http://127.0.0.1:43210",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const token = argv[index];
|
||||||
|
if (token === "--name") {
|
||||||
|
options.name = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
} else if (token === "--os") {
|
||||||
|
options.os = argv[index + 1] ?? "linux";
|
||||||
|
index += 1;
|
||||||
|
} else if (token === "--capability") {
|
||||||
|
options.capabilities.push(argv[index + 1] ?? "terminal");
|
||||||
|
index += 1;
|
||||||
|
} else if (token === "--server") {
|
||||||
|
options.server = argv[index + 1] ?? options.server;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.name) {
|
||||||
|
throw new Error("Usage: npm run worker -- --name <worker-name> [--os windows|macos|linux]");
|
||||||
|
}
|
||||||
|
|
||||||
|
options.capabilities = Array.from(new Set(options.capabilities));
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(url: string, body: unknown) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulateTask(server: string, workerId: string, task: Task) {
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
progressPercent: 20,
|
||||||
|
summary: `${task.title}: 收集上下文和工作目录`,
|
||||||
|
currentStep: "prepare",
|
||||||
|
nextStep: "inspect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
progressPercent: 55,
|
||||||
|
summary: `${task.title}: 正在执行主要步骤`,
|
||||||
|
currentStep: "execute",
|
||||||
|
nextStep: "verify",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
progressPercent: 85,
|
||||||
|
summary: `${task.title}: 正在整理结果`,
|
||||||
|
currentStep: "summarize",
|
||||||
|
nextStep: "complete",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
await delay(1_500);
|
||||||
|
await postJson(`${server}/api/tasks/${task.id}/progress`, {
|
||||||
|
workerId,
|
||||||
|
...step,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(1_000);
|
||||||
|
await postJson(`${server}/api/tasks/${task.id}/complete`, {
|
||||||
|
workerId,
|
||||||
|
summary: `${task.title} 已完成。`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const worker = (await postJson(`${options.server}/api/workers/register`, {
|
||||||
|
name: options.name,
|
||||||
|
os: options.os,
|
||||||
|
capabilities: options.capabilities,
|
||||||
|
})) as { id: string; name: string };
|
||||||
|
|
||||||
|
console.log(`worker ready: ${worker.name} (${worker.id})`);
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
await postJson(`${options.server}/api/workers/${worker.id}/heartbeat`, { load: 0 });
|
||||||
|
const response = (await postJson(`${options.server}/api/workers/${worker.id}/claim-next`, {})) as {
|
||||||
|
task: Task | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.task) {
|
||||||
|
console.log(`claimed task: ${response.task.title}`);
|
||||||
|
try {
|
||||||
|
await simulateTask(options.server, worker.id, response.task);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "unknown error";
|
||||||
|
await postJson(`${options.server}/api/tasks/${response.task.id}/fail`, {
|
||||||
|
workerId: worker.id,
|
||||||
|
errorMessage: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await delay(2_500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user