feat: wire dispatch execution and device import flows
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
- `src/boss_control`:空占位目录,不参与当前运行
|
||||
- `src/boss_device_agent`:空占位目录,不参与当前运行
|
||||
|
||||
## 当前运行状态(2026-03-29)
|
||||
## 当前运行状态(2026-03-30)
|
||||
|
||||
本地:
|
||||
|
||||
@@ -100,6 +100,8 @@ Android APK:
|
||||
- 原生顶部安全区当前已补齐状态栏 inset 处理,并把首页 / 会话信息 / 群资料 / 发起群聊 / 转发目标等页面的顶部操作区域收回到可点击安全区内
|
||||
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
|
||||
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
|
||||
- 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总
|
||||
- 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口
|
||||
- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页
|
||||
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView
|
||||
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退
|
||||
@@ -173,6 +175,7 @@ device-agent 当前职责:
|
||||
- 递归扫描本机 `~/.codex/skills`,并同步到云端 `/api/v1/devices/[deviceId]/skills`
|
||||
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
|
||||
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
|
||||
- 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
|
||||
当前常驻默认值:
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
- `POST /api/v1/projects/[projectId]/attachments` 正常,已支持图片 / 视频 / 文件上传与附件消息写入
|
||||
- `POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze` 正常,已支持手动触发主 Agent 附件分析
|
||||
- `POST /api/v1/group-chats` 正常,已支持从会话首页直接发起独立群聊
|
||||
- `GET /api/v1/projects/[projectId]/dispatch-plans` 正常,已支持读取群聊最新主 Agent 推荐下发方案
|
||||
- `POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm` 正常,已支持把推荐目标确认成真正的线程执行单
|
||||
- `GET /api/v1/devices/[deviceId]/import-draft` 正常,已支持读取设备导入草稿与最新决议
|
||||
- `POST /api/v1/devices/[deviceId]/import-draft/select|review|apply` 正常,已支持设备候选线程勾选、导入决议和落地成真实聊天窗口
|
||||
- `GET /api/v1/attachments/[attachmentId]/download` 正常,已支持会话鉴权下载和 task token 下载
|
||||
- `POST /api/auth/login` 正常,会写入 `boss_session`
|
||||
- `boss_session` 当前默认保持 30 天
|
||||
@@ -129,6 +133,8 @@
|
||||
- 项目聊天页当前已经改成聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口;线程预算、handoff、运维与转发能力仍保留数据和深层活动页,但不再出现在主聊天面
|
||||
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
|
||||
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
|
||||
- 当前群聊编排主链已经补到第一阶段:群聊消息先进入主 Agent,主 Agent 生成推荐下发方案,用户确认后再创建执行单;执行完成后线程原始结果会回群,主 Agent 再追加汇总
|
||||
- 当前设备导入主链已经补到第一阶段:设备 heartbeat 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口
|
||||
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
|
||||
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发
|
||||
- 当前附件与存储配置页位于 `我的 > 附件与存储`:默认使用服务器文件存储,用户可按账号切到阿里 OSS 私有桶;下载链会优先使用附件上传时固化的 OSS 快照,避免用户后续改配置后旧附件失效
|
||||
@@ -205,6 +211,7 @@ npm run apk:debug
|
||||
- 服务器邮件栈已部署完成,应用内也已经支持 email 模式,但默认开关还没切到 email
|
||||
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
|
||||
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做
|
||||
- 设备导入主链已补上后端闭环,但前台页面还没有把“候选勾选 / 决议预览 / 应用导入”完整串到 Web 和原生 Android,需要后续 UI 接线
|
||||
- 数据库尚未替代文件存储
|
||||
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
|
||||
- 当前只支持服务器文件存储和阿里 OSS,尚未接更多对象存储或更丰富的附件详情页
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
- 写入 `data/boss-state.json`
|
||||
- 更新设备状态
|
||||
- 若 `pairingCode` 合法,则 claim 设备绑定草稿并返回 token
|
||||
- 若携带 `projectCandidates[]`,则会同步生成或刷新对应设备的 `deviceImportDraft`
|
||||
|
||||
#### `POST /api/projects/[projectId]/goals/[goalId]/toggle`
|
||||
|
||||
@@ -308,6 +309,7 @@
|
||||
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
||||
- 如本机节点未接通,可切到 `OpenAI API` 容灾账号
|
||||
- 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批
|
||||
- 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案
|
||||
|
||||
#### `GET /api/v1/projects/[projectId]/participants`
|
||||
|
||||
@@ -349,6 +351,24 @@
|
||||
- 群名会按成员线程自动生成
|
||||
- 创建完成后仍可在群资料页改名
|
||||
|
||||
#### `GET /api/v1/projects/[projectId]/dispatch-plans`
|
||||
|
||||
- 用途:读取当前群聊最近一批主 Agent 推荐下发方案
|
||||
- 当前行为:
|
||||
- 只返回当前群聊关联的 dispatch plan
|
||||
- 会附带目标线程列表、审批状态、已确认目标和最近一次确认人
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm`
|
||||
|
||||
- 用途:由用户确认主 Agent 推荐的下发目标
|
||||
- 输入:
|
||||
- `approvedTargetProjectIds[]`
|
||||
- 当前行为:
|
||||
- 只允许对 `pending_user_confirmation` 的 dispatch plan 执行确认
|
||||
- 确认后会创建真正的 `dispatchExecution`
|
||||
- 会写入一条 `kind=system_notice` 的群聊系统消息
|
||||
- 同时会为每个执行单创建 `taskType=dispatch_execution` 的主 Agent 任务,等待对应设备的 local-agent 认领
|
||||
|
||||
#### `GET /api/v1/accounts`
|
||||
|
||||
- 用途:返回 AI 账号列表、当前主控身份和切换历史
|
||||
@@ -514,6 +534,38 @@
|
||||
|
||||
- 用途:修改设备名称、头像、账号、状态、endpoint、备注和项目列表
|
||||
|
||||
#### `GET /api/v1/devices/[deviceId]/import-draft`
|
||||
|
||||
- 用途:读取某台设备最新的项目候选导入草稿
|
||||
- 当前行为:
|
||||
- 返回最新 `deviceImportDraft`
|
||||
- 如果已经做过导入决议,还会一并返回最新 `deviceImportResolution`
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/import-draft/select`
|
||||
|
||||
- 用途:提交用户勾选的候选线程
|
||||
- 输入:
|
||||
- `selectedCandidateIds[]`
|
||||
- 当前行为:
|
||||
- 只接受当前导入草稿里真实存在的候选项
|
||||
- 提交后会把草稿推进到 `pending_resolution`
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/import-draft/review`
|
||||
|
||||
- 用途:生成主 Agent 风格的设备导入决议
|
||||
- 当前行为:
|
||||
- 会基于已勾选候选生成 `deviceImportResolution`
|
||||
- 当前决议为服务端同步 heuristic 版
|
||||
- 决议会区分 `create_thread_conversation | attach_existing | skip`
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/import-draft/apply`
|
||||
|
||||
- 用途:应用导入决议,把选中的线程真正落成聊天窗口
|
||||
- 当前行为:
|
||||
- `create_thread_conversation` 会生成新的单线程会话
|
||||
- `attach_existing` 会补充现有会话的设备 / 线程映射
|
||||
- 应用后草稿和决议都会变成 `applied`
|
||||
|
||||
#### `GET /api/v1/devices/[deviceId]/skills`
|
||||
|
||||
- 用途:读取指定设备已经同步上来的 Skill 列表
|
||||
@@ -628,8 +680,13 @@
|
||||
- `replyBody`
|
||||
- `errorMessage`
|
||||
- `requestId`
|
||||
- `dispatchExecutionId`
|
||||
- `targetProjectId`
|
||||
- `targetThreadId`
|
||||
- `rawThreadReply`
|
||||
- 当前行为:
|
||||
- `completed` 时把真实主 Agent 回复写回 `master-agent` 项目消息账本
|
||||
- `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态
|
||||
- `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态
|
||||
- 当前保护:要求 `x-boss-device-token` 或匹配登录会话
|
||||
|
||||
@@ -661,6 +718,8 @@
|
||||
- local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim`
|
||||
- 认领到任务后会执行本机 `codex exec`
|
||||
- 执行完成后会调用 `POST /api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 对群聊下发链路,认领到的 `dispatch_execution` 任务会带 `dispatchExecutionId / targetProjectId / targetThreadId`
|
||||
- local-agent 回写完成时会同时带上 `rawThreadReply`,服务端据此把线程原始结果和主 Agent 汇总回写到群聊
|
||||
|
||||
## 5. 当前状态存储
|
||||
|
||||
@@ -687,6 +746,10 @@
|
||||
- `deviceSkills`
|
||||
- `appLogs`
|
||||
- `masterAgentTasks`
|
||||
- `dispatchPlans`
|
||||
- `dispatchExecutions`
|
||||
- `deviceImportDrafts`
|
||||
- `deviceImportResolutions`
|
||||
- `opsFaults`
|
||||
- `opsRepairTickets`
|
||||
- `opsRepairVerifications`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boss 当前运行与部署状态
|
||||
|
||||
更新时间:`2026-03-29`
|
||||
更新时间:`2026-03-30`
|
||||
|
||||
## 1. 本地状态
|
||||
|
||||
@@ -97,6 +97,8 @@ cd /Users/kris/code/boss
|
||||
- 项目详情页当前已补齐微信式消息转发:长按消息会弹出 `转发 / 多选 / 复制 / 删除 / 取消`;单条消息直接进入统一会话选择页,多选消息会进入合并转发链路
|
||||
- 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话`
|
||||
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
|
||||
- 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总
|
||||
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
|
||||
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
|
||||
@@ -136,6 +138,8 @@ cd /Users/kris/code/boss
|
||||
- `2.5.3` 已把聊天页里自己发出的消息顶部元信息收成只显示时间,不再重复 `你 · 时间`,文本消息、附件卡片和聊天记录卡片都会共用这条规则
|
||||
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
|
||||
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
|
||||
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
|
||||
- 当前设备导入决议仍是服务端同步 heuristic 版,下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
@@ -216,6 +220,8 @@ cd /Users/kris/code/boss
|
||||
- APP 实时日志当前已能同步到主 Agent 会话,但还没有单独的日志检索、分页和告警升级规则
|
||||
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力
|
||||
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
|
||||
- 设备导入主链的后端状态机已经跑通;下一阶段重点从“纯后端打通”切到“Web / Android 把候选勾选、决议预览和应用导入完整接到前台”
|
||||
- 设备导入的后端主链已经打通,但 Web / Android 仍未把“候选项目勾选与导入应用”完整接进前台页面;当前主要可通过 API 和后续 UI 接线验证
|
||||
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 页面自行配置 `OpenAI API` 账号,不再依赖服务器预置 Key
|
||||
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
|
||||
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
|
||||
|
||||
@@ -37,6 +37,22 @@
|
||||
|
||||
本轮开发默认先做 `WP1 + WP2`,随后再进入 `WP3 + WP4`。
|
||||
|
||||
截至 `2026-03-30` 的最新进度补充:
|
||||
|
||||
- `WP1` 已持续回写到 `README.md`、`current_runtime_and_deploy_status_cn.md`、`api_and_service_inventory_cn.md`
|
||||
- `WP2` 已完成到“可运行闭环”的第一阶段:
|
||||
- 群聊文本消息会先进入主 Agent 生成 `dispatchPlan`
|
||||
- 用户已可通过确认接口批准目标线程
|
||||
- 系统会创建真正的 `dispatchExecution`
|
||||
- local-agent 已可认领 `dispatch_execution` 任务
|
||||
- 执行完成后会把线程原始结果回群,并补一条主 Agent 汇总
|
||||
- `WP3` 已完成到“后端闭环第一阶段”:
|
||||
- 设备 heartbeat 可上报真实项目候选
|
||||
- 服务端会生成 `deviceImportDraft`
|
||||
- 用户可提交候选勾选结果
|
||||
- 系统可生成导入决议并应用到真实聊天窗口
|
||||
- 下一阶段重点转为 Web / Android 页面接线和真机回归
|
||||
|
||||
适用范围:
|
||||
|
||||
- 单主 Agent
|
||||
|
||||
@@ -26,6 +26,7 @@ async function postHeartbeat(config, runtime) {
|
||||
quota5h: config.quota5h,
|
||||
quota7d: config.quota7d,
|
||||
projects: config.projects,
|
||||
projectCandidates: config.projectCandidates,
|
||||
endpoint: config.endpoint,
|
||||
}),
|
||||
});
|
||||
@@ -156,7 +157,7 @@ async function discoverSkills(config) {
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async function postSkills(config, skills) {
|
||||
async function postSkills(config, runtime, skills) {
|
||||
const response = await fetch(
|
||||
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skills`,
|
||||
{
|
||||
@@ -177,7 +178,7 @@ async function postSkills(config, skills) {
|
||||
};
|
||||
}
|
||||
|
||||
async function postAppLog(config, payload) {
|
||||
async function postAppLog(config, runtime, payload) {
|
||||
try {
|
||||
await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/app-logs`, {
|
||||
method: "POST",
|
||||
@@ -231,6 +232,10 @@ async function completeMasterAgentTask(config, runtime, payload) {
|
||||
replyBody: payload.replyBody,
|
||||
errorMessage: payload.errorMessage,
|
||||
requestId: payload.requestId,
|
||||
dispatchExecutionId: payload.dispatchExecutionId,
|
||||
targetProjectId: payload.targetProjectId,
|
||||
targetThreadId: payload.targetThreadId,
|
||||
rawThreadReply: payload.rawThreadReply,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -261,6 +266,42 @@ function buildCodexArgs(config, outputFile, prompt) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function parseDispatchExecutionCompletion(rawOutput) {
|
||||
const trimmed = String(rawOutput || "").trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
rawThreadReply: "",
|
||||
replyBody: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
const jsonCandidate = fencedMatch?.[1]?.trim() ?? trimmed;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonCandidate);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const rawThreadReply =
|
||||
typeof parsed.rawThreadReply === "string" ? parsed.rawThreadReply.trim() : "";
|
||||
const replyBody =
|
||||
typeof parsed.replyBody === "string" ? parsed.replyBody.trim() : undefined;
|
||||
if (rawThreadReply) {
|
||||
return {
|
||||
rawThreadReply,
|
||||
replyBody: replyBody || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to treating the full output as the raw thread reply.
|
||||
}
|
||||
|
||||
return {
|
||||
rawThreadReply: trimmed,
|
||||
replyBody: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMasterAgentTask(config, runtime, task) {
|
||||
const outputFile = join(os.tmpdir(), `${task.taskId}.reply.txt`);
|
||||
const stderrChunks = [];
|
||||
@@ -292,10 +333,18 @@ async function runMasterAgentTask(config, runtime, task) {
|
||||
});
|
||||
|
||||
const replyBody = (await readFile(outputFile, "utf8")).trim();
|
||||
const dispatchExecutionCompletion =
|
||||
task.taskType === "dispatch_execution"
|
||||
? parseDispatchExecutionCompletion(replyBody)
|
||||
: null;
|
||||
const completion = await completeMasterAgentTask(config, runtime, {
|
||||
taskId: task.taskId,
|
||||
status: "completed",
|
||||
replyBody,
|
||||
replyBody: dispatchExecutionCompletion?.replyBody ?? replyBody,
|
||||
dispatchExecutionId: task.dispatchExecutionId,
|
||||
targetProjectId: task.targetProjectId,
|
||||
targetThreadId: task.targetThreadId,
|
||||
rawThreadReply: dispatchExecutionCompletion?.rawThreadReply,
|
||||
});
|
||||
runtime.activeMasterTask = {
|
||||
taskId: task.taskId,
|
||||
@@ -303,7 +352,7 @@ async function runMasterAgentTask(config, runtime, task) {
|
||||
completedAt: new Date().toISOString(),
|
||||
detail: completion.body,
|
||||
};
|
||||
await postAppLog(config, {
|
||||
await postAppLog(config, runtime, {
|
||||
projectId: "master-agent",
|
||||
level: "info",
|
||||
category: "local_agent.master_agent_task_completed",
|
||||
@@ -323,8 +372,11 @@ async function runMasterAgentTask(config, runtime, task) {
|
||||
taskId: task.taskId,
|
||||
status: "failed",
|
||||
errorMessage: detail,
|
||||
dispatchExecutionId: task.dispatchExecutionId,
|
||||
targetProjectId: task.targetProjectId,
|
||||
targetThreadId: task.targetThreadId,
|
||||
}).catch(() => null);
|
||||
await postAppLog(config, {
|
||||
await postAppLog(config, runtime, {
|
||||
projectId: "master-agent",
|
||||
level: "error",
|
||||
category: "local_agent.master_agent_task_failed",
|
||||
@@ -415,7 +467,7 @@ async function heartbeat() {
|
||||
runtime.issuedToken = result.json.token;
|
||||
}
|
||||
if (!result.ok) {
|
||||
await postAppLog(config, {
|
||||
await postAppLog(config, runtime, {
|
||||
level: "error",
|
||||
category: "local_agent.heartbeat_failed",
|
||||
message: "local-agent 心跳返回失败。",
|
||||
@@ -430,7 +482,7 @@ async function heartbeat() {
|
||||
const threadResult = await postThreadContext(config, runtime, snapshot);
|
||||
runtime.lastThreadContextResults.push(threadResult);
|
||||
if (!threadResult.ok) {
|
||||
await postAppLog(config, {
|
||||
await postAppLog(config, runtime, {
|
||||
projectId: snapshot.projectId,
|
||||
level: "error",
|
||||
category: "local_agent.thread_context_failed",
|
||||
@@ -444,7 +496,7 @@ async function heartbeat() {
|
||||
try {
|
||||
const skills = await discoverSkills(config);
|
||||
runtime.lastSkills = skills;
|
||||
const skillSyncResult = await postSkills(config, skills);
|
||||
const skillSyncResult = await postSkills(config, runtime, skills);
|
||||
runtime.lastSkillSyncAt = new Date().toISOString();
|
||||
runtime.lastSkillSyncOk = skillSyncResult.ok;
|
||||
runtime.lastSkillSyncStatus = skillSyncResult.status;
|
||||
@@ -454,7 +506,7 @@ async function heartbeat() {
|
||||
runtime.lastSkillSyncOk = false;
|
||||
runtime.lastSkillSyncStatus = 0;
|
||||
runtime.lastSkillSyncBody = error instanceof Error ? error.message : String(error);
|
||||
await postAppLog(config, {
|
||||
await postAppLog(config, runtime, {
|
||||
level: "error",
|
||||
category: "local_agent.skills_sync_failed",
|
||||
message: "Skill 扫描或同步失败。",
|
||||
@@ -467,7 +519,7 @@ async function heartbeat() {
|
||||
runtime.lastHeartbeatOk = false;
|
||||
runtime.lastHeartbeatStatus = 0;
|
||||
runtime.lastHeartbeatBody = error instanceof Error ? error.message : String(error);
|
||||
await postAppLog(config, {
|
||||
await postAppLog(config, runtime, {
|
||||
level: "error",
|
||||
category: "local_agent.heartbeat_exception",
|
||||
message: "local-agent 心跳执行异常。",
|
||||
|
||||
@@ -13,6 +13,16 @@ export async function POST(request: NextRequest) {
|
||||
quota5h?: number;
|
||||
quota7d?: number;
|
||||
projects?: string[];
|
||||
projectCandidates?: Array<{
|
||||
folderName?: string;
|
||||
folderRef?: string;
|
||||
threadId?: string;
|
||||
threadDisplayName?: string;
|
||||
codexFolderRef?: string;
|
||||
codexThreadRef?: string;
|
||||
lastActiveAt?: string;
|
||||
suggestedImport?: boolean;
|
||||
}>;
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
@@ -38,6 +48,21 @@ export async function POST(request: NextRequest) {
|
||||
quota5h: body.quota5h ?? 0,
|
||||
quota7d: body.quota7d ?? 0,
|
||||
projects: body.projects,
|
||||
projectCandidates: (body.projectCandidates ?? []).filter(
|
||||
(candidate) =>
|
||||
candidate.folderName?.trim() &&
|
||||
candidate.threadId?.trim() &&
|
||||
candidate.threadDisplayName?.trim(),
|
||||
) as Array<{
|
||||
folderName: string;
|
||||
folderRef?: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
codexFolderRef?: string;
|
||||
codexThreadRef?: string;
|
||||
lastActiveAt?: string;
|
||||
suggestedImport?: boolean;
|
||||
}>,
|
||||
endpoint: body.endpoint,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { applyDeviceImportResolution } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { deviceId } = await context.params;
|
||||
|
||||
try {
|
||||
const result = await applyDeviceImportResolution({
|
||||
deviceId,
|
||||
appliedBy: session.account,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { resolveDeviceImportDraft } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { deviceId } = await context.params;
|
||||
|
||||
try {
|
||||
const result = await resolveDeviceImportDraft({
|
||||
deviceId,
|
||||
reviewedBy: session.account,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/app/api/v1/devices/[deviceId]/import-draft/route.ts
Normal file
17
src/app/api/v1/devices/[deviceId]/import-draft/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getLatestDeviceImportDraft } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { deviceId } = await context.params;
|
||||
const result = await getLatestDeviceImportDraft(deviceId);
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { selectDeviceImportCandidates } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
selectedCandidateIds?: string[];
|
||||
};
|
||||
const { deviceId } = await context.params;
|
||||
|
||||
try {
|
||||
const draft = await selectDeviceImportCandidates({
|
||||
deviceId,
|
||||
selectedCandidateIds: body.selectedCandidateIds ?? [],
|
||||
selectedBy: session.account,
|
||||
});
|
||||
return NextResponse.json({ ok: true, draft });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ export async function POST(
|
||||
replyBody?: string;
|
||||
errorMessage?: string;
|
||||
requestId?: string;
|
||||
dispatchExecutionId?: string;
|
||||
targetProjectId?: string;
|
||||
targetThreadId?: string;
|
||||
rawThreadReply?: string;
|
||||
};
|
||||
|
||||
if (!body.deviceId?.trim()) {
|
||||
@@ -33,6 +37,10 @@ export async function POST(
|
||||
replyBody: body.replyBody,
|
||||
errorMessage: body.errorMessage,
|
||||
requestId: body.requestId,
|
||||
dispatchExecutionId: body.dispatchExecutionId,
|
||||
targetProjectId: body.targetProjectId,
|
||||
targetThreadId: body.targetThreadId,
|
||||
rawThreadReply: body.rawThreadReply,
|
||||
});
|
||||
return NextResponse.json({ ok: true, task });
|
||||
} catch (error) {
|
||||
|
||||
@@ -134,7 +134,9 @@ export type MasterAgentTaskStatus = "queued" | "running" | "completed" | "failed
|
||||
export type MasterAgentTaskType =
|
||||
| "conversation_reply"
|
||||
| "attachment_analysis"
|
||||
| "group_dispatch_plan";
|
||||
| "group_dispatch_plan"
|
||||
| "dispatch_execution"
|
||||
| "device_import_resolution";
|
||||
export type DispatchPlanStatus =
|
||||
| "pending_user_confirmation"
|
||||
| "approved"
|
||||
@@ -312,6 +314,54 @@ export interface DispatchExecution {
|
||||
completedByDeviceId?: string;
|
||||
}
|
||||
|
||||
export interface DeviceImportCandidate {
|
||||
candidateId: string;
|
||||
deviceId: string;
|
||||
folderName: string;
|
||||
folderRef?: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
codexFolderRef?: string;
|
||||
codexThreadRef?: string;
|
||||
lastActiveAt: string;
|
||||
suggestedImport: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceImportDraft {
|
||||
draftId: string;
|
||||
deviceId: string;
|
||||
enrollmentId?: string;
|
||||
status: "pending_candidates" | "pending_selection" | "pending_resolution" | "resolved" | "applied";
|
||||
candidates: DeviceImportCandidate[];
|
||||
selectedCandidateIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
reviewedAt?: string;
|
||||
reviewedBy?: string;
|
||||
resolutionId?: string;
|
||||
}
|
||||
|
||||
export interface DeviceImportResolutionItem {
|
||||
candidateId: string;
|
||||
action: "create_thread_conversation" | "attach_existing" | "skip";
|
||||
threadDisplayName: string;
|
||||
folderName: string;
|
||||
targetProjectId?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DeviceImportResolution {
|
||||
resolutionId: string;
|
||||
draftId: string;
|
||||
deviceId: string;
|
||||
status: "ready" | "applied";
|
||||
summary: string;
|
||||
items: DeviceImportResolutionItem[];
|
||||
createdAt: string;
|
||||
appliedAt?: string;
|
||||
appliedBy?: string;
|
||||
}
|
||||
|
||||
export interface VerificationCode {
|
||||
id: string;
|
||||
account: string;
|
||||
@@ -467,6 +517,11 @@ export interface MasterAgentTask {
|
||||
attachmentDownloadExpiresAt?: string;
|
||||
attachmentDownloadUrl?: string;
|
||||
attachmentTextExcerpt?: string;
|
||||
dispatchExecutionId?: string;
|
||||
targetProjectId?: string;
|
||||
targetThreadId?: string;
|
||||
targetThreadDisplayName?: string;
|
||||
deviceImportDraftId?: string;
|
||||
status: MasterAgentTaskStatus;
|
||||
requestedAt: string;
|
||||
claimedAt?: string;
|
||||
@@ -718,6 +773,8 @@ export interface BossState {
|
||||
masterAgentTasks: MasterAgentTask[];
|
||||
dispatchPlans: DispatchPlan[];
|
||||
dispatchExecutions: DispatchExecution[];
|
||||
deviceImportDrafts: DeviceImportDraft[];
|
||||
deviceImportResolutions: DeviceImportResolution[];
|
||||
otaUpdates: OtaUpdate[];
|
||||
otaUpdateLogs: OtaUpdateLog[];
|
||||
deviceSkills: DeviceSkill[];
|
||||
@@ -1140,6 +1197,8 @@ const initialState: BossState = {
|
||||
masterAgentTasks: [],
|
||||
dispatchPlans: [],
|
||||
dispatchExecutions: [],
|
||||
deviceImportDrafts: [],
|
||||
deviceImportResolutions: [],
|
||||
otaUpdates: [
|
||||
{
|
||||
releaseId: "ota_140_to_141",
|
||||
@@ -1760,6 +1819,125 @@ function normalizeDispatchExecution(
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeviceImportCandidateId(input: {
|
||||
deviceId: string;
|
||||
folderName: string;
|
||||
threadId: string;
|
||||
codexFolderRef?: string;
|
||||
codexThreadRef?: string;
|
||||
}) {
|
||||
const signature = [
|
||||
input.deviceId,
|
||||
input.codexFolderRef?.trim() || input.folderName.trim(),
|
||||
input.codexThreadRef?.trim() || input.threadId.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
return `import-${slugify(signature)}`;
|
||||
}
|
||||
|
||||
function normalizeDeviceImportCandidate(
|
||||
raw: Partial<DeviceImportCandidate>,
|
||||
fallback?: DeviceImportCandidate,
|
||||
): DeviceImportCandidate {
|
||||
const deviceId = raw.deviceId ?? fallback?.deviceId ?? "";
|
||||
const folderName = raw.folderName ?? fallback?.folderName ?? "";
|
||||
const threadId = raw.threadId ?? fallback?.threadId ?? "";
|
||||
return {
|
||||
candidateId:
|
||||
raw.candidateId ??
|
||||
fallback?.candidateId ??
|
||||
buildDeviceImportCandidateId({
|
||||
deviceId,
|
||||
folderName,
|
||||
threadId,
|
||||
codexFolderRef: raw.codexFolderRef ?? fallback?.codexFolderRef,
|
||||
codexThreadRef: raw.codexThreadRef ?? fallback?.codexThreadRef,
|
||||
}),
|
||||
deviceId,
|
||||
folderName,
|
||||
folderRef: raw.folderRef ?? fallback?.folderRef,
|
||||
threadId,
|
||||
threadDisplayName: raw.threadDisplayName ?? fallback?.threadDisplayName ?? threadId,
|
||||
codexFolderRef: raw.codexFolderRef ?? fallback?.codexFolderRef,
|
||||
codexThreadRef: raw.codexThreadRef ?? fallback?.codexThreadRef,
|
||||
lastActiveAt: raw.lastActiveAt ?? fallback?.lastActiveAt ?? nowIso(),
|
||||
suggestedImport: raw.suggestedImport ?? fallback?.suggestedImport ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeviceImportDraft(
|
||||
raw: Partial<DeviceImportDraft>,
|
||||
fallback?: DeviceImportDraft,
|
||||
): DeviceImportDraft {
|
||||
const fallbackCandidates = fallback?.candidates ?? [];
|
||||
return {
|
||||
draftId: raw.draftId ?? fallback?.draftId ?? randomToken("import-draft"),
|
||||
deviceId: raw.deviceId ?? fallback?.deviceId ?? "",
|
||||
enrollmentId: raw.enrollmentId ?? fallback?.enrollmentId,
|
||||
status: raw.status ?? fallback?.status ?? "pending_candidates",
|
||||
candidates: ensureArray(
|
||||
raw.candidates as Partial<DeviceImportCandidate>[] | undefined,
|
||||
fallbackCandidates,
|
||||
).map((candidate, index) =>
|
||||
normalizeDeviceImportCandidate(
|
||||
candidate,
|
||||
fallbackCandidates[index % Math.max(1, fallbackCandidates.length)],
|
||||
),
|
||||
),
|
||||
selectedCandidateIds: dedupeStrings(
|
||||
ensureArray(raw.selectedCandidateIds, fallback?.selectedCandidateIds ?? []),
|
||||
),
|
||||
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
|
||||
updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(),
|
||||
reviewedAt: raw.reviewedAt ?? fallback?.reviewedAt,
|
||||
reviewedBy: raw.reviewedBy ?? fallback?.reviewedBy,
|
||||
resolutionId: raw.resolutionId ?? fallback?.resolutionId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeviceImportResolution(
|
||||
raw: Partial<DeviceImportResolution>,
|
||||
fallback?: DeviceImportResolution,
|
||||
): DeviceImportResolution {
|
||||
const fallbackItems = fallback?.items ?? [];
|
||||
return {
|
||||
resolutionId: raw.resolutionId ?? fallback?.resolutionId ?? randomToken("import-resolution"),
|
||||
draftId: raw.draftId ?? fallback?.draftId ?? "",
|
||||
deviceId: raw.deviceId ?? fallback?.deviceId ?? "",
|
||||
status: raw.status ?? fallback?.status ?? "ready",
|
||||
summary: raw.summary ?? fallback?.summary ?? "",
|
||||
items: ensureArray(
|
||||
raw.items as Partial<DeviceImportResolutionItem>[] | undefined,
|
||||
fallbackItems,
|
||||
).map((item, index) => ({
|
||||
candidateId: item.candidateId ?? fallbackItems[index % Math.max(1, fallbackItems.length)]?.candidateId ?? "",
|
||||
action:
|
||||
item.action ??
|
||||
fallbackItems[index % Math.max(1, fallbackItems.length)]?.action ??
|
||||
"skip",
|
||||
threadDisplayName:
|
||||
item.threadDisplayName ??
|
||||
fallbackItems[index % Math.max(1, fallbackItems.length)]?.threadDisplayName ??
|
||||
"",
|
||||
folderName:
|
||||
item.folderName ??
|
||||
fallbackItems[index % Math.max(1, fallbackItems.length)]?.folderName ??
|
||||
"",
|
||||
targetProjectId:
|
||||
item.targetProjectId ??
|
||||
fallbackItems[index % Math.max(1, fallbackItems.length)]?.targetProjectId,
|
||||
reason:
|
||||
item.reason ??
|
||||
fallbackItems[index % Math.max(1, fallbackItems.length)]?.reason ??
|
||||
"",
|
||||
})),
|
||||
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
|
||||
appliedAt: raw.appliedAt ?? fallback?.appliedAt,
|
||||
appliedBy: raw.appliedBy ?? fallback?.appliedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[]) {
|
||||
return [...new Set(values.filter((value) => Boolean(value)))];
|
||||
}
|
||||
@@ -2424,6 +2602,11 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
attachmentDownloadExpiresAt: task.attachmentDownloadExpiresAt,
|
||||
attachmentDownloadUrl: task.attachmentDownloadUrl,
|
||||
attachmentTextExcerpt: task.attachmentTextExcerpt,
|
||||
dispatchExecutionId: task.dispatchExecutionId,
|
||||
targetProjectId: task.targetProjectId,
|
||||
targetThreadId: task.targetThreadId,
|
||||
targetThreadDisplayName: task.targetThreadDisplayName,
|
||||
deviceImportDraftId: task.deviceImportDraftId,
|
||||
status: task.status ?? "queued",
|
||||
requestedAt: task.requestedAt ?? nowIso(),
|
||||
claimedAt: task.claimedAt,
|
||||
@@ -2441,6 +2624,21 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
base.dispatchExecutions[index % Math.max(1, base.dispatchExecutions.length)],
|
||||
),
|
||||
),
|
||||
deviceImportDrafts: ensureArray(raw.deviceImportDrafts, base.deviceImportDrafts).map((draft, index) =>
|
||||
normalizeDeviceImportDraft(
|
||||
draft,
|
||||
base.deviceImportDrafts[index % Math.max(1, base.deviceImportDrafts.length)],
|
||||
),
|
||||
),
|
||||
deviceImportResolutions: ensureArray(
|
||||
raw.deviceImportResolutions,
|
||||
base.deviceImportResolutions,
|
||||
).map((resolution, index) =>
|
||||
normalizeDeviceImportResolution(
|
||||
resolution,
|
||||
base.deviceImportResolutions[index % Math.max(1, base.deviceImportResolutions.length)],
|
||||
),
|
||||
),
|
||||
otaUpdates: ensureArray(raw.otaUpdates, base.otaUpdates).map((update, index) => ({
|
||||
...base.otaUpdates[index % base.otaUpdates.length],
|
||||
...update,
|
||||
@@ -2928,6 +3126,12 @@ function syncDerivedState(input: BossState) {
|
||||
state.dispatchExecutions = state.dispatchExecutions
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 160);
|
||||
state.deviceImportDrafts = state.deviceImportDrafts
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.slice(0, 40);
|
||||
state.deviceImportResolutions = state.deviceImportResolutions
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 80);
|
||||
|
||||
state.devices = state.devices.filter(isProductionDevice);
|
||||
const visibleDeviceIds = new Set(state.devices.map((device) => device.id));
|
||||
@@ -2942,6 +3146,13 @@ function syncDerivedState(input: BossState) {
|
||||
visibleThreadIds.has(item.threadId),
|
||||
);
|
||||
state.deviceEnrollments = state.deviceEnrollments.filter((item) => visibleDeviceIds.has(item.deviceId));
|
||||
state.deviceImportDrafts = state.deviceImportDrafts.filter((item) =>
|
||||
visibleDeviceIds.has(item.deviceId),
|
||||
);
|
||||
const visibleImportDraftIds = new Set(state.deviceImportDrafts.map((item) => item.draftId));
|
||||
state.deviceImportResolutions = state.deviceImportResolutions.filter(
|
||||
(item) => visibleDeviceIds.has(item.deviceId) && visibleImportDraftIds.has(item.draftId),
|
||||
);
|
||||
state.deviceSkills = state.deviceSkills
|
||||
.filter((skill) => visibleDeviceIds.has(skill.deviceId))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
@@ -3773,6 +3984,10 @@ export async function queueMasterAgentTask(payload: {
|
||||
attachmentDownloadExpiresAt?: string;
|
||||
attachmentDownloadUrl?: string;
|
||||
attachmentTextExcerpt?: string;
|
||||
dispatchExecutionId?: string;
|
||||
targetProjectId?: string;
|
||||
targetThreadId?: string;
|
||||
targetThreadDisplayName?: string;
|
||||
}) {
|
||||
const task = await mutateState((state) => {
|
||||
const task: MasterAgentTask = {
|
||||
@@ -3793,6 +4008,10 @@ export async function queueMasterAgentTask(payload: {
|
||||
attachmentDownloadExpiresAt: payload.attachmentDownloadExpiresAt,
|
||||
attachmentDownloadUrl: payload.attachmentDownloadUrl,
|
||||
attachmentTextExcerpt: payload.attachmentTextExcerpt,
|
||||
dispatchExecutionId: payload.dispatchExecutionId,
|
||||
targetProjectId: payload.targetProjectId,
|
||||
targetThreadId: payload.targetThreadId,
|
||||
targetThreadDisplayName: payload.targetThreadDisplayName,
|
||||
status: "queued",
|
||||
requestedAt: nowIso(),
|
||||
};
|
||||
@@ -3953,6 +4172,7 @@ export async function createDispatchExecutionsFromPlan(input: {
|
||||
if (plan.status !== "dispatched") {
|
||||
plan.status = "dispatched";
|
||||
}
|
||||
ensureDispatchExecutionTasksInState(state, plan, existingExecutions);
|
||||
return existingExecutions;
|
||||
}
|
||||
|
||||
@@ -3977,11 +4197,116 @@ export async function createDispatchExecutionsFromPlan(input: {
|
||||
state.dispatchExecutions.unshift(execution);
|
||||
return execution;
|
||||
});
|
||||
ensureDispatchExecutionTasksInState(state, plan, executions);
|
||||
plan.status = "dispatched";
|
||||
return executions;
|
||||
});
|
||||
}
|
||||
|
||||
function buildDispatchExecutionPrompt(input: {
|
||||
groupProject: Project;
|
||||
plan: DispatchPlan;
|
||||
target: DispatchPlanTarget;
|
||||
}) {
|
||||
const requestMessage = input.groupProject.messages.find(
|
||||
(message) => message.id === input.plan.requestMessageId,
|
||||
);
|
||||
const requestText = requestMessage?.body ?? input.plan.summary;
|
||||
return [
|
||||
"你正在执行 Boss 控制台的线程分发任务。",
|
||||
"你的输出必须是 JSON,并且只能包含两个字符串字段:rawThreadReply、replyBody。",
|
||||
"rawThreadReply:写成目标线程直接回到群里的原始结果,不要冒充主 Agent。",
|
||||
"replyBody:写成主 Agent 给群里的简短汇总,必须保留“主 Agent 汇总:”前缀。",
|
||||
"不要输出 Markdown 代码块,不要输出额外解释。",
|
||||
`groupProjectId: ${input.groupProject.id}`,
|
||||
`groupProjectName: ${input.groupProject.name}`,
|
||||
`threadProjectId: ${input.target.projectId}`,
|
||||
`threadId: ${input.target.threadId}`,
|
||||
`threadTitle: ${input.target.threadDisplayName}`,
|
||||
`folderName: ${input.target.folderName}`,
|
||||
`requestText: ${requestText}`,
|
||||
`dispatchSummary: ${input.plan.summary}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function ensureDispatchExecutionTaskInState(
|
||||
state: BossState,
|
||||
plan: DispatchPlan,
|
||||
execution: DispatchExecution,
|
||||
) {
|
||||
const groupProject = state.projects.find((item) => item.id === execution.groupProjectId);
|
||||
if (!groupProject) {
|
||||
throw new Error("DISPATCH_EXECUTION_GROUP_PROJECT_NOT_FOUND");
|
||||
}
|
||||
const target = plan.targets.find(
|
||||
(item) =>
|
||||
item.projectId === execution.targetProjectId &&
|
||||
item.threadId === execution.targetThreadId &&
|
||||
item.deviceId === execution.deviceId,
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error("DISPATCH_EXECUTION_TARGET_NOT_FOUND");
|
||||
}
|
||||
|
||||
const existing = state.masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.taskType === "dispatch_execution" &&
|
||||
(task.dispatchExecutionId === execution.executionId ||
|
||||
(task.projectId === execution.groupProjectId &&
|
||||
task.requestMessageId === plan.planId &&
|
||||
task.targetProjectId === execution.targetProjectId &&
|
||||
task.targetThreadId === execution.targetThreadId)),
|
||||
);
|
||||
if (existing) {
|
||||
existing.dispatchExecutionId = existing.dispatchExecutionId ?? execution.executionId;
|
||||
existing.targetProjectId = existing.targetProjectId ?? execution.targetProjectId;
|
||||
existing.targetThreadId = existing.targetThreadId ?? execution.targetThreadId;
|
||||
existing.targetThreadDisplayName = existing.targetThreadDisplayName ?? target.threadDisplayName;
|
||||
existing.executionPrompt =
|
||||
existing.executionPrompt ||
|
||||
buildDispatchExecutionPrompt({
|
||||
groupProject,
|
||||
plan,
|
||||
target,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
const requestedBy = plan.confirmedBy ?? plan.requestedBy;
|
||||
const requestMessage = groupProject.messages.find((message) => message.id === plan.requestMessageId);
|
||||
const task: MasterAgentTask = {
|
||||
taskId: randomToken("mastertask"),
|
||||
projectId: execution.groupProjectId,
|
||||
taskType: "dispatch_execution",
|
||||
requestMessageId: plan.planId,
|
||||
requestText: requestMessage?.body ?? plan.summary,
|
||||
executionPrompt: buildDispatchExecutionPrompt({
|
||||
groupProject,
|
||||
plan,
|
||||
target,
|
||||
}),
|
||||
requestedBy,
|
||||
requestedByAccount: requestedBy,
|
||||
deviceId: execution.deviceId,
|
||||
dispatchExecutionId: execution.executionId,
|
||||
targetProjectId: execution.targetProjectId,
|
||||
targetThreadId: execution.targetThreadId,
|
||||
targetThreadDisplayName: target.threadDisplayName,
|
||||
status: "queued",
|
||||
requestedAt: nowIso(),
|
||||
};
|
||||
state.masterAgentTasks.unshift(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function ensureDispatchExecutionTasksInState(
|
||||
state: BossState,
|
||||
plan: DispatchPlan,
|
||||
executions: DispatchExecution[],
|
||||
) {
|
||||
return executions.map((execution) => ensureDispatchExecutionTaskInState(state, plan, execution));
|
||||
}
|
||||
|
||||
export async function confirmDispatchPlanAndCreateExecutions(input: {
|
||||
groupProjectId: string;
|
||||
planId: string;
|
||||
@@ -4055,6 +4380,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
|
||||
});
|
||||
}
|
||||
|
||||
ensureDispatchExecutionTasksInState(state, plan, executions);
|
||||
|
||||
return {
|
||||
plan: { ...plan },
|
||||
executions: executions.map((execution) => ({ ...execution })),
|
||||
@@ -4109,6 +4436,115 @@ export async function completeDispatchExecution(payload: {
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeDispatchExecutionReply(rawThreadReply: string, threadTitle: string) {
|
||||
const compact = rawThreadReply.trim().replace(/\s+/g, " ");
|
||||
if (!compact) {
|
||||
return `主 Agent 汇总:${threadTitle} 已返回执行结果。`;
|
||||
}
|
||||
if (compact.length <= 72) {
|
||||
return `主 Agent 汇总:${threadTitle} 已返回执行结果:${compact}`;
|
||||
}
|
||||
return `主 Agent 汇总:${threadTitle} 已返回执行结果:${compact.slice(0, 69)}...`;
|
||||
}
|
||||
|
||||
function appendDispatchExecutionResultInState(
|
||||
state: BossState,
|
||||
payload: {
|
||||
dispatchExecutionId: string;
|
||||
completedByDeviceId: string;
|
||||
status: "completed" | "failed";
|
||||
groupProjectId: string;
|
||||
targetProjectId: string;
|
||||
targetThreadId: string;
|
||||
targetThreadDisplayName?: string;
|
||||
rawThreadReply?: string;
|
||||
masterSummary?: string;
|
||||
},
|
||||
) {
|
||||
const execution = state.dispatchExecutions.find(
|
||||
(item) => item.executionId === payload.dispatchExecutionId,
|
||||
);
|
||||
if (!execution) throw new Error("DISPATCH_EXECUTION_NOT_FOUND");
|
||||
if (execution.groupProjectId !== payload.groupProjectId) {
|
||||
throw new Error("DISPATCH_EXECUTION_GROUP_PROJECT_MISMATCH");
|
||||
}
|
||||
if (execution.targetProjectId !== payload.targetProjectId) {
|
||||
throw new Error("DISPATCH_EXECUTION_TARGET_PROJECT_MISMATCH");
|
||||
}
|
||||
if (execution.targetThreadId !== payload.targetThreadId) {
|
||||
throw new Error("DISPATCH_EXECUTION_TARGET_THREAD_MISMATCH");
|
||||
}
|
||||
if (execution.deviceId !== payload.completedByDeviceId) {
|
||||
throw new Error("DISPATCH_EXECUTION_DEVICE_MISMATCH");
|
||||
}
|
||||
|
||||
const device = state.devices.find((item) => item.id === payload.completedByDeviceId);
|
||||
const threadTitle =
|
||||
payload.targetThreadDisplayName?.trim() ||
|
||||
state.projects.find((item) => item.id === payload.targetProjectId)?.threadMeta.threadDisplayName ||
|
||||
payload.targetThreadId;
|
||||
|
||||
let mirroredResult: Message | null = null;
|
||||
let masterSummary: Message | null = null;
|
||||
|
||||
if (payload.status === "completed") {
|
||||
if (!payload.rawThreadReply?.trim()) {
|
||||
throw new Error("DISPATCH_EXECUTION_RAW_REPLY_REQUIRED");
|
||||
}
|
||||
mirroredResult = pushProjectLedgerMessage(state, payload.groupProjectId, {
|
||||
sender: "device",
|
||||
senderLabel: `${threadTitle} · ${device?.name ?? payload.completedByDeviceId}`,
|
||||
body: payload.rawThreadReply.trim(),
|
||||
kind: "text",
|
||||
});
|
||||
masterSummary = pushProjectLedgerMessage(state, payload.groupProjectId, {
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body:
|
||||
payload.masterSummary?.trim() ||
|
||||
summarizeDispatchExecutionReply(payload.rawThreadReply, threadTitle),
|
||||
kind: "text",
|
||||
});
|
||||
} else {
|
||||
masterSummary = pushProjectLedgerMessage(state, payload.groupProjectId, {
|
||||
sender: "ops",
|
||||
senderLabel: "主 Agent Relay",
|
||||
body: `${threadTitle} 执行失败,请稍后重试。`,
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
|
||||
execution.status = payload.status;
|
||||
execution.completedAt = nowIso();
|
||||
execution.completedByDeviceId = payload.completedByDeviceId;
|
||||
execution.resultMessageId = mirroredResult?.id ?? execution.resultMessageId;
|
||||
|
||||
return {
|
||||
execution: { ...execution },
|
||||
mirroredResult,
|
||||
masterSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function appendDispatchExecutionResult(payload: {
|
||||
dispatchExecutionId: string;
|
||||
completedByDeviceId: string;
|
||||
status: "completed" | "failed";
|
||||
groupProjectId: string;
|
||||
targetProjectId: string;
|
||||
targetThreadId: string;
|
||||
targetThreadDisplayName?: string;
|
||||
rawThreadReply?: string;
|
||||
masterSummary?: string;
|
||||
}) {
|
||||
const result = await mutateState((state) =>
|
||||
appendDispatchExecutionResultInState(state, payload),
|
||||
);
|
||||
publishBossEvent("project.messages.updated", { projectId: payload.groupProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: payload.groupProjectId });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getMasterAgentTask(taskId: string) {
|
||||
const state = await readState();
|
||||
return state.masterAgentTasks.find((item) => item.taskId === taskId) ?? null;
|
||||
@@ -4116,6 +4552,7 @@ export async function getMasterAgentTask(taskId: string) {
|
||||
|
||||
export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
let attachmentProjectId: string | undefined;
|
||||
let dispatchExecutionProjectId: string | undefined;
|
||||
const task = await mutateState((state) => {
|
||||
const next = state.masterAgentTasks.find(
|
||||
(item) => item.deviceId === deviceId && item.status === "queued",
|
||||
@@ -4133,6 +4570,15 @@ export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
attachmentProjectId = next.projectId;
|
||||
}
|
||||
}
|
||||
if (next.taskType === "dispatch_execution" && next.dispatchExecutionId) {
|
||||
const execution = state.dispatchExecutions.find(
|
||||
(item) => item.executionId === next.dispatchExecutionId,
|
||||
);
|
||||
if (execution && execution.status === "queued") {
|
||||
execution.status = "running";
|
||||
dispatchExecutionProjectId = execution.groupProjectId;
|
||||
}
|
||||
}
|
||||
return { ...next };
|
||||
});
|
||||
if (task) {
|
||||
@@ -4145,6 +4591,9 @@ export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
publishBossEvent("project.messages.updated", { projectId: attachmentProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: attachmentProjectId });
|
||||
}
|
||||
if (dispatchExecutionProjectId) {
|
||||
publishBossEvent("conversation.updated", { projectId: dispatchExecutionProjectId });
|
||||
}
|
||||
}
|
||||
return task;
|
||||
}
|
||||
@@ -4156,6 +4605,10 @@ export async function completeMasterAgentTask(payload: {
|
||||
replyBody?: string;
|
||||
errorMessage?: string;
|
||||
requestId?: string;
|
||||
dispatchExecutionId?: string;
|
||||
targetProjectId?: string;
|
||||
targetThreadId?: string;
|
||||
rawThreadReply?: string;
|
||||
dispatchPlan?: {
|
||||
summary?: string;
|
||||
targets: DispatchPlanTarget[];
|
||||
@@ -4197,6 +4650,9 @@ export async function completeMasterAgentTask(payload: {
|
||||
|
||||
let attachmentProjectId: string | undefined;
|
||||
let createdDispatchPlan: DispatchPlan | undefined;
|
||||
let dispatchExecutionResult:
|
||||
| ReturnType<typeof appendDispatchExecutionResultInState>
|
||||
| undefined;
|
||||
if (task.taskType === "attachment_analysis" && task.attachmentId) {
|
||||
const project = state.projects.find((item) => item.id === task.projectId);
|
||||
const match = project ? findProjectAttachment(project, task.attachmentId) : null;
|
||||
@@ -4250,6 +4706,21 @@ export async function completeMasterAgentTask(payload: {
|
||||
targets: payload.dispatchPlan.targets,
|
||||
});
|
||||
}
|
||||
} else if (task.taskType === "dispatch_execution") {
|
||||
if (!task.dispatchExecutionId || !task.targetProjectId || !task.targetThreadId) {
|
||||
throw new Error("MASTER_AGENT_DISPATCH_EXECUTION_CONTEXT_REQUIRED");
|
||||
}
|
||||
dispatchExecutionResult = appendDispatchExecutionResultInState(state, {
|
||||
dispatchExecutionId: payload.dispatchExecutionId?.trim() || task.dispatchExecutionId,
|
||||
completedByDeviceId: payload.deviceId,
|
||||
status: payload.status,
|
||||
groupProjectId: task.projectId,
|
||||
targetProjectId: payload.targetProjectId?.trim() || task.targetProjectId,
|
||||
targetThreadId: payload.targetThreadId?.trim() || task.targetThreadId,
|
||||
targetThreadDisplayName: task.targetThreadDisplayName,
|
||||
rawThreadReply: payload.rawThreadReply?.trim() || task.replyBody,
|
||||
masterSummary: payload.replyBody?.trim(),
|
||||
});
|
||||
} else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) {
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
@@ -4269,6 +4740,7 @@ export async function completeMasterAgentTask(payload: {
|
||||
return {
|
||||
...task,
|
||||
dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined,
|
||||
dispatchExecution: dispatchExecutionResult?.execution,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4724,6 +5196,51 @@ export async function verifyDeviceToken(deviceId: string, token?: string) {
|
||||
return hasAuthorizedDeviceToken(state, deviceId, token);
|
||||
}
|
||||
|
||||
function upsertDeviceImportDraftFromHeartbeat(
|
||||
state: BossState,
|
||||
payload: {
|
||||
deviceId: string;
|
||||
enrollmentId?: string;
|
||||
candidates: DeviceImportCandidate[];
|
||||
},
|
||||
) {
|
||||
if (payload.candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
|
||||
const selectedCandidateIds = dedupeStrings(
|
||||
(existing?.selectedCandidateIds ?? []).filter((candidateId) =>
|
||||
payload.candidates.some((candidate) => candidate.candidateId === candidateId),
|
||||
),
|
||||
);
|
||||
|
||||
const nextDraft = normalizeDeviceImportDraft({
|
||||
draftId: existing?.draftId ?? randomToken("import-draft"),
|
||||
deviceId: payload.deviceId,
|
||||
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
|
||||
status:
|
||||
selectedCandidateIds.length > 0
|
||||
? existing?.resolutionId
|
||||
? "resolved"
|
||||
: "pending_resolution"
|
||||
: "pending_selection",
|
||||
candidates: payload.candidates,
|
||||
selectedCandidateIds,
|
||||
createdAt: existing?.createdAt ?? nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
reviewedAt: existing?.reviewedAt,
|
||||
reviewedBy: existing?.reviewedBy,
|
||||
resolutionId: existing?.resolutionId,
|
||||
}, existing);
|
||||
|
||||
state.deviceImportDrafts = [
|
||||
nextDraft,
|
||||
...state.deviceImportDrafts.filter((item) => item.draftId !== nextDraft.draftId),
|
||||
];
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
export async function upsertDeviceHeartbeat(payload: {
|
||||
deviceId: string;
|
||||
token?: string;
|
||||
@@ -4736,6 +5253,16 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
quota7d: number;
|
||||
projects: string[];
|
||||
endpoint?: string;
|
||||
projectCandidates?: Array<{
|
||||
folderName: string;
|
||||
folderRef?: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
codexFolderRef?: string;
|
||||
codexThreadRef?: string;
|
||||
lastActiveAt?: string;
|
||||
suggestedImport?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const result = await mutateState((state) => {
|
||||
const claimedEnrollment = claimEnrollment(
|
||||
@@ -4817,10 +5344,30 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCandidates = ensureArray(payload.projectCandidates, []).map((candidate) =>
|
||||
normalizeDeviceImportCandidate({
|
||||
deviceId: payload.deviceId,
|
||||
folderName: candidate.folderName,
|
||||
folderRef: candidate.folderRef,
|
||||
threadId: candidate.threadId,
|
||||
threadDisplayName: candidate.threadDisplayName,
|
||||
codexFolderRef: candidate.codexFolderRef,
|
||||
codexThreadRef: candidate.codexThreadRef,
|
||||
lastActiveAt: candidate.lastActiveAt ?? nowIso(),
|
||||
suggestedImport: candidate.suggestedImport ?? true,
|
||||
}),
|
||||
);
|
||||
const draft = upsertDeviceImportDraftFromHeartbeat(state, {
|
||||
deviceId: payload.deviceId,
|
||||
enrollmentId: claimedEnrollment?.enrollmentId,
|
||||
candidates: normalizedCandidates,
|
||||
});
|
||||
|
||||
return {
|
||||
device,
|
||||
token: claimedEnrollment?.token ?? device.token,
|
||||
pairingStatus: claimedEnrollment?.status,
|
||||
importDraft: draft,
|
||||
};
|
||||
});
|
||||
publishBossEvent("devices.updated", { deviceId: payload.deviceId });
|
||||
@@ -4828,6 +5375,269 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveDeviceImportAction(
|
||||
state: BossState,
|
||||
deviceId: string,
|
||||
candidate: DeviceImportCandidate,
|
||||
): DeviceImportResolutionItem {
|
||||
const directMatch = state.projects.find(
|
||||
(project) =>
|
||||
!project.isGroup &&
|
||||
((candidate.codexThreadRef && project.threadMeta.codexThreadRef === candidate.codexThreadRef) ||
|
||||
project.threadMeta.threadId === candidate.threadId),
|
||||
);
|
||||
if (directMatch) {
|
||||
return {
|
||||
candidateId: candidate.candidateId,
|
||||
action: "attach_existing",
|
||||
threadDisplayName: candidate.threadDisplayName,
|
||||
folderName: candidate.folderName,
|
||||
targetProjectId: directMatch.id,
|
||||
reason: `已匹配到现有会话《${directMatch.name}》,直接补充设备与线程映射。`,
|
||||
};
|
||||
}
|
||||
|
||||
const similarByFolder = state.projects.find(
|
||||
(project) =>
|
||||
!project.isGroup &&
|
||||
project.deviceIds.includes(deviceId) &&
|
||||
project.threadMeta.folderName === candidate.folderName &&
|
||||
project.threadMeta.threadDisplayName === candidate.threadDisplayName,
|
||||
);
|
||||
if (similarByFolder) {
|
||||
return {
|
||||
candidateId: candidate.candidateId,
|
||||
action: "attach_existing",
|
||||
threadDisplayName: candidate.threadDisplayName,
|
||||
folderName: candidate.folderName,
|
||||
targetProjectId: similarByFolder.id,
|
||||
reason: `同设备下已有同名线程《${similarByFolder.name}》,避免重复导入。`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
candidateId: candidate.candidateId,
|
||||
action: "create_thread_conversation",
|
||||
threadDisplayName: candidate.threadDisplayName,
|
||||
folderName: candidate.folderName,
|
||||
reason: `建议把 ${candidate.threadDisplayName} 作为独立聊天窗口导入。`,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeDeviceImportResolution(
|
||||
deviceName: string,
|
||||
items: DeviceImportResolutionItem[],
|
||||
) {
|
||||
const createCount = items.filter((item) => item.action === "create_thread_conversation").length;
|
||||
const attachCount = items.filter((item) => item.action === "attach_existing").length;
|
||||
const skipCount = items.filter((item) => item.action === "skip").length;
|
||||
return `${deviceName} 导入建议:新建 ${createCount} 个会话,关联 ${attachCount} 个现有会话${skipCount > 0 ? `,跳过 ${skipCount} 项` : ""}。`;
|
||||
}
|
||||
|
||||
export async function getLatestDeviceImportDraft(deviceId: string) {
|
||||
const state = await readState();
|
||||
const draft = state.deviceImportDrafts.find((item) => item.deviceId === deviceId) ?? null;
|
||||
const resolution = draft?.resolutionId
|
||||
? state.deviceImportResolutions.find((item) => item.resolutionId === draft.resolutionId) ?? null
|
||||
: state.deviceImportResolutions.find((item) => item.deviceId === deviceId) ?? null;
|
||||
return { draft, resolution };
|
||||
}
|
||||
|
||||
export async function selectDeviceImportCandidates(input: {
|
||||
deviceId: string;
|
||||
selectedCandidateIds: string[];
|
||||
selectedBy: string;
|
||||
}) {
|
||||
const draft = await mutateState((state) => {
|
||||
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
|
||||
if (!draft) throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
|
||||
const availableCandidateIds = new Set(draft.candidates.map((item) => item.candidateId));
|
||||
const nextSelected = dedupeStrings(input.selectedCandidateIds).filter((candidateId) =>
|
||||
availableCandidateIds.has(candidateId),
|
||||
);
|
||||
if (nextSelected.length === 0) {
|
||||
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
|
||||
}
|
||||
draft.selectedCandidateIds = nextSelected;
|
||||
draft.status = "pending_resolution";
|
||||
draft.updatedAt = nowIso();
|
||||
draft.reviewedBy = input.selectedBy;
|
||||
draft.reviewedAt = undefined;
|
||||
draft.resolutionId = undefined;
|
||||
state.deviceImportResolutions = state.deviceImportResolutions.filter(
|
||||
(item) => item.draftId !== draft.draftId,
|
||||
);
|
||||
return { ...draft };
|
||||
});
|
||||
publishBossEvent("devices.updated", { deviceId: input.deviceId });
|
||||
return draft;
|
||||
}
|
||||
|
||||
export async function resolveDeviceImportDraft(input: {
|
||||
deviceId: string;
|
||||
reviewedBy: string;
|
||||
}) {
|
||||
const result = await mutateState((state) => {
|
||||
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
|
||||
if (!draft) throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
|
||||
if (draft.selectedCandidateIds.length === 0) {
|
||||
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
|
||||
}
|
||||
const device = state.devices.find((item) => item.id === input.deviceId);
|
||||
if (!device) throw new Error("DEVICE_NOT_FOUND");
|
||||
|
||||
const selectedCandidates = draft.candidates.filter((candidate) =>
|
||||
draft.selectedCandidateIds.includes(candidate.candidateId),
|
||||
);
|
||||
const items = selectedCandidates.map((candidate) =>
|
||||
resolveDeviceImportAction(state, input.deviceId, candidate),
|
||||
);
|
||||
const resolution = normalizeDeviceImportResolution({
|
||||
resolutionId: randomToken("import-resolution"),
|
||||
draftId: draft.draftId,
|
||||
deviceId: input.deviceId,
|
||||
status: "ready",
|
||||
summary: summarizeDeviceImportResolution(device.name, items),
|
||||
items,
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
|
||||
draft.status = "resolved";
|
||||
draft.updatedAt = nowIso();
|
||||
draft.reviewedAt = nowIso();
|
||||
draft.reviewedBy = input.reviewedBy;
|
||||
draft.resolutionId = resolution.resolutionId;
|
||||
|
||||
state.deviceImportResolutions = [
|
||||
resolution,
|
||||
...state.deviceImportResolutions.filter((item) => item.draftId !== draft.draftId),
|
||||
];
|
||||
|
||||
return { draft: { ...draft }, resolution };
|
||||
});
|
||||
publishBossEvent("devices.updated", { deviceId: input.deviceId });
|
||||
publishBossEvent("conversation.updated", { deviceId: input.deviceId });
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildImportedThreadProject(device: Device, candidate: DeviceImportCandidate) {
|
||||
const projectId =
|
||||
candidate.codexThreadRef?.trim() && candidate.codexFolderRef?.trim()
|
||||
? slugify(`${device.id}-${candidate.codexFolderRef}-${candidate.codexThreadRef}`)
|
||||
: slugify(`${device.id}-${candidate.folderName}-${candidate.threadId}`);
|
||||
const now = nowIso();
|
||||
return normalizeProject({
|
||||
id: projectId,
|
||||
name: candidate.threadDisplayName,
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: [device.id],
|
||||
preview: `已从 ${device.name} 导入线程 ${candidate.threadDisplayName}`,
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: false,
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
threadMeta: {
|
||||
projectId,
|
||||
threadId: candidate.threadId,
|
||||
threadDisplayName: candidate.threadDisplayName,
|
||||
folderName: candidate.folderName,
|
||||
activityIconCount: 1,
|
||||
updatedAt: candidate.lastActiveAt || now,
|
||||
codexFolderRef: candidate.codexFolderRef ?? candidate.folderRef,
|
||||
codexThreadRef: candidate.codexThreadRef,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
messages: [
|
||||
{
|
||||
id: randomToken("msg"),
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: `已从设备 ${device.name} 导入线程《${candidate.threadDisplayName}》。`,
|
||||
sentAt: now,
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyDeviceImportResolution(input: {
|
||||
deviceId: string;
|
||||
appliedBy: string;
|
||||
}) {
|
||||
const result = await mutateState((state) => {
|
||||
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
|
||||
if (!draft || !draft.resolutionId) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
|
||||
const resolution = state.deviceImportResolutions.find(
|
||||
(item) => item.resolutionId === draft.resolutionId,
|
||||
);
|
||||
if (!resolution) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
|
||||
const device = state.devices.find((item) => item.id === input.deviceId);
|
||||
if (!device) throw new Error("DEVICE_NOT_FOUND");
|
||||
|
||||
const importedProjects: Project[] = [];
|
||||
for (const item of resolution.items) {
|
||||
const candidate = draft.candidates.find((entry) => entry.candidateId === item.candidateId);
|
||||
if (!candidate || item.action === "skip") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let targetProject = item.targetProjectId
|
||||
? state.projects.find((project) => project.id === item.targetProjectId)
|
||||
: undefined;
|
||||
if (item.action === "create_thread_conversation" && !targetProject) {
|
||||
targetProject = buildImportedThreadProject(device, candidate);
|
||||
state.projects.unshift(targetProject);
|
||||
} else if (item.action === "attach_existing" && !targetProject) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!targetProject) continue;
|
||||
if (!targetProject.deviceIds.includes(device.id)) {
|
||||
targetProject.deviceIds.push(device.id);
|
||||
}
|
||||
targetProject.threadMeta.threadDisplayName = candidate.threadDisplayName;
|
||||
targetProject.threadMeta.folderName = candidate.folderName;
|
||||
targetProject.threadMeta.threadId = candidate.threadId;
|
||||
targetProject.threadMeta.codexFolderRef = candidate.codexFolderRef ?? candidate.folderRef;
|
||||
targetProject.threadMeta.codexThreadRef = candidate.codexThreadRef;
|
||||
targetProject.threadMeta.updatedAt = candidate.lastActiveAt;
|
||||
targetProject.preview = `已导入 ${candidate.threadDisplayName}`;
|
||||
targetProject.updatedAt = nowIso();
|
||||
targetProject.lastMessageAt = targetProject.updatedAt;
|
||||
importedProjects.push({ ...targetProject });
|
||||
}
|
||||
|
||||
device.projects = dedupeStrings(
|
||||
draft.candidates
|
||||
.filter((candidate) => draft.selectedCandidateIds.includes(candidate.candidateId))
|
||||
.map((candidate) => candidate.folderName),
|
||||
);
|
||||
|
||||
resolution.status = "applied";
|
||||
resolution.appliedAt = nowIso();
|
||||
resolution.appliedBy = input.appliedBy;
|
||||
draft.status = "applied";
|
||||
draft.updatedAt = nowIso();
|
||||
|
||||
return {
|
||||
draft: { ...draft },
|
||||
resolution: { ...resolution },
|
||||
importedProjects,
|
||||
};
|
||||
});
|
||||
|
||||
publishBossEvent("devices.updated", { deviceId: input.deviceId });
|
||||
publishBossEvent("conversation.updated", { deviceId: input.deviceId });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function upsertDeviceSkills(payload: {
|
||||
deviceId: string;
|
||||
skills: Array<{
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
ContextBudgetLevel,
|
||||
Device,
|
||||
DeviceEnrollment,
|
||||
DeviceImportDraft,
|
||||
DeviceImportResolution,
|
||||
DeviceSkill,
|
||||
MasterIdentitySummary,
|
||||
OpsFault,
|
||||
@@ -94,6 +96,8 @@ export interface DeviceWorkspaceView {
|
||||
selectedDevice?: Device;
|
||||
relatedThreads: ThreadContextSnapshot[];
|
||||
activeEnrollment?: DeviceEnrollment;
|
||||
importDraft?: DeviceImportDraft;
|
||||
importResolution?: DeviceImportResolution;
|
||||
}
|
||||
|
||||
export interface OpsSummaryView {
|
||||
@@ -453,6 +457,8 @@ export function getDeviceWorkspaceView(
|
||||
selectedDevice: state.devices.find((item) => item.id === deviceId),
|
||||
relatedThreads: state.threadContextSnapshots.filter((item) => item.nodeId === deviceId),
|
||||
activeEnrollment: state.deviceEnrollments.find((item) => item.deviceId === deviceId),
|
||||
importDraft: state.deviceImportDrafts.find((item) => item.deviceId === deviceId),
|
||||
importResolution: state.deviceImportResolutions.find((item) => item.deviceId === deviceId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
209
tests/device-import-draft.test.ts
Normal file
209
tests/device-import-draft.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let createEnrollmentRoute: (typeof import("../src/app/api/v1/devices/enrollments/route"))["POST"];
|
||||
let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"];
|
||||
let getImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/route"))["GET"];
|
||||
let selectImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/select/route"))["POST"];
|
||||
let reviewImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route"))["POST"];
|
||||
let applyImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-import-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, applyModule, data, auth] =
|
||||
await Promise.all([
|
||||
import("../src/app/api/v1/devices/enrollments/route.ts"),
|
||||
import("../src/app/api/device-heartbeat/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/import-draft/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
|
||||
createEnrollmentRoute = enrollmentModule.POST;
|
||||
deviceHeartbeatRoute = heartbeatModule.POST;
|
||||
getImportDraftRoute = importDraftModule.GET;
|
||||
selectImportDraftRoute = selectModule.POST;
|
||||
reviewImportDraftRoute = reviewModule.POST;
|
||||
applyImportDraftRoute = applyModule.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
readState = data.readState;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: {
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
test("device import draft flow scans candidates, selects imports, resolves suggestions, and creates real chat windows", async () => {
|
||||
await setup();
|
||||
|
||||
const enrollmentResponse = await createEnrollmentRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "MacBook Pro",
|
||||
avatar: "P",
|
||||
account: "17600003315",
|
||||
endpoint: "mac://mbp.local",
|
||||
note: "待导入新设备",
|
||||
}),
|
||||
);
|
||||
assert.equal(enrollmentResponse.status, 200);
|
||||
const enrollmentPayload = (await enrollmentResponse.json()) as {
|
||||
enrollment: { deviceId: string; pairingCode: string };
|
||||
device: { id: string };
|
||||
};
|
||||
|
||||
const heartbeatResponse = await deviceHeartbeatRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: enrollmentPayload.device.id,
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "MacBook Pro",
|
||||
avatar: "P",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 88,
|
||||
projects: [],
|
||||
endpoint: "mac://mbp.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "北区试产线",
|
||||
folderRef: "north-line",
|
||||
threadId: "thread-north-regression",
|
||||
threadDisplayName: "北区试产线回归",
|
||||
codexFolderRef: "north-line",
|
||||
codexThreadRef: "thread-north-regression",
|
||||
lastActiveAt: "2026-03-30T10:18:00+08:00",
|
||||
suggestedImport: true,
|
||||
},
|
||||
{
|
||||
folderName: "北区试产线",
|
||||
folderRef: "north-line",
|
||||
threadId: "thread-north-audit",
|
||||
threadDisplayName: "北区试产线审计",
|
||||
codexFolderRef: "north-line",
|
||||
codexThreadRef: "thread-north-audit",
|
||||
lastActiveAt: "2026-03-30T10:20:00+08:00",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(heartbeatResponse.status, 200);
|
||||
|
||||
const draftResponse = await getImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`,
|
||||
"GET",
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
assert.equal(draftResponse.status, 200);
|
||||
const draftPayload = (await draftResponse.json()) as {
|
||||
draft: { draftId: string; candidates: Array<{ candidateId: string; threadDisplayName: string }> } | null;
|
||||
};
|
||||
assert.ok(draftPayload.draft, "expected an import draft after heartbeat candidates");
|
||||
assert.equal(draftPayload.draft.candidates.length, 2);
|
||||
|
||||
const selectedCandidateIds = draftPayload.draft.candidates
|
||||
.filter((candidate) => candidate.threadDisplayName === "北区试产线回归")
|
||||
.map((candidate) => candidate.candidateId);
|
||||
assert.equal(selectedCandidateIds.length, 1);
|
||||
|
||||
const selectResponse = await selectImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
|
||||
"POST",
|
||||
{ selectedCandidateIds },
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
assert.equal(selectResponse.status, 200);
|
||||
|
||||
const reviewResponse = await reviewImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/review`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
assert.equal(reviewResponse.status, 200);
|
||||
const reviewPayload = (await reviewResponse.json()) as {
|
||||
resolution: { summary: string; items: Array<{ action: string; threadDisplayName: string }> };
|
||||
};
|
||||
assert.match(reviewPayload.resolution.summary, /MacBook Pro 导入建议/);
|
||||
assert.deepEqual(
|
||||
reviewPayload.resolution.items.map((item) => item.action),
|
||||
["create_thread_conversation"],
|
||||
);
|
||||
|
||||
const applyResponse = await applyImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/apply`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
assert.equal(applyResponse.status, 200);
|
||||
|
||||
const nextState = await readState();
|
||||
const importedProject = nextState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-north-regression",
|
||||
);
|
||||
assert.ok(importedProject, "expected selected candidate to become a real chat window");
|
||||
assert.equal(importedProject?.threadMeta.threadDisplayName, "北区试产线回归");
|
||||
assert.equal(importedProject?.threadMeta.folderName, "北区试产线");
|
||||
|
||||
const device = nextState.devices.find((item) => item.id === enrollmentPayload.device.id);
|
||||
assert.deepEqual(device?.projects, ["北区试产线"]);
|
||||
|
||||
const appliedDraft = nextState.deviceImportDrafts.find(
|
||||
(draft) => draft.deviceId === enrollmentPayload.device.id,
|
||||
);
|
||||
const appliedResolution = nextState.deviceImportResolutions.find(
|
||||
(resolution) => resolution.deviceId === enrollmentPayload.device.id,
|
||||
);
|
||||
assert.equal(appliedDraft?.status, "applied");
|
||||
assert.equal(appliedResolution?.status, "applied");
|
||||
});
|
||||
222
tests/dispatch-execution-result.test.ts
Normal file
222
tests/dispatch-execution-result.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
||||
let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route"))["POST"];
|
||||
let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-task5-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [messageModule, confirmModule, completeModule, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts"),
|
||||
import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
|
||||
postMessageRoute = messageModule.POST;
|
||||
confirmDispatchPlanRoute = confirmModule.POST;
|
||||
completeMasterTaskRoute = completeModule.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
createProjectGroupChat = data.createProjectGroupChat;
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createAuthedRequest(url: string, method: "POST", body: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTwoSingleThreadProjects() {
|
||||
const state = await readState();
|
||||
const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
|
||||
if (singles.length >= 2) {
|
||||
return singles;
|
||||
}
|
||||
|
||||
assert.ok(singles[0], "expected at least one seeded single-thread project");
|
||||
const seed = singles[0];
|
||||
const clonedProject = {
|
||||
...seed,
|
||||
id: "boss-console-clone",
|
||||
name: "Boss 移动控制台副线程",
|
||||
deviceIds: ["win-gpu-01"],
|
||||
updatedAt: "2026-03-30T10:00:00+08:00",
|
||||
lastMessageAt: "2026-03-30T10:00:00+08:00",
|
||||
preview: "副线程等待主 Agent 汇总阻塞点。",
|
||||
threadMeta: {
|
||||
...seed.threadMeta,
|
||||
projectId: "boss-console-clone",
|
||||
threadId: "thread-boss-ui-clone",
|
||||
threadDisplayName: "南区试产线回归",
|
||||
folderName: "阻塞梳理",
|
||||
updatedAt: "2026-03-30T10:00:00+08:00",
|
||||
codexThreadRef: "thread-boss-ui-clone",
|
||||
codexFolderRef: "boss-console-clone",
|
||||
},
|
||||
groupMembers: [],
|
||||
messages: [
|
||||
{
|
||||
id: "msg-boss-console-clone",
|
||||
sender: "device" as const,
|
||||
senderLabel: "Win GPU / Codex",
|
||||
body: "这里还在等待视觉链路复核。",
|
||||
sentAt: "2026-03-30T10:00:00+08:00",
|
||||
kind: "text" as const,
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
};
|
||||
|
||||
await writeState({
|
||||
...state,
|
||||
projects: [...state.projects, clonedProject],
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
|
||||
}
|
||||
|
||||
async function createConfirmedDispatchExecution() {
|
||||
await setup();
|
||||
const memberProjects = await ensureTwoSingleThreadProjects();
|
||||
const groupProject = await createProjectGroupChat({
|
||||
sourceProjectId: memberProjects[0].id,
|
||||
memberProjectIds: [memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
});
|
||||
|
||||
const messageResponse = await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/messages`,
|
||||
"POST",
|
||||
{ body: "请主 Agent 推荐要先同步的线程" },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: groupProject.id }) },
|
||||
);
|
||||
assert.equal(messageResponse.status, 200);
|
||||
const messagePayload = (await messageResponse.json()) as {
|
||||
dispatchPlan: { planId: string; targets: Array<{ projectId: string }> } | null;
|
||||
};
|
||||
assert.ok(messagePayload.dispatchPlan, "expected seeded dispatch plan");
|
||||
|
||||
const approvedTargetProjectId = messagePayload.dispatchPlan.targets[0]?.projectId;
|
||||
assert.ok(approvedTargetProjectId, "expected approved target");
|
||||
|
||||
const confirmResponse = await confirmDispatchPlanRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${messagePayload.dispatchPlan.planId}/confirm`,
|
||||
"POST",
|
||||
{ approvedTargetProjectIds: [approvedTargetProjectId] },
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({
|
||||
projectId: groupProject.id,
|
||||
planId: messagePayload.dispatchPlan.planId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(confirmResponse.status, 200);
|
||||
|
||||
const state = await readState();
|
||||
const execution = state.dispatchExecutions.find(
|
||||
(item) =>
|
||||
item.planId === messagePayload.dispatchPlan?.planId &&
|
||||
item.targetProjectId === approvedTargetProjectId,
|
||||
);
|
||||
assert.ok(execution, "expected queued dispatch execution");
|
||||
|
||||
const executionTask = state.masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.taskType === "dispatch_execution" &&
|
||||
task.projectId === groupProject.id &&
|
||||
task.requestMessageId === messagePayload.dispatchPlan?.planId,
|
||||
);
|
||||
assert.ok(executionTask, "expected a queued dispatch execution master-agent task");
|
||||
|
||||
return { groupProject, execution, executionTask };
|
||||
}
|
||||
|
||||
test("POST /api/v1/master-agent/tasks/[taskId]/complete mirrors raw thread replies to the group chat and appends a master-agent summary", async () => {
|
||||
const { groupProject, execution, executionTask } = await createConfirmedDispatchExecution();
|
||||
|
||||
const response = await completeMasterTaskRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${executionTask.taskId}/complete`,
|
||||
"POST",
|
||||
{
|
||||
deviceId: execution.deviceId,
|
||||
status: "completed",
|
||||
dispatchExecutionId: execution.executionId,
|
||||
targetProjectId: execution.targetProjectId,
|
||||
targetThreadId: execution.targetThreadId,
|
||||
rawThreadReply: "线程A已经完成阻塞点整理,待你确认最终回滚窗口。",
|
||||
replyBody: "主 Agent 汇总:线程A已返回阻塞点整理,下一步建议安排回滚窗口确认。",
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ taskId: executionTask.taskId }) },
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const nextState = await readState();
|
||||
const completedExecution = nextState.dispatchExecutions.find(
|
||||
(item) => item.executionId === execution.executionId,
|
||||
);
|
||||
assert.equal(completedExecution?.status, "completed");
|
||||
assert.ok(completedExecution?.resultMessageId, "expected raw result message id to be recorded");
|
||||
|
||||
const groupMessages = nextState.projects.find((project) => project.id === groupProject.id)?.messages ?? [];
|
||||
const mirroredDeviceReply = groupMessages.find(
|
||||
(message) =>
|
||||
message.sender === "device" &&
|
||||
message.body.includes("线程A已经完成阻塞点整理"),
|
||||
);
|
||||
assert.ok(mirroredDeviceReply, "expected raw thread reply to be mirrored back to the group chat");
|
||||
|
||||
const masterSummary = groupMessages.find(
|
||||
(message) =>
|
||||
message.sender === "master" &&
|
||||
message.body.includes("主 Agent 汇总:线程A已返回阻塞点整理"),
|
||||
);
|
||||
assert.ok(masterSummary, "expected master-agent summary to be appended after the raw thread reply");
|
||||
});
|
||||
Reference in New Issue
Block a user