diff --git a/README.md b/README.md index a3618db..c56bfb1 100644 --- a/README.md +++ b/README.md @@ -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` 当前常驻默认值: diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index 8e2436d..c50b4cc 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -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,尚未接更多对象存储或更丰富的附件详情页 diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index cbea687..be61f4d 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -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` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 3f95752..a50aaaa 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -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`,会导致中间产物互踩并出现假失败 diff --git a/docs/architecture/development_subtasks_and_delivery_plan_cn.md b/docs/architecture/development_subtasks_and_delivery_plan_cn.md index 4ae5e07..857b5bf 100644 --- a/docs/architecture/development_subtasks_and_delivery_plan_cn.md +++ b/docs/architecture/development_subtasks_and_delivery_plan_cn.md @@ -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 diff --git a/local-agent/server.mjs b/local-agent/server.mjs index 9f33f67..739c08e 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -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 心跳执行异常。", diff --git a/src/app/api/device-heartbeat/route.ts b/src/app/api/device-heartbeat/route.ts index b346fbe..0677708 100644 --- a/src/app/api/device-heartbeat/route.ts +++ b/src/app/api/device-heartbeat/route.ts @@ -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, }); diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts new file mode 100644 index 0000000..aeca1d0 --- /dev/null +++ b/src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts new file mode 100644 index 0000000..df08ed1 --- /dev/null +++ b/src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/route.ts new file mode 100644 index 0000000..2d4ec25 --- /dev/null +++ b/src/app/api/v1/devices/[deviceId]/import-draft/route.ts @@ -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 }); +} diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts new file mode 100644 index 0000000..f44e42d --- /dev/null +++ b/src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts b/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts index e5789f0..1e42502 100644 --- a/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts +++ b/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts @@ -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) { diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index f675279..83defaa 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -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, + 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, + 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[] | 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, + 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[] | 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 | 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 | 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 + | 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<{ diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 45a9a6f..b141743 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -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), }; } diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts new file mode 100644 index 0000000..1dcba3e --- /dev/null +++ b/tests/device-import-draft.test.ts @@ -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"); +}); diff --git a/tests/dispatch-execution-result.test.ts b/tests/dispatch-execution-result.test.ts new file mode 100644 index 0000000..c148ece --- /dev/null +++ b/tests/dispatch-execution-result.test.ts @@ -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"); +});