From 0bcdcbfb9ddc0a4f0324255ce3e49c23ce57d550 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Wed, 3 Jun 2026 13:47:50 +0800 Subject: [PATCH] feat: add controlled codex thread archive --- docs/architecture/ai_handoff_index_cn.md | 1 + .../api_and_service_inventory_cn.md | 9 + .../codex_server_progress_card_cn.md | 1 + .../current_runtime_and_deploy_status_cn.md | 1 + local-agent/codex-app-server-runner.mjs | 43 +++++ .../[projectId]/thread-archive/route.ts | 100 ++++++++++ src/lib/boss-data.ts | 18 ++ src/lib/boss-master-agent.ts | 62 ++++++ tests/fixtures/codex-app-server-runtime.mjs | 36 ++++ ...cal-agent-codex-app-server-runner.test.mjs | 45 +++++ tests/thread-archive-route.test.ts | 177 ++++++++++++++++++ 11 files changed, 493 insertions(+) create mode 100644 src/app/api/v1/projects/[projectId]/thread-archive/route.ts create mode 100644 tests/thread-archive-route.test.ts diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index 0cc6d41..8a9e589 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -167,6 +167,7 @@ - 当前 App Server 任务取消已从“服务端标记”升级为“真实 turn 中断”:`POST /api/v1/master-agent/tasks/[taskId]/cancel` 仍负责把任务置为 `canceled`,新增 `GET /api/v1/master-agent/tasks/[taskId]/control-state` 供设备端轮询;`local-agent` 在 App Server turn 启动后会按取消状态调用 `turn/interrupt`,并把 `interrupted` 作为干净取消处理,避免取消后长任务继续跑或被误写成失败日志。 - 当前已补 Codex App Server 受控线程回滚:`POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务,`local-agent` 调用 `thread/rollback` 回滚目标线程最近 N 轮;该链路不启动新 turn,不把 thread/turn/items 原文写回 APP,只提示“线程历史已回滚”,且不会自动还原本地文件变更。 - 当前已补 Codex App Server 受控线程压缩:`POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务,`local-agent` 调用 `thread/compact/start` 发起目标线程上下文压缩;该链路不启动普通 turn,不把 contextCompaction item 原文写回 APP,只提示“上下文压缩已发起”。 +- 当前已补 Codex App Server 受控线程归档 / 恢复:`POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务,`local-agent` 直接调用 `thread/archive` 或 `thread/unarchive`;该链路不启动普通 turn,不把 thread 原始字段写回 APP,只提示“线程已归档/已恢复”。 - 当前 boss-agent 已支持 Mac OTA:`local-agent/boss-agent-ota-runner.mjs` 默认开启,每 5 分钟检查服务端最新包;状态页可手动检查或下载并安装,安装时保留原绑定配置,只更新版本号和本机 runtime 路径。最新验证版本为 `20260516221619`,已在 MacBook Air `macbook-air` 上确认 OTA 下载校验、暂存、覆盖安装后不会误切到默认 `config.cloud.json`。正式分发脚本已预留 Developer ID 公证路径:`BOSS_AGENT_NOTARIZE=1` 配合 notary profile 或 Apple ID 凭据。 - 当前量产治理已补设备撤权和任务可靠性底座:`revoke_device` 会清空设备 token、标记离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA;`MasterAgentTask` claim 会记录 attempt 和 lease,运行中任务可按租约重试,超过上限转 `timed_out`,用户或管理员可通过 cancel 接口转 `canceled` 且迟到 complete 不覆盖终态。 - 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果 diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 5776c2c..0654c11 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -136,6 +136,7 @@ - 当前 Codex App Server runner 已新增第一版 Boss Inter-Thread Broker:任务携带 `intentCategory=thread_collaboration`、`sourceCodexThreadRef` 和 `targetCodexThreadRef` 时,会先 `thread/read` 源线程,再通过 `thread/inject_items` 向目标线程注入受控摘要,最后 `turn/start` 目标线程;服务端入口是 `POST /api/v1/projects/[projectId]/thread-collaboration`,负责权限、源/目标线程校验和任务排队。这不是假设官方线程 P2P,而是 Boss 自己做线程协作编排。 - 当前 Codex App Server runner 已新增受控线程回滚:任务携带 `intentCategory=thread_rollback`、目标 `codexThreadRef` 和 `rollbackNumTurns` 时,会调用 `thread/rollback` 回滚目标线程最近 N 轮,不会启动新 turn,也不会把 App Server 返回的 thread/turn/items 写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-rollback`,只保存回滚轮数、原因和执行摘要;边界是只回滚 Codex 线程历史,不自动还原本地文件变更。 - 当前 Codex App Server runner 已新增受控线程压缩:任务携带 `intentCategory=thread_compact` 和目标 `codexThreadRef` 时,会调用 `thread/compact/start` 发起上下文压缩,不会启动普通 turn,也不会把 contextCompaction item 的原始字段写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-compact`,只保存压缩原因和执行摘要;边界是只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。 +- 当前 Codex App Server runner 已新增受控线程归档 / 恢复:任务携带 `intentCategory=thread_archive|thread_unarchive`、目标 `codexThreadRef` 和 `threadLifecycleAction` 时,会直接调用 `thread/archive` 或 `thread/unarchive`,不会先 resume 已归档线程,也不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-archive`,只保存生命周期动作、原因和执行摘要;边界是只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。 - 当前 boss-agent Mac OTA 已接入:`local-agent/boss-agent-ota-runner.mjs` 会用设备 token 调 Boss 服务端 `/api/v1/boss-agent/ota` 检查最新 Mac 运行包,`/api/v1/boss-agent/ota/apply` 会下载 `boss-agent-mac-latest.zip`、校验 sha256、暂存安装 wrapper,并拉起本机安装器;安装脚本会保留绑定配置并只更新版本号与本机 runtime 路径。安装器会优先沿用当前 LaunchAgent active config,并保留所有 `config*.json`,避免多电脑场景中误绑定到默认设备配置。当前最新验证包为 `20260516221619`;构建脚本支持 `BOSS_AGENT_NOTARIZE=1` 的 Developer ID 公证路径。 - 当前 `local-agent` 还新增了两条统一电脑控制 runtime: - `local-agent/browser-control-task-runner.mjs` @@ -179,6 +180,14 @@ - 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_compact`、目标 `threadId`、`codexThreadRef`、`codexFolderRef` 和 `compactReason` - 边界:设备端通过 Codex App Server 调用 `thread/compact/start`,只发起上下文压缩;不会启动普通 turn,不会把 contextCompaction item 的原始字段写回 APP,也不代表代码修改、文件恢复或版本发布完成 +#### `POST /api/v1/projects/[projectId]/thread-archive` + +- 用途:对当前会话绑定的 Codex 线程发起受控归档或恢复,适合项目阶段性结束、误归档恢复或清理会话首页前先同步 Codex 线程生命周期 +- 权限:登录态;目标项目需要 `project.view` 和 `master_agent.ask` +- 请求体:`action=archive|unarchive`,可选 `reason` +- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_archive|thread_unarchive`、目标 `threadId`、`codexThreadRef`、`codexFolderRef`、`threadLifecycleAction` 和 `threadLifecycleReason` +- 边界:设备端通过 Codex App Server 调用 `thread/archive` 或 `thread/unarchive`;不会启动普通 turn,不会把 App Server 返回的 thread 原始字段写回 APP,也不代表代码修改、文件恢复或版本发布完成 + - 当前仓库已自带 browser smoke runtime、desktop Cua runtime 和旧 desktop smoke 兜底: - `scripts/browser-control-smoke.mjs` - `scripts/codex-computer-use-runtime.mjs` diff --git a/docs/architecture/codex_server_progress_card_cn.md b/docs/architecture/codex_server_progress_card_cn.md index 3dde606..7ba656a 100644 --- a/docs/architecture/codex_server_progress_card_cn.md +++ b/docs/architecture/codex_server_progress_card_cn.md @@ -150,6 +150,7 @@ UI 参考: - 新增活跃 turn 中断:用户或管理员通过任务取消接口把任务转为 `canceled` 后,设备端会轮询 `GET /api/v1/master-agent/tasks/[taskId]/control-state`;如果当前任务已经启动 App Server turn,runner 会在同一个 JSON-RPC 连接上调用 `turn/interrupt`,并把返回的 `interrupted` 处理成干净取消,不再把用户主动取消误判成 runtime failure - 新增受控线程回滚:服务端入口 `POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务;设备端通过 App Server 调用 `thread/rollback`,只返回“已回滚最近 N 轮”的用户可见摘要,不启动新 turn,不保存 App Server 返回的 thread/turn/items。该能力只回滚 Codex 线程历史,不自动还原本地文件变更,后续如需文件级撤回必须另走 Git / 文件快照恢复链路。 - 新增受控线程压缩:服务端入口 `POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务;设备端通过 App Server 调用 `thread/compact/start`,只返回“已发起上下文压缩”的用户可见摘要,不启动普通 turn,不保存 contextCompaction item 的原始字段。该能力只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。 +- 新增受控线程归档 / 恢复:服务端入口 `POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务;设备端通过 App Server 直接调用 `thread/archive` 或 `thread/unarchive`,不先 resume 已归档线程,不启动普通 turn,不保存 App Server 返回的 thread 原始字段。该能力只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。 - `getCodexAppServerRunnerConfig` 已识别 `codexAppServerTransport` / `BOSS_CODEX_APP_SERVER_TRANSPORT`、`codexAppServerUrl` / `BOSS_CODEX_APP_SERVER_URL`、`codexAppServerAuthTokenFile` / `BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE`;`local-agent/codex-app-server-runner.mjs` 现已支持 `stdio`、`ws://127.0.0.1:` 与 `unix://PATH` 三种 JSON-RPC transport,默认仍是 stdio,ws/unix 适合作为同机长驻 App Server 灰度路径 - 新增 App Server 过载退避:单个 JSON-RPC 请求收到 `-32001` 或 `retry later` 文案时,会在同一个任务生命周期内重试,超出上限后才进入失败/CLI fallback 判定 - 新增 App Server capability discovery:`local-agent` 会把可用模型、默认/快速/深度模型建议、provider 能力、Skill、Plugin、App 摘要写入设备 heartbeat;Web 设备详情已显示 App Server、模型和扩展数量,为后续 APP/后台模型配置页提供真实数据来源 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index ce0868c..44bfd1a 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -265,6 +265,7 @@ cd /Users/kris/code/boss - 当前已新增 Boss 自有 Inter-Thread Broker 第一版:服务端入口 `POST /api/v1/projects/[projectId]/thread-collaboration` 会创建带源/目标 Codex 线程引用的协作任务;App Server runner 执行 `thread/read(source) -> thread/inject_items(target) -> turn/start(target)`,用于让一个线程的结论受控进入另一个线程,不依赖官方任意线程 P2P 互聊能力 - 当前已新增 Codex App Server 受控线程回滚:服务端入口 `POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务;App Server runner 执行 `thread/rollback(target, numTurns)`,只回写“已回滚最近 N 轮”的用户可见摘要,不启动新 turn,不保存 App Server 返回的 thread/turn/items。该能力只回滚 Codex 线程历史,不自动还原本地文件变更。 - 当前已新增 Codex App Server 受控线程压缩:服务端入口 `POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务;App Server runner 执行 `thread/compact/start(target)`,只回写“已发起上下文压缩”的用户可见摘要,不启动普通 turn,不保存 contextCompaction item 原始字段。该能力只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。 +- 当前已新增 Codex App Server 受控线程归档 / 恢复:服务端入口 `POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务;App Server runner 直接执行 `thread/archive(target)` 或 `thread/unarchive(target)`,不先 resume 已归档线程,不启动普通 turn,不保存 App Server 返回的 thread 原始字段。该能力只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。 - 当前 `local-agent` 对 `dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody` - 当前 `local-agent` 会在 Codex 任务执行中和完成时回传 `executionProgress`:服务端把同一任务的进度卡从 queued / running 更新到 completed / failed,Android 原生聊天页会显示“进度 / 线程状态 / 实时状态 / 线程配置 / 线程协作 / 工具活动 / 思考摘要 / 账号状态 / 运行状态 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”。2026-05-31 起,Codex App Server 的 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started` 会直接映射为进度步骤、变更统计、生成产物和后台智能体;第二批已把 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 映射为审批、安全提醒和文件变更摘要;第三批已把 `thread/status/changed` 与 `thread/realtime/*` 安全映射为线程状态和实时状态摘要;第四批已把 `model/rerouted`、`thread/tokenUsage/updated`、`mcpServer/startupStatus/updated`、`remoteControl/status/changed` 安全映射为运行状态摘要;第五批已把 `thread/goal/*`、`thread/settings/updated` 和 `thread/compacted` 映射为线程配置摘要;第六批已把 `account/updated`、`account/rateLimits/updated`、`model/verification`、`warning`、`configWarning`、`deprecationNotice` 映射为账号状态、模型校验和安全提醒摘要;第七批已把 `ThreadItem.collabToolCall` 和 `ThreadItem.contextCompaction` 映射为线程协作和上下文压缩摘要;第八批已把 `mcpToolCall`、`dynamicToolCall`、`webSearch`、`imageView`、`enteredReviewMode`、`exitedReviewMode`、`commandExecution` 映射为工具活动摘要;第九批已把 `ThreadItem.plan` 和 `ThreadItem.reasoning.summary` 映射为计划步骤与思考摘要;第十批已把 `ThreadItem.imageGeneration` 映射为图像生成工具活动和图片产物;第十一批已把 `hook/started|completed` 映射为钩子生命周期工具活动;第十二批已把 `windowsSandbox/setupCompleted` 映射为 Windows 沙箱准备状态摘要;第十七批已把新版 `ThreadItem.collabToolCall.receiverThreadIds / agentsStates` 安全映射为线程协作目标数量和 agent 状态集合。所有进度均通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;字段白名单会剥离 cwd、turnId、配置文件路径、内部 prompt、collab 源/目标线程 ID、receiverThreadIds、agentsStates 私有消息、共享 Skill 根绝对路径、tool arguments/result、web URL token、命令正文/输出、raw reasoning content、reasoning item id、图像生成 revisedPrompt/result、hook sourcePath/statusMessage/entries、Windows sandbox sourcePath/samplePaths/本地绝对路径和未清洗密钥,complete 回写仍会携带最终进度兜底 - 当前 `local-agent` heartbeat 已新增 Codex App Server capability discovery:按 TTL 拉取模型、provider 能力、Skill、Hook、Plugin、App 摘要,并附加只读线程操作、插件治理、账号治理、配置治理、文件治理、命令会话、外部 Agent 迁移、Marketplace、实验特性、审查、Windows 沙箱、文件搜索事件、MCP、用户交互、Guardian、运行事件、扩展事件、线程生命周期和流式增量能力 catalog,写入 `capabilities.codexAppServer.metadata`;Web 设备详情会展示 App Server 连接状态、模型数量、默认/快速/深度模型、扩展数量、Hook 治理摘要、线程操作摘要、插件治理摘要、账号治理摘要、配置治理摘要、文件治理摘要、命令会话摘要、迁移治理摘要、市场治理摘要、实验特性治理摘要、审查治理摘要、Windows 沙箱摘要、文件搜索事件摘要、MCP 治理摘要、用户交互摘要、Guardian 治理摘要、运行事件摘要、扩展事件摘要、线程生命周期摘要和流式增量摘要 diff --git a/local-agent/codex-app-server-runner.mjs b/local-agent/codex-app-server-runner.mjs index 4db59be..bbd51c8 100644 --- a/local-agent/codex-app-server-runner.mjs +++ b/local-agent/codex-app-server-runner.mjs @@ -70,6 +70,24 @@ function isThreadCompactTask(task) { return task?.intentCategory === "thread_compact" || task?.taskType === "thread_compact"; } +function resolveThreadLifecycleAction(task) { + if ( + task?.threadLifecycleAction === "archive" || + task?.intentCategory === "thread_archive" || + task?.taskType === "thread_archive" + ) { + return "archive"; + } + if ( + task?.threadLifecycleAction === "unarchive" || + task?.intentCategory === "thread_unarchive" || + task?.taskType === "thread_unarchive" + ) { + return "unarchive"; + } + return undefined; +} + function resolveRollbackNumTurns(task) { const numeric = Number(task?.rollbackNumTurns ?? 1); return Number.isFinite(numeric) && numeric >= 1 ? Math.floor(numeric) : undefined; @@ -3092,6 +3110,31 @@ export async function executeCodexAppServerTask(runnerConfig, task) { }); notify("initialized", {}); + const lifecycleAction = resolveThreadLifecycleAction(task); + if (lifecycleAction) { + const lifecycleThreadId = targetThreadRef; + if (!lifecycleThreadId) { + throw new Error("CODEX_APP_SERVER_THREAD_ID_MISSING"); + } + const method = lifecycleAction === "archive" ? "thread/archive" : "thread/unarchive"; + await request(method, { threadId: lifecycleThreadId }); + const actionLabel = lifecycleAction === "archive" ? "归档" : "恢复"; + return { + status: "completed", + replyBody: `已${actionLabel} Codex 线程。该动作只改变线程生命周期状态,不代表完成代码修改或文件恢复。`, + threadId: lifecycleThreadId, + turnControl: lifecycleAction, + threadLifecycle: { + action: lifecycleAction, + status: "completed", + }, + cwd, + transport: runnerConfig.transport, + executionProgress: progressCollector.snapshot(), + canFallbackToCli: false, + }; + } + const threadResult = targetThreadRef ? await request("thread/resume", { threadId: targetThreadRef, diff --git a/src/app/api/v1/projects/[projectId]/thread-archive/route.ts b/src/app/api/v1/projects/[projectId]/thread-archive/route.ts new file mode 100644 index 0000000..6ff1821 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/thread-archive/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data"; +import { canAccessProject } from "@/lib/boss-permissions"; +import { + queueThreadLifecycleTask, + ThreadConversationExecutionConflictError, +} from "@/lib/boss-master-agent"; + +type ThreadLifecycleAction = "archive" | "unarchive"; + +function forbiddenResponse(message = "FORBIDDEN") { + return NextResponse.json({ ok: false, message }, { status: 403 }); +} + +function normalizeLifecycleAction(value: unknown): ThreadLifecycleAction | undefined { + const normalized = String(value ?? "archive").trim(); + return normalized === "archive" || normalized === "unarchive" ? normalized : undefined; +} + +function normalizeLifecycleReason(value: unknown) { + const trimmed = String(value ?? "").trim(); + return trimmed ? trimmed : undefined; +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const body = (await request.json().catch(() => ({}))) as { + action?: unknown; + reason?: unknown; + }; + const action = normalizeLifecycleAction(body.action); + if (!action) { + return NextResponse.json( + { ok: false, message: "THREAD_LIFECYCLE_ACTION_UNSUPPORTED" }, + { status: 400 }, + ); + } + const reason = normalizeLifecycleReason(body.reason); + + const state = await readState(); + const projectExists = state.projects.some((project) => project.id === projectId); + if (!canAccessProject(state, session, projectId, "project.view")) { + return forbiddenResponse(projectExists ? "FORBIDDEN" : "PROJECT_NOT_FOUND"); + } + if (!canAccessProject(state, session, projectId, "master_agent.ask")) { + return forbiddenResponse("MASTER_AGENT_FORBIDDEN"); + } + + try { + const actionLabel = action === "archive" ? "归档" : "恢复"; + const message = await appendProjectMessage({ + projectId, + account: session.account, + senderLabel: session.displayName || "你", + body: reason || `${actionLabel}当前 Codex 线程。`, + kind: "text", + }); + const task = await queueThreadLifecycleTask({ + projectId, + requestMessageId: message.id, + action, + reason, + requestedBy: session.displayName || session.account, + requestedByAccount: session.account, + }); + const nextState = await readState(); + const project = nextState.projects.find((item) => item.id === projectId); + return NextResponse.json({ + ok: true, + message, + task, + collaborationGate: buildCollaborationGate(project), + }); + } catch (error) { + if (error instanceof ThreadConversationExecutionConflictError) { + return NextResponse.json( + { + ok: false, + code: error.message, + message: "THREAD_EXECUTION_CONFLICT", + executionConflict: error.conflict, + }, + { status: 409 }, + ); + } + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: 400 }, + ); + } +} diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index a3bb582..c71508c 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -485,6 +485,8 @@ export type ComputerControlIntentCategory = | "thread_collaboration" | "thread_rollback" | "thread_compact" + | "thread_archive" + | "thread_unarchive" | "browser_control" | "desktop_control"; export type ComputerControlRuntimeKind = @@ -1350,6 +1352,8 @@ export interface MasterAgentTask { rollbackNumTurns?: number; rollbackReason?: string; compactReason?: string; + threadLifecycleAction?: "archive" | "unarchive"; + threadLifecycleReason?: string; intentCategory?: ComputerControlIntentCategory; runtimeKind?: ComputerControlRuntimeKind; controlPlatform?: ComputerControlPlatform; @@ -4738,12 +4742,19 @@ export function migrateBossState(raw: Partial | undefined): BossState : undefined, rollbackReason: trimToDefined(task.rollbackReason), compactReason: trimToDefined(task.compactReason), + threadLifecycleAction: + task.threadLifecycleAction === "archive" || task.threadLifecycleAction === "unarchive" + ? task.threadLifecycleAction + : undefined, + threadLifecycleReason: trimToDefined(task.threadLifecycleReason), intentCategory: task.intentCategory === "discussion_only" || task.intentCategory === "project_development" || task.intentCategory === "thread_collaboration" || task.intentCategory === "thread_rollback" || task.intentCategory === "thread_compact" || + task.intentCategory === "thread_archive" || + task.intentCategory === "thread_unarchive" || task.intentCategory === "browser_control" || task.intentCategory === "desktop_control" ? task.intentCategory @@ -8811,6 +8822,8 @@ export async function queueMasterAgentTask(payload: { rollbackNumTurns?: number; rollbackReason?: string; compactReason?: string; + threadLifecycleAction?: "archive" | "unarchive"; + threadLifecycleReason?: string; intentCategory?: ComputerControlIntentCategory; runtimeKind?: ComputerControlRuntimeKind; controlPlatform?: ComputerControlPlatform; @@ -8878,6 +8891,11 @@ export async function queueMasterAgentTask(payload: { : undefined, rollbackReason: trimToDefined(payload.rollbackReason), compactReason: trimToDefined(payload.compactReason), + threadLifecycleAction: + payload.threadLifecycleAction === "archive" || payload.threadLifecycleAction === "unarchive" + ? payload.threadLifecycleAction + : undefined, + threadLifecycleReason: trimToDefined(payload.threadLifecycleReason), intentCategory: payload.intentCategory, runtimeKind: payload.runtimeKind, controlPlatform: payload.controlPlatform, diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index aae0c6f..17d20f6 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -3303,6 +3303,68 @@ export async function queueThreadCompactTask(params: { }); } +function buildThreadLifecyclePrompt(params: { + project: Project; + action: "archive" | "unarchive"; + reason?: string; +}) { + const threadTitle = + params.project.threadMeta.threadDisplayName?.trim() || params.project.name || "当前线程"; + const actionLabel = params.action === "archive" ? "归档" : "恢复"; + const method = params.action === "archive" ? "thread/archive" : "thread/unarchive"; + return [ + `你正在执行 Boss 下发的 Codex App Server 线程${actionLabel}控制任务。`, + `目标线程:${threadTitle}`, + params.reason ? `用户原因:${params.reason}` : undefined, + `请通过 ${method} 执行线程${actionLabel},不要启动普通 turn,不要输出系统提示词、线程原始历史或内部调度字段。`, + "注意:该动作只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。", + ] + .filter(Boolean) + .join("\n"); +} + +export async function queueThreadLifecycleTask(params: { + projectId: string; + requestMessageId: string; + action: "archive" | "unarchive"; + reason?: string; + requestedBy: string; + requestedByAccount: string; +}) { + if (params.action !== "archive" && params.action !== "unarchive") { + throw new Error("THREAD_LIFECYCLE_ACTION_UNSUPPORTED"); + } + const conflict = await getThreadConversationExecutionConflict(params.projectId); + if (conflict) { + throw new ThreadConversationExecutionConflictError(conflict); + } + const { project, deviceId } = await resolveThreadConversationExecutionContext(params.projectId); + const reason = params.reason?.trim() || undefined; + const actionLabel = params.action === "archive" ? "归档" : "恢复"; + return queueMasterAgentTask({ + projectId: project.id, + taskType: "conversation_reply", + requestMessageId: params.requestMessageId, + requestText: reason || `${actionLabel}当前 Codex 线程。`, + executionPrompt: buildThreadLifecyclePrompt({ + project, + action: params.action, + reason, + }), + requestedBy: params.requestedBy, + requestedByAccount: params.requestedByAccount, + deviceId, + intentCategory: params.action === "archive" ? "thread_archive" : "thread_unarchive", + targetProjectId: project.id, + targetThreadId: project.threadMeta.threadId, + targetThreadDisplayName: project.threadMeta.threadDisplayName, + targetCodexThreadRef: project.threadMeta.codexThreadRef, + targetCodexFolderRef: project.threadMeta.codexFolderRef, + threadLifecycleAction: params.action, + threadLifecycleReason: reason, + }); +} + export async function queueInterThreadCollaborationTask(params: { sourceProjectId: string; targetProjectId: string; diff --git a/tests/fixtures/codex-app-server-runtime.mjs b/tests/fixtures/codex-app-server-runtime.mjs index b89c188..16b785e 100644 --- a/tests/fixtures/codex-app-server-runtime.mjs +++ b/tests/fixtures/codex-app-server-runtime.mjs @@ -619,6 +619,42 @@ rl.on("line", (line) => { return; } + if (message.method === "thread/archive") { + send({ + id: message.id, + result: {}, + }); + send({ + method: "thread/archived", + params: { + threadId: message.params?.threadId, + archivedThreadSecret: "thread-archive-secret-should-not-leak", + }, + }); + return; + } + + if (message.method === "thread/unarchive") { + send({ + id: message.id, + result: { + thread: { + id: message.params?.threadId, + name: "private unarchived thread name should not leak", + archived: false, + }, + }, + }); + send({ + method: "thread/unarchived", + params: { + threadId: message.params?.threadId, + unarchivedThreadSecret: "thread-unarchive-secret-should-not-leak", + }, + }); + return; + } + if (message.method === "thread/read") { send({ id: message.id, diff --git a/tests/local-agent-codex-app-server-runner.test.mjs b/tests/local-agent-codex-app-server-runner.test.mjs index d3fbd86..bcfcc4d 100644 --- a/tests/local-agent-codex-app-server-runner.test.mjs +++ b/tests/local-agent-codex-app-server-runner.test.mjs @@ -1573,6 +1573,51 @@ test("codex app-server runner starts thread compaction without starting a normal assert.equal(result.turnId, undefined); }); +test("codex app-server runner archives and unarchives a thread without starting a normal turn", async () => { + const runnerConfig = getCodexAppServerRunnerConfig(process.env, { + codexAppServerEnabled: true, + codexAppServerCommand: process.execPath, + codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"], + codexAppServerWorkdir: repoRoot, + codexAppServerTimeoutMs: 5000, + }); + + const archiveResult = await executeCodexAppServerTask(runnerConfig, { + taskId: "task-thread-archive", + taskType: "conversation_reply", + intentCategory: "thread_archive", + targetCodexThreadRef: "019d-app-server-thread", + targetCodexFolderRef: repoRoot, + threadLifecycleAction: "archive", + executionPrompt: "归档当前 Codex 线程。", + }); + const unarchiveResult = await executeCodexAppServerTask(runnerConfig, { + taskId: "task-thread-unarchive", + taskType: "conversation_reply", + intentCategory: "thread_unarchive", + targetCodexThreadRef: "019d-app-server-thread", + targetCodexFolderRef: repoRoot, + threadLifecycleAction: "unarchive", + executionPrompt: "恢复当前 Codex 线程。", + }); + + assert.equal(archiveResult.status, "completed"); + assert.equal(archiveResult.threadId, "019d-app-server-thread"); + assert.equal(archiveResult.turnControl, "archive"); + assert.match(archiveResult.replyBody, /已归档 Codex 线程/); + assert.equal(archiveResult.turnId, undefined); + assert.equal(unarchiveResult.status, "completed"); + assert.equal(unarchiveResult.threadId, "019d-app-server-thread"); + assert.equal(unarchiveResult.turnControl, "unarchive"); + assert.match(unarchiveResult.replyBody, /已恢复 Codex 线程/); + assert.equal(unarchiveResult.turnId, undefined); + + const serialized = JSON.stringify({ archiveResult, unarchiveResult }); + assert.doesNotMatch(serialized, /thread-archive-secret-should-not-leak/); + assert.doesNotMatch(serialized, /thread-unarchive-secret-should-not-leak/); + assert.doesNotMatch(serialized, /private unarchived thread name should not leak/); +}); + test("codex app-server runner stays disabled unless feature flag is explicit", () => { const runnerConfig = getCodexAppServerRunnerConfig(process.env, { codexAppServerCommand: process.execPath, diff --git a/tests/thread-archive-route.test.ts b/tests/thread-archive-route.test.ts new file mode 100644 index 0000000..f76298f --- /dev/null +++ b/tests/thread-archive-route.test.ts @@ -0,0 +1,177 @@ +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 postRoute: (typeof import("../src/app/api/v1/projects/[projectId]/thread-archive/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +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-thread-archive-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [route, data, auth] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/thread-archive/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + postRoute = route.POST; + createAuthSession = data.createAuthSession; + 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 }); + } +}); + +test.beforeEach(async () => { + await setup(); + await rm(runtimeRoot, { recursive: true, force: true }); +}); + +async function createAuthedRequest(projectId: string, body: unknown) { + const session = await createAuthSession({ + account: "krisolo", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/thread-archive`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify(body), + }); +} + +function buildThreadProject() { + return { + id: "archive-project", + name: "可归档线程", + pinned: false, + systemPinned: false, + deviceIds: ["mac-studio"], + preview: "", + updatedAt: "2026-06-03T11:00:00+08:00", + lastMessageAt: "2026-06-03T11:00:00+08:00", + isGroup: false, + threadMeta: { + projectId: "archive-project", + threadId: "archive-thread", + threadDisplayName: "可归档线程", + folderName: "boss", + codexFolderRef: "/Users/kris/code/boss", + codexThreadRef: "codex-archive-thread", + updatedAt: "2026-06-03T11:00:00+08:00", + }, + groupMembers: [], + createdByAgent: false, + collaborationMode: "development" as const, + approvalState: "not_required" as const, + unreadCount: 0, + riskLevel: "low" as const, + messages: [], + goals: [], + versions: [], + }; +} + +test("POST /thread-archive queues a controlled Codex thread archive task", async () => { + const state = await readState(); + const project = buildThreadProject(); + state.projects = [ + ...state.projects.filter((item) => item.id === "master-agent"), + project, + ]; + await writeState(state); + + const response = await postRoute( + await createAuthedRequest(project.id, { + action: "archive", + reason: "项目已阶段性结束,归档线程。", + }), + { params: Promise.resolve({ projectId: project.id }) }, + ); + const payload = await response.json(); + + assert.equal(response.status, 200); + assert.equal(payload.ok, true); + assert.equal(payload.task.intentCategory, "thread_archive"); + assert.equal(payload.task.threadLifecycleAction, "archive"); + assert.equal(payload.task.threadLifecycleReason, "项目已阶段性结束,归档线程。"); + assert.equal(payload.task.targetProjectId, project.id); + assert.equal(payload.task.targetCodexThreadRef, "codex-archive-thread"); + + const persisted = (await readState()).masterAgentTasks.find( + (task) => task.taskId === payload.task.taskId, + ); + assert.equal(persisted?.status, "queued"); + assert.equal(persisted?.intentCategory, "thread_archive"); + assert.equal(persisted?.threadLifecycleAction, "archive"); +}); + +test("POST /thread-archive queues a controlled Codex thread unarchive task", async () => { + const state = await readState(); + const project = buildThreadProject(); + state.projects = [ + ...state.projects.filter((item) => item.id === "master-agent"), + project, + ]; + await writeState(state); + + const response = await postRoute( + await createAuthedRequest(project.id, { + action: "unarchive", + reason: "恢复线程继续开发。", + }), + { params: Promise.resolve({ projectId: project.id }) }, + ); + const payload = await response.json(); + + assert.equal(response.status, 200); + assert.equal(payload.ok, true); + assert.equal(payload.task.intentCategory, "thread_unarchive"); + assert.equal(payload.task.threadLifecycleAction, "unarchive"); + assert.equal(payload.task.threadLifecycleReason, "恢复线程继续开发。"); + + const persisted = (await readState()).masterAgentTasks.find( + (task) => task.taskId === payload.task.taskId, + ); + assert.equal(persisted?.status, "queued"); + assert.equal(persisted?.intentCategory, "thread_unarchive"); + assert.equal(persisted?.threadLifecycleAction, "unarchive"); +}); + +test("POST /thread-archive rejects unsupported lifecycle actions", async () => { + const state = await readState(); + const project = buildThreadProject(); + state.projects = [ + ...state.projects.filter((item) => item.id === "master-agent"), + project, + ]; + await writeState(state); + + const response = await postRoute( + await createAuthedRequest(project.id, { action: "delete" }), + { params: Promise.resolve({ projectId: project.id }) }, + ); + const payload = await response.json(); + + assert.equal(response.status, 400); + assert.equal(payload.ok, false); + assert.equal(payload.message, "THREAD_LIFECYCLE_ACTION_UNSUPPORTED"); +});