feat: wire dispatch execution and device import flows

This commit is contained in:
kris
2026-03-30 11:08:43 +08:00
parent 3b2bf59b65
commit 745b47e812
16 changed files with 1545 additions and 13 deletions

View File

@@ -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`
当前常驻默认值:

View File

@@ -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尚未接更多对象存储或更丰富的附件详情页

View File

@@ -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`

View File

@@ -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`,会导致中间产物互踩并出现假失败

View File

@@ -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

View File

@@ -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 心跳执行异常。",

View File

@@ -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,
});

View File

@@ -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 },
);
}
}

View File

@@ -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 },
);
}
}

View 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 });
}

View File

@@ -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 },
);
}
}

View File

@@ -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) {

View File

@@ -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<{

View File

@@ -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),
};
}

View 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");
});

View 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");
});