diff --git a/.env.example b/.env.example index 56d0e40..383643b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,26 @@ # Mission Control MVP flags # Keep readonly true until explicit live-mode validation is complete. GATEWAY_URL=ws://127.0.0.1:18789 +# Optional absolute URL used in outbound bridge links, for example: +# OPENCLAW_CONTROL_UI_URL=http://127.0.0.1:4310/ +# Optional Discord / Telegram mirror for task-room and hall-linked room updates. +# Keep disabled unless you explicitly want room events mirrored out. +# TASK_ROOM_BRIDGE_ENABLED=false +# TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +# TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN=123456:telegram-bot-token +# TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID=-1001234567890 +# Hall runtime dispatch controls whether hall discussion / assign / handoff +# use the real `openclaw agent` runtime and stream real session output. +# Leave enabled for the full hall experience; disable only when you want +# deterministic synthetic fallback replies for testing. +# HALL_RUNTIME_DISPATCH_ENABLED=true +# HALL_RUNTIME_DIRECT_STREAM_ENABLED=true +# HALL_RUNTIME_THINKING_LEVEL=minimal +# HALL_RUNTIME_TIMEOUT_SECONDS=600 +# HALL_RUNTIME_POLL_INTERVAL_MS=350 +# HALL_RUNTIME_HISTORY_LIMIT=120 +# HALL_RUNTIME_EXECUTION_CHAIN_ENABLED=true +# HALL_RUNTIME_EXECUTION_MAX_TURNS=3 # Optional path overrides when your OpenClaw/Codex data is not in the default home locations. # OPENCLAW_HOME=/path/to/.openclaw # OPENCLAW_CONFIG_PATH=/path/to/openclaw.json diff --git a/.gitignore b/.gitignore index 2feed2d..b357bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ docs/assets/*-preview*.png docs/assets/*-inspect*.png docs/assets/_wk-*.png docs/assets/_usage-*.png +.tmp_* 1440m 180m 720m diff --git a/README.md b/README.md index 379e3b7..e9d2ce5 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,17 @@ Language: **English** | [中文](README.zh-CN.md) - `Overview`: health, current state, decisions waiting, and operator-facing summaries - `Usage`: usage, spend, subscription windows, and connector status - `Staff`: who is really working now versus only queued -- `Collaboration`: parent-child relays and cross-session messages between existing agent sessions +- `Collaboration`: a hall-first multi-agent work chat with live discussion, execution order, handoff, review, and evidence threads - `Tasks`: current work, approvals, execution chains, and runtime evidence - `Documents` and `Memory`: source-backed workbenches scoped to active OpenClaw agents ## What this release adds -- `Collaboration`: a new standalone collaboration page so you can see both parent-child handoffs and verified cross-session agent communication such as `Main ⇄ Pandas`, instead of inferring everything from execution chains. +- `Collaboration`: a new hall-first collaboration page so you can post work in one shared timeline, see real agent roster names reply live, and watch discussion collapse into one execution owner before review. +- `Collaboration`: hall discussion, assign, and handoff can now dispatch through the real `openclaw agent` runtime, so the live draft stream reflects real session execution instead of only synthetic orchestrator text. +- `Collaboration`: hall runtime streaming now prefers direct CLI/stdout passthrough when OpenClaw emits live output, and gracefully falls back to session-history deltas when the runtime only exposes persisted session updates. +- `Collaboration`: `assign` can now trigger a short automatic real execution chain, so one owner can continue through several runtime turns before the task pauses in `review` or `blocked`. +- `Collaboration`: the hall now uses your current OpenClaw roster directly; existing agent ids do not need to be renamed to any control-center-specific defaults before they can discuss, execute, and hand off work. +- `Collaboration`: linked task rooms now act as detail and evidence threads with live room streaming, assignment, review, and optional outbound Discord or Telegram mirroring without moving source-of-truth storage out of `control-center`. - `Settings`: a new `Connection health` card that tells you what is already wired, what is still partial, and where to finish setup. - `Settings`: a new `Security risk summary` that translates current risk, impact, and next-step guidance into plain operator-facing language. - `Settings`: a new `Update status` card for current version, latest version, update channel, and install method. @@ -65,11 +70,27 @@ Example UI from a local OpenClaw environment: - Collaboration page
See parent-child relays and verified cross-session communication such as Main ⇄ Pandas in one place. + Collaboration page
See one shared thread where agents discuss, assign owners, hand off work, and review results. Security and update status
See current risk, impact, next-step guidance, and the gap between your current and latest version. +## Hall integration with your existing agents +- The hall reads the current OpenClaw roster from the runtime environment and uses the live agent ids and display names it finds there. +- Existing users do not need to rename agents to `pandas`, `coq`, `monkey`, or any other control-center-specific name before using hall collaboration. +- Role suggestions are heuristic only. If the roster has clear names such as `manager`, `planner`, `builder`, or `qa`, the hall will use them; otherwise it falls back gracefully without blocking the workflow. +- Hall runtime turns now carry structured transport context such as `surface`, `workspaceRoot`, `workdir`, `entryFiles`, and artifact references so repo-aware tasks can run against the same working context your agents already use elsewhere. + +## Hall workflow +- Start with one task in the hall. The first turn stays in `discussion`. +- Unless you explicitly `@` one agent, the hall aims to gather at least two short replies so the second person can add a missing angle instead of repeating the first. +- Use `Arrange execution order` to decide the first owner, later owners, and what each person hands off. +- Saving the order does **not** start execution. +- Once the queue is ready, the decision card will show `Start execution (...)`. +- During execution, each owner should finish only their own step, then visibly `@` the next owner in the same thread. +- Review should happen only after the last queued owner finishes, or when a human explicitly asks to stop and review. +- After review, use `Continue discussion` to reopen the thread, then arrange the next round and start again from the same thread. + ## 5-minute start ```bash npm install @@ -77,6 +98,7 @@ cp .env.example .env npm run build npm test npm run smoke:ui +npm run smoke:hall npm run dev:ui ``` @@ -103,12 +125,18 @@ Notes: ### Staff - Shows who is truly active now versus who only has queued work. - Separates live work from “next up” so backlog is not confused with active execution. +- If a card shows `Role not defined in workspace`, start with the [FAQ & best practices guide](docs/FAQ.md). - Best when you want to know who is busy, idle, blocked, or waiting. ### Collaboration -- Shows how work moves between agents: who accepted it first, who handed it off, and which session is holding the next move. -- Covers both parent-child session relays and verified cross-session communication such as `sessions_send` / `inter-session message`. -- Best when you want to understand “who passed this to whom, and where is the collaboration waiting now?” +- Shows a shared multi-agent hall where operators post work once and the current roster replies in one timeline. +- Streams draft agent replies live over SSE, then lands the final persisted message and any linked task-card state changes. +- When hall runtime dispatch is enabled, discussion, assign, and handoff turns are sent through the real `openclaw agent` runtime and mirrored back from real session history. +- When the runtime emits live stdout, the hall prefers that direct stream first; otherwise it keeps the draft alive with session-backed deltas so the operator still sees the real execution progress. +- Assignment can trigger multiple real execution turns in sequence before pausing for review or surfacing a blocker, rather than stopping after a single runtime turn. +- Keeps linked task rooms available as detail and evidence threads when you need deeper execution logs. +- The intended interaction style is a real work chat: short discussion turns, one owner at a time during execution, explicit `@handoff`, then review only after the queued owners finish. +- Best when you want to understand “who is talking now, who owns execution now, and what happens next?” ### Memory - A source-backed workbench for daily and long-term memory files. @@ -155,7 +183,8 @@ Notes: 4. `npm run build` 5. `npm test` 6. `npm run smoke:ui` -7. `npm run dev:ui` +7. `npm run smoke:hall` +8. `npm run dev:ui` ## Installation and onboarding @@ -246,9 +275,9 @@ Phase 1: inspect the environment 8. Do not assume any fixed agent names. If `openclaw.json` is readable, treat it as the source of truth. Phase 2: install the project -9. Confirm that the current directory is the control-center repo root. +9. Confirm that the current directory is the control-center repo root. If it has not been cloned yet, clone it first: `git clone https://github.com/TianyiDataScience/openclaw-control-center.git` 10. Verify the repo is complete before editing anything. -11. If core paths are missing, stop and re-clone the official repo. +11. If core paths (`src/runtime`, `src/ui`, `package.json`) are missing, do not continue. Re-clone from `https://github.com/TianyiDataScience/openclaw-control-center.git`. 12. Run dependency install. 13. If `.env` does not exist, create it from `.env.example`; otherwise correct it while preserving safe defaults. @@ -269,6 +298,7 @@ Phase 4: validation - npm run build - npm test - npm run smoke:ui + - npm run smoke:hall 18. If any step fails, stop and tell me exactly which step failed, why, and what I should fix next. 19. If build / test / smoke succeed but the live Gateway is still unreachable, classify the result as: local UI works, but live observability is not fully connected yet. diff --git a/README.zh-CN.md b/README.zh-CN.md index 6df5d7a..69330af 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,12 +20,14 @@ - `总览`:系统状态、待处理事项、关键风险和运营摘要 - `用量`:用量、花费、订阅窗口和连接状态 - `员工`:谁真的在工作,谁只是排队待命 -- `协作`:父子会话接力与智能体之间的跨会话通信 +- `协作`:一个 hall-first 的多 agent 工作群,可以在同一条线程里讨论、排顺序、交接、评审 - `任务`:当前任务、审批、执行链和运行证据 - `文档` 与 `记忆`:按活跃 OpenClaw agent 范围展示的源文件工作台 ## 这个版本新增了什么 -- `协作`:新增独立 `协作` 页面,直接看父子会话接力和 `Main ⇄ Pandas` 这种已验证跨会话通信,不再只看执行链猜关系。 +- `协作`:新增 hall-first 的独立 `协作` 页面,任务可以先在共享时间线里讨论、再收口到执行 owner,而不是只靠父子会话去猜任务怎么推进。 +- `协作`:hall 里的讨论、指派和交接现在可以走真实 `openclaw agent` 运行时,草稿流和最终落地消息都会回到同一条线程。 +- `协作`:hall 现在直接使用你当前 OpenClaw roster 里的 agent id 和显示名;已有用户不需要把 agent 改名成任何 control-center 私有名字就能接入。 - `设置`:新增 `接线状态`,直接告诉你哪些数据已经接好、哪些还差一步,以及该去哪里补。 - `设置`:新增 `安全风险摘要`,把当前风险、影响和下一步建议翻译成人话。 - `设置`:新增 `更新状态`,直接看当前版本、最新版本、更新通道和安装方式。 @@ -65,11 +67,27 @@ - 协作页
直接看父子会话接力,以及像 Main ⇄ Pandas 这样的已验证跨会话通信。 + 协作页
直接看多 agent 在同一条线程里讨论、拍板、执行、交接和评审,而不是把协作关系藏在不同会话里。 安全与更新状态
直接看当前风险、影响、下一步建议,以及当前版本和最新版本。 +## hall 如何接入你现有的 agent +- hall 会读取当前 OpenClaw 运行环境里的 roster,直接使用你已经在用的 agent id 和显示名。 +- 已有用户不需要把 agent 改名成 `pandas`、`coq`、`monkey` 这类 control-center 示例名,直接接入即可。 +- hall 的角色建议只是启发式,不是强绑定;如果你的 roster 名字里带有 `manager`、`planner`、`builder`、`qa` 之类信号,会优先用这些信号,否则会平滑降级,不会因为名字不匹配而卡住。 +- hall 运行时现在会把 `surface`、`workspaceRoot`、`workdir`、关键入口文件和 artifact 引用一起传给 agent,让 repo-aware 的任务尽量获得和其他渠道一致的执行上下文。 + +## hall 工作流 +- 从一条任务开始,第一轮会先留在 `讨论中`。 +- 如果你没有显式 `@` 某个人,hall 会尽量先收两条短回复,让第二个人承接第一人的上下文补缺口,而不是重写一遍。 +- 用 `安排后续顺序` 来决定谁先做、后面谁接、每一步交接给谁。 +- 保存顺序 **不会** 自动开始执行。 +- 队列排好后,结论卡会出现 `开始执行(...)`。 +- 进入执行后,每个人只做自己这一棒,并在同一条线程里显式 `@` 下一位交棒。 +- 只有最后一位执行者做完,或者人类明确要求先停下来评审时,才应该进入评审。 +- 一轮评审后,可以点 `继续讨论` 回到讨论态,再排下一轮顺序,然后从同一条线程继续开始。 + ## 5 分钟启动 ```bash npm install @@ -77,6 +95,7 @@ cp .env.example .env npm run build npm test npm run smoke:ui +npm run smoke:hall npm run dev:ui ``` @@ -103,12 +122,14 @@ npm run dev:ui ### 员工 - 展示谁现在真的在工作,谁只是有排队中的任务。 - 明确区分“正在执行”和“下一项”,避免把 backlog 误认为正在跑。 +- 如果员工卡片显示 `工作区未写明职责`,优先看 [FAQ 与最佳实践](docs/FAQ.md) 的第 1 条。 - 最适合判断谁忙、谁闲、谁卡住、谁在等待。 ### 协作 -- 独立展示智能体之间怎么交接、谁先接单、谁派给了谁、回复从哪条会话回来。 -- 既能看父会话与子会话的接力,也能看 `sessions_send` / `inter-session message` 这类已验证跨会话通信。 -- 最适合理解“这件事到底是谁转给了谁、现在卡在谁这里”。 +- hall-first 的多 agent 工作群:同一条线程里先讨论,再排顺序,再开始执行、交接和评审。 +- 运行时回复会直接回写到大厅时间线;需要更深证据时,再打开 linked room 看细节。 +- 预期风格更像真实员工群:讨论时短回复补缺口,执行时只认一个 owner,交接时显式 `@下一位`,最后才评审。 +- 最适合理解“这件事是谁在做、下一步交给谁、为什么现在能继续往前推进”。 ### 记忆 - 一个直接基于源文件的记忆工作台,用来查看和编辑每日记忆与长期记忆。 @@ -155,7 +176,8 @@ npm run dev:ui 4. `npm run build` 5. `npm test` 6. `npm run smoke:ui` -7. `npm run dev:ui` +7. `npm run smoke:hall` +8. `npm run dev:ui` ## 安装与上手 @@ -246,9 +268,9 @@ cp .env.example .env 8. 不要假设任何固定 agent 名称。若 `openclaw.json` 可读,就以它为准。 第二阶段:安装项目 -9. 确认当前目录是 control-center 仓库根目录。 +9. 确认当前目录是 control-center 仓库根目录。如果还没有 clone,先执行:`git clone https://github.com/TianyiDataScience/openclaw-control-center.git` 10. 先确认仓库本体完整。 -11. 如果缺少 `src/runtime`、`src/ui` 或 `package.json`,不要继续安装,直接重新 clone 官方仓库。 +11. 如果缺少 `src/runtime`、`src/ui` 或 `package.json`,不要继续安装,重新 clone:`https://github.com/TianyiDataScience/openclaw-control-center.git`。 12. 运行依赖安装。 13. 如果 `.env` 不存在,就从 `.env.example` 创建;如果存在,就在保留安全默认值的前提下修正它。 diff --git a/docs/COLLABORATION_HALL_MVP.md b/docs/COLLABORATION_HALL_MVP.md new file mode 100644 index 0000000..31124d3 --- /dev/null +++ b/docs/COLLABORATION_HALL_MVP.md @@ -0,0 +1,59 @@ +# Collaboration Hall MVP + +## Product shape +- `Collaboration` defaults to one shared hall, not one task room per task. +- Operators post requests in the hall. +- Agents reply using the current real roster names from `openclaw.json` / runtime roster discovery. +- Agent replies stream into the hall as SSE draft deltas before the final persisted message lands. +- When hall runtime dispatch is enabled, those draft deltas are backed by real `openclaw agent` runs and normalized session history, not only synthetic orchestrator text. +- When the runtime exposes live stdout, the hall now prefers that direct stream first and only falls back to session-history deltas when needed. +- Task rooms remain available as secondary detail and evidence threads. + +## Core objects +- `CollaborationHall`: the shared group chat container. +- `HallTaskCard`: the task card anchored in the hall timeline. +- `HallMessage`: one message in the shared timeline. +- `TaskRoom`: the linked detail/evidence thread behind a task card. + +## Routing rules +- `@RealAgentName` routes only to the matching participant. +- `@all` broadcasts to the active hall participants. +- No mention on a new task routes to the planner-like participant first. +- No mention during execution routes to the current execution owner first. + +## State model +- `discussion`: agents discuss the task and no execution tools are allowed. +- `execution`: one owner holds the execution lock and posts the main work updates. +- `review`: reviewer and operator decide whether the task passes or goes back. +- `blocked`: the task needs human help or a new handoff. +- `completed`: the task is done and the result stays visible in the hall timeline. + +## Anti-chaos guarantees +- One shared hall, but each task has one default execution owner. +- Speaker selection is explicit and deterministic. +- Execution requires a lock; another agent cannot silently take over. +- Runtime assignment can continue through several automatic execution turns before pausing, but only one owner still holds the lock during that chain. +- Multi-agent cooperation uses structured handoff packets: + - `goal` + - `current_result` + - `done_when` + - `blockers` + - `next_owner` + - `requires_input_from` + +## UI principles +- Hall timeline is the visual center. +- Task cards stay visible, but secondary to the active conversation. +- Draft agent replies should feel live, not poll-based, while still settling into durable stored messages. +- The operator should be able to answer three questions in under five seconds: + - Who is speaking now? + - Who owns execution now? + - What happens next? + +## Delivery model +- Hall clients subscribe to `/api/hall/events` with `EventSource`. +- Linked task-room clients subscribe to `/api/rooms/:roomId/events`. +- Generated agent replies emit `draft_start`, `draft_delta`, and `draft_complete` events. +- Hall discussion, assign, and handoff can dispatch to the real OpenClaw runtime, poll the live session history, and turn new assistant/tool output into draft deltas. +- Automatic execution chains can keep dispatching the same owner for a bounded number of runtime turns before the task moves into `review`, `blocked`, or manual continuation. +- Final messages still persist through the normal hall / room stores so refresh, replay, and summaries stay durable. diff --git a/docs/FAQ.md b/docs/FAQ.md index 4df793d..1c47b33 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -4,10 +4,12 @@ ## 中文版 -### 1. 多 Agent 工作区 — 为什么成员列表看不到职责? +### 1. 多 Agent 工作区 — 为什么成员列表看不到职责 / 显示“工作区未写明职责”? **问题:** 已经在 `SOUL.md` 和 `AGENTS.md` 中写好了角色和职责,但控制中心的员工页面看不到具体分工。 +**直接回答:** 员工页当前不会直接把某个配置文件里的 `role` / `mission` 字段原样摘出来显示。它更依赖 workspace 目录、`IDENTITY.md` 这类身份线索,以及 Gateway 返回的运行时元信息。 + **常见原因:** 控制中心会参考 OpenClaw 的 workspace 目录和运行时信号。是否能看到完整职责信息,通常取决于: - OpenClaw Gateway 返回的 session 数据中是否包含 agent 元信息 - `OPENCLAW_AGENT_ROOT` 环境变量是否指向了正确的 workspace 目录 @@ -40,6 +42,7 @@ - **状态(执行中/空闲)** → 主要来自 Gateway 的 session 运行信号 - **workspace** → 通常来自 agent 配置中的 `workspace` 字段 - **具体职责描述** → 当前不会自动把 `SOUL.md` 摘要直接显示成员工卡片文案,更适合作为文档线索保存在 `IDENTITY.md`、`SOUL.md`、`AGENTS.md` + - **不会直接摘取的内容** → 任意配置文件里自定义的 `role` / `mission` 文本,目前不会被员工卡片逐字渲染成职责说明 > **最佳实践:** 如果你希望控制中心更容易展示身份与职责线索,建议优先把简短身份说明写进 `IDENTITY.md`,并把详细角色说明写进 `SOUL.md` / `AGENTS.md`。 @@ -138,15 +141,18 @@ openclaw sessions history --limit 5 ## English Version -### 1. Multi-Agent Workspace — Why Can't I See Roles in the Staff Page? +### 1. Multi-Agent Workspace — Why Does the Staff Page Show "Role not defined in workspace"? **Issue:** Roles defined in `SOUL.md` and `AGENTS.md` don't appear in the Control Center staff list. +**Short answer:** The Staff page does not currently render an arbitrary `role` or `mission` field from one config file verbatim. It relies more on workspace discovery, identity hints such as `IDENTITY.md`, and runtime metadata returned by Gateway. + **Common guidance / best practice:** - Ensure your workspace directory follows the standard structure (`SOUL.md`, `IDENTITY.md`, `AGENTS.md`, `MEMORY.md`) - Set `OPENCLAW_AGENT_ROOT` environment variable to point to the parent directory of all workspaces - Confirm OpenClaw Gateway is running (`openclaw gateway status`) - A practical best practice is to put short identity/role hints in `IDENTITY.md`, and longer role definitions in `SOUL.md` / `AGENTS.md` +- Do not expect a custom `role` / `mission` field from an arbitrary config file to appear as the exact Staff-card responsibility text ### 2. Billing & Budget — How to Connect Data and Set Limits? diff --git a/docs/TASK_ROOM_BRIDGE.md b/docs/TASK_ROOM_BRIDGE.md new file mode 100644 index 0000000..2ead184 --- /dev/null +++ b/docs/TASK_ROOM_BRIDGE.md @@ -0,0 +1,39 @@ +# Task Room Bridge + +This bridge keeps `control-center` as the source of truth while letting external chat surfaces mirror room activity. + +## What it does + +- records outbound room events in `runtime/task-room-bridge-events.json` +- optionally mirrors room events to Discord and Telegram +- includes a deep link back to the `Collaboration` page with `roomId=...` +- never stores room state outside `control-center` + +## Supported event types + +- `room_created` +- `message_posted` +- `handoff_recorded` +- `executor_assigned` +- `review_submitted` +- `stage_changed` + +## Environment flags + +- `OPENCLAW_CONTROL_UI_URL` +- `TASK_ROOM_BRIDGE_ENABLED` +- `TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL` +- `TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN` +- `TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID` + +## Delivery model + +1. Core room mutation succeeds first. +2. Bridge event is written locally. +3. If Discord or Telegram mirroring is enabled, outbound payloads are sent for each configured target. +4. Bridge failures do not roll back the room mutation. + +## Operator use + +- Use the bridge for mirror notifications and human intervention across Discord or Telegram. +- Use the existing room APIs for any write-back from external bots or moderators. diff --git a/docs/TASK_ROOM_MVP.md b/docs/TASK_ROOM_MVP.md new file mode 100644 index 0000000..fb553dc --- /dev/null +++ b/docs/TASK_ROOM_MVP.md @@ -0,0 +1,59 @@ +# Task Room MVP + +This document defines the first MVP for task-room collaboration inside OpenClaw Control Center. + +Note: task rooms are now secondary detail and evidence threads behind the hall-first collaboration surface. They still preserve the room lifecycle below, and generated room replies now stream to the UI as draft deltas before the final message is persisted. + +## Goal + +Give each tracked task one durable collaboration room where: + +- humans can post requests directly +- `planner`, `coder`, `reviewer`, and `manager` discuss in sequence +- the system chooses an executor +- execution and review stay on one timeline +- task status and room state stay aligned + +## Room Stages + +- `intake`: the room exists but structured discussion has not started +- `discussion`: the orchestrator is collecting planner/coder/reviewer/manager messages +- `assigned`: an executor has been selected and the handoff is recorded +- `executing`: the executor is actively posting status or result updates +- `review`: execution is waiting for approval or rejection +- `completed`: the task passed review and the room is closed for the MVP flow + +## Message Kinds + +- `chat`: human request or general conversational note +- `proposal`: structured plan from planner/coder/reviewer +- `decision`: manager decision containing executor and done condition +- `handoff`: explicit ownership transfer between roles +- `status`: execution heartbeat or state transition note +- `result`: final output or review outcome + +## MVP Rules + +1. One task can have only one primary room. +2. A room can contain multiple handoffs. +3. Discussion starts with `planner`. +4. `coder` and `reviewer` each get one discussion turn per human prompt. +5. `manager` closes the discussion with a structured decision. +6. Once assigned, only the executor keeps talking unless another role is explicitly mentioned. +7. Review approval marks the task `done`. +8. Review rejection returns the task to `in_progress` or `blocked`. + +## Stored Artifacts + +- `runtime/chat-rooms.json` +- `runtime/chat-messages.json` +- `runtime/chat-summaries.json` + +## Structured Decision Output + +The manager decision must provide: + +- `proposal` +- `decision` +- `executor` +- `done_when` diff --git a/package.json b/package.json index a96861b..f4698ee 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ "dev:continuous": "cross-env MONITOR_CONTINUOUS=true node --import tsx src/index.ts", "dev:ui": "cross-env UI_MODE=true node --import tsx src/index.ts", "smoke:ui": "node scripts/ui-smoke.js", + "smoke:hall": "node --import tsx scripts/hall-release-smoke.ts", "command:backup-export": "cross-env APP_COMMAND=backup-export node --import tsx src/index.ts", "avatars:export": "node --import tsx scripts/export-staff-avatars.ts", "command:import-validate": "cross-env APP_COMMAND=import-validate node --import tsx src/index.ts", "command:acks-prune": "cross-env APP_COMMAND=acks-prune node --import tsx src/index.ts", "command:task-heartbeat": "cross-env APP_COMMAND=task-heartbeat node --import tsx src/index.ts", - "test": "node --import tsx --test test/**/*.test.ts", + "test": "node --import tsx scripts/run-tests-isolated.ts", "build": "tsc -p tsconfig.json", "release:audit": "bash scripts/release-audit.sh", "validate:task-store": "node --import tsx scripts/validate-task-store.ts", diff --git a/scripts/hall-release-smoke.ts b/scripts/hall-release-smoke.ts new file mode 100644 index 0000000..da63471 --- /dev/null +++ b/scripts/hall-release-smoke.ts @@ -0,0 +1,793 @@ +#!/usr/bin/env node + +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +const ROOT = join(__dirname, ".."); +const PORT = process.env.HALL_SMOKE_PORT || "4517"; +const RUNTIME_DIR = mkdtempSync(join(tmpdir(), "hall-release-smoke-")); +const PAGE_TIMEOUT_MS = 30_000; +const SERVER_TIMEOUT_MS = 20_000; + +type SeededTask = { + hallId: string; + taskCardId: string; + projectId: string; + taskId: string; + roomId: string; + title: string; +}; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForServer(baseUrl: string): Promise { + const deadline = Date.now() + SERVER_TIMEOUT_MS; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const response = await fetch(`${baseUrl}/healthz`); + if (response.ok) return; + lastError = new Error(`healthz returned ${response.status}`); + } catch (error) { + lastError = error; + } + await wait(500); + } + throw new Error(`hall smoke server did not become ready: ${String(lastError)}`); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +async function seedRuntime(): Promise<{ firstTask: SeededTask; secondTask: SeededTask; thirdTask: SeededTask; fourthTask: SeededTask; fifthTask: SeededTask; sixthTask: SeededTask; seventhTask: SeededTask; eighthTask: SeededTask }> { + process.env.OPENCLAW_RUNTIME_DIR = RUNTIME_DIR; + mkdirSync(RUNTIME_DIR, { recursive: true }); + + const [{ createHallTaskFromOperatorRequest }, { appendHallMessage, updateHallTaskCard }] = await Promise.all([ + import("../src/runtime/collaboration-hall-orchestrator"), + import("../src/runtime/collaboration-hall-store"), + ]); + + const first = await createHallTaskFromOperatorRequest( + { + content: "我想要做一个视频 介绍我的群聊功能", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + const second = await createHallTaskFromOperatorRequest( + { + content: "我想要策划一个互动数据叙事事件", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + const third = await createHallTaskFromOperatorRequest( + { + content: "我想把第二轮继续跑起来,确认下一位执行者还能开始。", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + const fourth = await createHallTaskFromOperatorRequest( + { + content: "我想从执行中切回讨论,再排第三轮顺序并重新开始。", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + const fifth = await createHallTaskFromOperatorRequest( + { + content: "请先扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + const sixth = await createHallTaskFromOperatorRequest( + { + content: "请把群聊功能收成一句能拍的总结,再交给下一位写 3 个 hook。", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + const seventh = await createHallTaskFromOperatorRequest( + { + content: "请排一个很长的多 agent 执行顺序,用来验证顺序编辑器可以滚动到最下面。", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + const eighth = await createHallTaskFromOperatorRequest( + { + content: "请打开一个空的执行顺序编辑器,用来验证没有已选执行者时的空态布局。", + authorParticipantId: "operator", + authorLabel: "Operator", + }, + { skipDiscussion: true }, + ); + + await appendHallMessage({ + hallId: first.hall.hallId, + kind: "proposal", + authorParticipantId: "coq", + authorLabel: "Coq-每日新闻", + content: "先把任务样本锁死:就用“做一个介绍群聊功能的视频”当片中任务。
这样开场 20 秒里就能自然出现讨论、拍板、owner、next action。
@pandas 可以直接按这个样本补一版最小台词和屏幕动作。", + projectId: first.taskCard.projectId, + taskId: first.taskCard.taskId, + taskCardId: first.taskCard.taskCardId, + roomId: first.roomId, + }); + + await updateHallTaskCard({ + taskCardId: first.taskCard.taskCardId, + stage: "discussion", + status: "todo", + proposal: "先选第一位执行者,再把这条视频任务推进成可执行顺序。", + latestSummary: "这条线程应该显示一个空的执行顺序控制台,并保持紧凑布局。", + }); + + await updateHallTaskCard({ + taskCardId: third.taskCard.taskCardId, + stage: "review", + status: "in_progress", + currentOwnerParticipantId: "builder", + currentOwnerLabel: "Builder", + currentExecutionItem: null, + decision: "第一轮先停在可评审状态,然后从 pandas 开第二轮。", + doneWhen: "第二轮能从同一张卡重新开始执行。", + plannedExecutionOrder: ["pandas"], + plannedExecutionItems: [ + { + itemId: "next-pandas", + participantId: "pandas", + task: "把第一轮评审结果收成第二轮的可执行起步稿。", + handoffWhen: "第二轮第一棒做完后贴回大厅。", + }, + ], + latestSummary: "这条线程应该还能从 pandas 重新开始第二轮执行。", + }); + + await updateHallTaskCard({ + taskCardId: fourth.taskCard.taskCardId, + stage: "execution", + status: "in_progress", + currentOwnerParticipantId: "main", + currentOwnerLabel: "main", + currentExecutionItem: { + itemId: "active-main", + participantId: "main", + task: "先把上一轮结果收住。", + handoffToParticipantId: "otter", + handoffWhen: "收住后交给 otter。", + }, + plannedExecutionOrder: ["otter"], + plannedExecutionItems: [ + { + itemId: "next-otter", + participantId: "otter", + task: "按新的方向开第三轮第一棒。", + handoffWhen: "开第三轮后贴回大厅。", + }, + ], + latestSummary: "这条线程应该能从执行中切回讨论,再重新开始第三轮。", + }); + + await appendHallMessage({ + hallId: fifth.hall.hallId, + kind: "proposal", + authorParticipantId: "coq", + authorLabel: "Coq-每日新闻", + content: "这题里 **“关键入口”** 最好先定义成“读懂 hall-chat 主链路时最先该进的文件”,不然 UI 外壳文件和真正的协作入口会混在一起。
按这个标准,`collaboration-hall.ts` 看呈现,`collaboration-hall-orchestrator.ts` 看编排,`hall-runtime-dispatch.ts` 看执行落地,`server.ts` 更像外层承载入口。", + projectId: fifth.taskCard.projectId, + taskId: fifth.taskCard.taskId, + taskCardId: fifth.taskCard.taskCardId, + roomId: fifth.roomId, + }); + + await appendHallMessage({ + hallId: fifth.hall.hallId, + kind: "proposal", + authorParticipantId: "monkey", + authorLabel: "monkey", + content: "这个划分已经够稳了,再补一个判断标准:凡是**不看它就读不通 hall-chat 主链路**的,才算这轮的关键入口。
按这个标准,`server.ts` 更像把页面和接口托起来的壳;真正决定 hall-chat 怎么显示、怎么收敛、怎么派发执行的,还是那 3 个主链路文件。", + projectId: fifth.taskCard.projectId, + taskId: fifth.taskCard.taskId, + taskCardId: fifth.taskCard.taskCardId, + roomId: fifth.roomId, + }); + + await appendHallMessage({ + hallId: fifth.hall.hallId, + kind: "status", + authorParticipantId: "pandas", + authorLabel: "pandas", + authorSemanticRole: "builder", + content: "- `src/ui/collaboration-hall.ts`:呈现层入口,负责把 hall-chat 的房间、消息、参与者、执行项、任务卡真正渲染成前台页面;这是“你看到的 hall 界面”本体。\n- `src/runtime/collaboration-hall-orchestrator.ts`:编排层入口,负责讨论轮转、speaker 选择、structured handoff、execution lock、角色解析和任务卡推进;这是“讨论怎么变成明确 owner 和下一步”的主逻辑。\n- `src/runtime/hall-runtime-dispatch.ts`:执行派发层入口,负责把 hall 里收口的结果转成真实 runtime 执行,并处理 stream、timeout、poll 等执行语义;这是“hall 结果怎么真正落地”的入口。\n\n`src/ui/server.ts` 要算更外层入口容器:它负责把 UI 页面和 runtime 路由接起来,但不属于 hall-chat 这条主链路的三层本体。\n@main 你只检查这个“三层 + 外层容器”的分法准不准。", + projectId: fifth.taskCard.projectId, + taskId: fifth.taskCard.taskId, + taskCardId: fifth.taskCard.taskCardId, + roomId: fifth.roomId, + payload: { status: "runtime_execution_update" }, + }); + + await appendHallMessage({ + hallId: fifth.hall.hallId, + kind: "handoff", + authorParticipantId: "main", + authorLabel: "main", + authorSemanticRole: "manager", + content: "这版判断没偏,三层主链路和 `src/ui/server.ts` 的外层容器定位都对。@otter 你只卡 must-fix,没硬伤就直接放行。", + projectId: fifth.taskCard.projectId, + taskId: fifth.taskCard.taskId, + taskCardId: fifth.taskCard.taskCardId, + roomId: fifth.roomId, + payload: { status: "runtime_handoff_update" }, + }); + + await appendHallMessage({ + hallId: fifth.hall.hallId, + kind: "handoff", + authorParticipantId: "otter", + authorLabel: "otter", + authorSemanticRole: "reviewer", + content: "`src/ui/collaboration-hall.ts` 对应 hall-chat 界面层,`src/runtime/collaboration-hall-orchestrator.ts` 对应编排流转层,`src/runtime/hall-runtime-dispatch.ts` 对应 agent 派发执行层;这条主链路判断准确,没有 must-fix。
`src/ui/server.ts` 作为外层入口容器的补充说明也成立。@main 现在请老板评审。", + projectId: fifth.taskCard.projectId, + taskId: fifth.taskCard.taskId, + taskCardId: fifth.taskCard.taskCardId, + roomId: fifth.roomId, + payload: { status: "runtime_handoff_update" }, + }); + + await appendHallMessage({ + hallId: fifth.hall.hallId, + kind: "system", + authorParticipantId: "system", + authorLabel: "System", + authorSemanticRole: "generalist", + content: "otter 把“只挑 must-fix,别扩 scope。”做到可评审了,现在请老板评审。", + projectId: fifth.taskCard.projectId, + taskId: fifth.taskCard.taskId, + taskCardId: fifth.taskCard.taskCardId, + roomId: fifth.roomId, + payload: { status: "execution_ready_for_review" }, + }); + + await updateHallTaskCard({ + taskCardId: fifth.taskCard.taskCardId, + stage: "review", + status: "in_progress", + currentOwnerParticipantId: "otter", + currentOwnerLabel: "otter", + currentExecutionItem: { + itemId: "review-otter", + participantId: "otter", + task: "只挑 must-fix,别扩 scope。", + handoffToParticipantId: "main", + handoffWhen: "没硬伤就请老板评审。", + }, + plannedExecutionOrder: ["pandas", "main", "otter"], + plannedExecutionItems: [ + { + itemId: "repo-pandas", + participantId: "pandas", + task: "扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。", + handoffToParticipantId: "main", + handoffWhen: "把 3 个入口和职责贴回大厅后交给 main。", + }, + { + itemId: "repo-main", + participantId: "main", + task: "检查三层 + 外层容器的分法准不准。", + handoffToParticipantId: "otter", + handoffWhen: "确认定位无误后交给 otter。", + }, + { + itemId: "repo-otter", + participantId: "otter", + task: "只挑 must-fix,别扩 scope。", + handoffToParticipantId: "main", + handoffWhen: "没有硬伤就请老板评审。", + }, + ], + latestSummary: "repo-scan 线程必须把 pandas 的代码结果、main 的复核和 otter 的 review 都显示在 UI 里。", + }); + + await appendHallMessage({ + hallId: sixth.hall.hallId, + kind: "status", + authorParticipantId: "pandas", + authorLabel: "pandas", + authorSemanticRole: "builder", + content: "新群聊功能已经收清了:它把讨论、分工、owner 收口、support-only 和 next action 串成一个可见的任务推进线程,能把本来会来回拉扯的事及时收住。
@main 你接着按这句写 3 个 hook。", + projectId: sixth.taskCard.projectId, + taskId: sixth.taskCard.taskId, + taskCardId: sixth.taskCard.taskCardId, + roomId: sixth.roomId, + payload: { status: "runtime_execution_update" }, + }); + + await updateHallTaskCard({ + taskCardId: sixth.taskCard.taskCardId, + stage: "execution", + status: "in_progress", + currentOwnerParticipantId: "pandas", + currentOwnerLabel: "pandas", + currentExecutionItem: { + itemId: "summary-pandas", + participantId: "pandas", + task: "把群聊功能收成一句能拍的总结,再交给下一位写 3 个 hook。", + handoffToParticipantId: "main", + handoffWhen: "总结贴回大厅后交给 main。", + }, + plannedExecutionOrder: ["pandas", "main"], + plannedExecutionItems: [ + { + itemId: "summary-pandas", + participantId: "pandas", + task: "把群聊功能收成一句能拍的总结,再交给下一位写 3 个 hook。", + handoffToParticipantId: "main", + handoffWhen: "总结贴回大厅后交给 main。", + }, + { + itemId: "summary-main", + participantId: "main", + task: "根据这句总结写 3 个 hook。", + handoffWhen: "把 3 个 hook 贴回大厅。", + }, + ], + latestSummary: "support-only 合法出现在执行结果时,UI 必须保留整句和 handoff。", + }); + + await updateHallTaskCard({ + taskCardId: seventh.taskCard.taskCardId, + stage: "discussion", + status: "todo", + currentOwnerParticipantId: "pandas", + currentOwnerLabel: "pandas", + currentExecutionItem: null, + decision: "这条线程专门验证执行顺序编辑器能滚到最下面。", + latestSummary: "执行顺序编辑器必须可以滚到最下面,看到保存按钮和全部 agent。", + plannedExecutionOrder: ["pandas", "main", "otter", "tiger", "coq", "monkey"], + plannedExecutionItems: [ + { + itemId: "planner-pandas", + participantId: "pandas", + task: "先去扫描 control-center 仓库,列出 3 个 hall-chat 关键入口文件,再把每个文件负责什么总结成一段清楚的话。", + handoffToParticipantId: "main", + handoffWhen: "把 3 个入口文件和职责都贴回大厅后交给 @main。", + }, + { + itemId: "planner-main", + participantId: "main", + task: "基于代码入口总结给 3 个不同风格的 20 秒开头,每版都突出 owner、next action、任务收口。", + handoffToParticipantId: "otter", + handoffWhen: "给出 3 版开头草稿后交给 @otter 只挑 must-fix。", + }, + { + itemId: "planner-otter", + participantId: "otter", + task: "只挑会影响普通观众即时理解的硬问题,不要扩 scope,不要重写整版结构。", + handoffToParticipantId: "tiger", + handoffWhen: "没有硬阻塞就把可继续版本交给 @tiger 补视觉方向。", + }, + { + itemId: "planner-tiger", + participantId: "tiger", + task: "给 3 个 thumbnail 视觉方向,每个方向都要一句可直接生成图片的提示词和一个可访问 URL 占位。", + handoffToParticipantId: "coq", + handoffWhen: "贴完 3 个视觉方向和 URL 占位后交给 @coq 收口。", + }, + { + itemId: "planner-coq", + participantId: "coq", + task: "把前面的结构、hook、thumbnail 方向收成最终可拍版本,确认叙事顺序不会再打架。", + handoffToParticipantId: "monkey", + handoffWhen: "确认可拍后交给 @monkey 做最后一轮执行整理。", + }, + { + itemId: "planner-monkey", + participantId: "monkey", + task: "把最终版本整理成这轮可执行结果,并明确下一轮是否还要继续讨论或直接开始执行。", + handoffWhen: "整理完最终可执行结果后,这轮可以保存并开始执行。", + }, + ], + }); + + await updateHallTaskCard({ + taskCardId: eighth.taskCard.taskCardId, + stage: "review", + status: "in_progress", + currentOwnerParticipantId: "otter", + currentOwnerLabel: "otter", + currentExecutionItem: { + itemId: "empty-otter", + participantId: "otter", + task: "确认空的执行顺序编辑器仍然保持紧凑。", + handoffWhen: "打开空态 planner 看布局。", + }, + decision: "这条线程专门验证没有已选执行者时的顺序编辑器空态。", + latestSummary: "空 planner 态应该紧凑,不应该把空框、agent 芯片和按钮拉满一整页。", + plannedExecutionOrder: [], + plannedExecutionItems: [], + }); + + return { + firstTask: { + hallId: first.hall.hallId, + taskCardId: first.taskCard.taskCardId, + projectId: first.taskCard.projectId, + taskId: first.taskCard.taskId, + roomId: first.roomId, + title: first.taskCard.title, + }, + secondTask: { + hallId: second.hall.hallId, + taskCardId: second.taskCard.taskCardId, + projectId: second.taskCard.projectId, + taskId: second.taskCard.taskId, + roomId: second.roomId, + title: second.taskCard.title, + }, + thirdTask: { + hallId: third.hall.hallId, + taskCardId: third.taskCard.taskCardId, + projectId: third.taskCard.projectId, + taskId: third.taskCard.taskId, + roomId: third.roomId, + title: third.taskCard.title, + }, + fourthTask: { + hallId: fourth.hall.hallId, + taskCardId: fourth.taskCard.taskCardId, + projectId: fourth.taskCard.projectId, + taskId: fourth.taskCard.taskId, + roomId: fourth.roomId, + title: fourth.taskCard.title, + }, + fifthTask: { + hallId: fifth.hall.hallId, + taskCardId: fifth.taskCard.taskCardId, + projectId: fifth.taskCard.projectId, + taskId: fifth.taskCard.taskId, + roomId: fifth.roomId, + title: fifth.taskCard.title, + }, + sixthTask: { + hallId: sixth.hall.hallId, + taskCardId: sixth.taskCard.taskCardId, + projectId: sixth.taskCard.projectId, + taskId: sixth.taskCard.taskId, + roomId: sixth.roomId, + title: sixth.taskCard.title, + }, + seventhTask: { + hallId: seventh.hall.hallId, + taskCardId: seventh.taskCard.taskCardId, + projectId: seventh.taskCard.projectId, + taskId: seventh.taskCard.taskId, + roomId: seventh.roomId, + title: seventh.taskCard.title, + }, + eighthTask: { + hallId: eighth.hall.hallId, + taskCardId: eighth.taskCard.taskCardId, + projectId: eighth.taskCard.projectId, + taskId: eighth.taskCard.taskId, + roomId: eighth.roomId, + title: eighth.taskCard.title, + }, + }; +} + +function startServer(): ChildProcessWithoutNullStreams { + return spawn(process.execPath, ["--import", "tsx", "src/index.ts"], { + cwd: ROOT, + env: { + ...process.env, + OPENCLAW_RUNTIME_DIR: RUNTIME_DIR, + UI_MODE: "true", + UI_PORT: PORT, + }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +async function runBrowserSmoke(baseUrl: string, firstTask: SeededTask, secondTask: SeededTask, thirdTask: SeededTask, fourthTask: SeededTask, fifthTask: SeededTask, sixthTask: SeededTask, seventhTask: SeededTask, eighthTask: SeededTask): Promise { + const { chromium } = await import("playwright"); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(String(error?.message || error))); + + try { + await page.goto(`${baseUrl}/?section=hall-chat&taskCardId=${encodeURIComponent(firstTask.taskCardId)}`, { + waitUntil: "domcontentloaded", + timeout: PAGE_TIMEOUT_MS, + }); + + await page.waitForSelector("[data-collaboration-hall-root]", { timeout: PAGE_TIMEOUT_MS }); + await page.waitForSelector("[data-task-card-id]", { timeout: PAGE_TIMEOUT_MS }); + + const headline = (await page.locator("[data-hall-headline]").textContent())?.trim(); + assert(headline === "围绕同一条线程讨论、分工、交接和评审。", `unexpected hall headline: ${headline}`); + + const threadTitle = (await page.locator("[data-hall-thread-title]").textContent())?.trim(); + assert(threadTitle === firstTask.title, `expected selected thread to be "${firstTask.title}", got "${threadTitle}"`); + + const bodyHtml = await page + .locator(".hall-message") + .filter({ hasText: "Coq-每日新闻" }) + .locator(".hall-message-body") + .first() + .innerHTML(); + assert(bodyHtml.includes("
"), "expected seeded hall message to render
tags"); + assert(bodyHtml.includes("hall-md-mention"), "expected seeded hall message to render mention highlight"); + + await page.locator(`[data-task-card-id="${eighthTask.taskCardId}"]`).click(); + await page.waitForFunction( + (expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle, + eighthTask.title, + { timeout: PAGE_TIMEOUT_MS }, + ); + await page.waitForSelector("[data-hall-plan-order]", { timeout: PAGE_TIMEOUT_MS }); + await page.locator("[data-hall-plan-order]").click(); + await page.waitForSelector(".hall-decision-card--planner.is-empty", { timeout: PAGE_TIMEOUT_MS }); + const emptyPlannerMetrics = await page.evaluate(() => { + const empty = document.querySelector(".hall-order-empty") as HTMLElement | null; + const save = document.querySelector("[data-hall-order-save]") as HTMLElement | null; + const cancel = document.querySelector("[data-hall-order-cancel]") as HTMLElement | null; + const chips = Array.from(document.querySelectorAll(".hall-order-chip")).slice(0, 3) as HTMLElement[]; + return { + emptyHeight: empty?.getBoundingClientRect().height ?? 0, + saveHeight: save?.getBoundingClientRect().height ?? 0, + cancelHeight: cancel?.getBoundingClientRect().height ?? 0, + chipHeights: chips.map((chip) => chip.getBoundingClientRect().height), + }; + }); + assert(emptyPlannerMetrics.emptyHeight < 90, `expected empty planner callout to stay compact, got ${JSON.stringify(emptyPlannerMetrics)}`); + assert(emptyPlannerMetrics.saveHeight < 48, `expected empty planner save button to stay compact, got ${JSON.stringify(emptyPlannerMetrics)}`); + assert(emptyPlannerMetrics.cancelHeight < 48, `expected empty planner cancel button to stay compact, got ${JSON.stringify(emptyPlannerMetrics)}`); + assert( + emptyPlannerMetrics.chipHeights.every((height) => height < 56), + `expected available-agent chips to stay compact in empty planner, got ${JSON.stringify(emptyPlannerMetrics)}`, + ); + await page.locator("[data-hall-order-cancel]").click(); + await page.waitForSelector(".hall-decision-card--planner", { state: "hidden", timeout: PAGE_TIMEOUT_MS }); + + await page.locator(`[data-task-card-id="${secondTask.taskCardId}"]`).click(); + await page.waitForFunction( + (expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle, + secondTask.title, + { timeout: PAGE_TIMEOUT_MS }, + ); + + await page.locator("[data-hall-compose-task]").click(); + await page.waitForFunction( + () => document.querySelector("[data-hall-send-reply]")?.textContent?.trim() === "创建任务", + undefined, + { timeout: PAGE_TIMEOUT_MS }, + ); + const flashText = (await page.locator("[data-hall-flash]").textContent())?.trim() || ""; + assert(flashText.includes("写下新任务后直接按 Enter 创建"), `unexpected composer flash text: ${flashText}`); + + await page.locator(`[data-task-card-id="${thirdTask.taskCardId}"]`).click(); + await page.waitForFunction( + (expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle, + thirdTask.title, + { timeout: PAGE_TIMEOUT_MS }, + ); + await page.waitForFunction( + () => { + const panel = document.querySelector("[data-hall-decision-panel]"); + return !!panel && !panel.hidden && !!panel.querySelector("[data-hall-current-console]"); + }, + undefined, + { timeout: PAGE_TIMEOUT_MS }, + ); + const thirdTaskConsolePlacement = await page.evaluate(() => { + const thread = document.querySelector("[data-hall-thread]"); + const panel = document.querySelector("[data-hall-decision-panel]"); + return { + threadHasConsole: !!thread?.querySelector("[data-hall-current-console]"), + panelHasConsole: !!panel?.querySelector("[data-hall-current-console]"), + panelHidden: !!(panel && panel.hidden), + }; + }); + assert(!thirdTaskConsolePlacement.threadHasConsole, "expected the current console to stay out of the message timeline"); + assert(thirdTaskConsolePlacement.panelHasConsole, "expected the current console to render in the bottom decision panel"); + assert(!thirdTaskConsolePlacement.panelHidden, "expected the bottom decision panel to stay visible for a selected task"); + const restartLabel = (await page.locator("[data-hall-start-execution]").first().textContent())?.trim() || ""; + assert(restartLabel.includes("开始执行("), `expected a restart execution button on queued review thread, got "${restartLabel}"`); + + await page.locator(`[data-task-card-id="${fourthTask.taskCardId}"]`).click(); + await page.waitForFunction( + (expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle, + fourthTask.title, + { timeout: PAGE_TIMEOUT_MS }, + ); + await page.locator("[data-hall-continue-discussion]").first().click(); + await page.waitForTimeout(1200); + await page.locator("[data-hall-plan-order]").click(); + await page.waitForSelector("[data-hall-order-save]", { timeout: PAGE_TIMEOUT_MS }); + const taskEditor = page.locator("[data-hall-item-task='otter']").first(); + await taskEditor.focus(); + await taskEditor.fill("按新的方向开第三轮第一棒,并把结果贴回大厅。"); + await page.waitForTimeout(4500); + const taskEditorState = await taskEditor.evaluate((node) => ({ + value: (node instanceof HTMLTextAreaElement ? node.value : ""), + focused: document.activeElement === node, + })); + assert(taskEditorState.focused, "expected task editor to keep focus while background polling continues"); + assert( + taskEditorState.value.includes("按新的方向开第三轮第一棒"), + `expected task editor value to survive polling, got "${taskEditorState.value}"`, + ); + await page.locator("[data-hall-order-add='pandas']").click(); + await page.locator("[data-hall-order-save]").click(); + await page.waitForTimeout(2600); + const postSaveStartLabel = (await page.locator("[data-hall-start-execution]").first().textContent())?.trim() || ""; + assert(postSaveStartLabel.includes("开始执行("), `expected start execution button after saving a replanned round, got "${postSaveStartLabel}"`); + const selectedCardTextAfterSave = (await page.locator(`[data-task-card-id="${fourthTask.taskCardId}"]`).innerText())?.trim() || ""; + assert( + !selectedCardTextAfterSave.includes("main · 执行中"), + `expected saved replanned round to stop showing stale executing state in the selected task card, got "${selectedCardTextAfterSave}"`, + ); + + await page.locator(`[data-task-card-id="${fifthTask.taskCardId}"]`).click(); + await page.waitForFunction( + (expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle, + fifthTask.title, + { timeout: PAGE_TIMEOUT_MS }, + ); + const repoThreadText = (await page.locator("[data-hall-thread]").innerText())?.trim() || ""; + assert(repoThreadText.includes("src/ui/collaboration-hall.ts"), "expected repo-scan result to keep pandas file-path output visible"); + assert(repoThreadText.includes("src/runtime/collaboration-hall-orchestrator.ts"), "expected repo-scan result to show orchestrator file path"); + assert(repoThreadText.includes("src/runtime/hall-runtime-dispatch.ts"), "expected repo-scan result to show dispatch file path"); + assert(!repoThreadText.includes("Handoff moved to pandas"), "expected repo-scan thread to avoid wrong handoff warning"); + const pandasVisibleMessage = page + .locator(".hall-message[data-kind='status']") + .filter({ hasText: "src/ui/collaboration-hall.ts" }) + .locator(".hall-message-body") + .first(); + const pandasVisibleText = (await pandasVisibleMessage.innerText())?.trim() || ""; + assert(pandasVisibleText.includes("src/ui/collaboration-hall.ts"), "expected pandas repo-scan reply to keep the UI file path visible"); + assert( + pandasVisibleText.includes("src/runtime/collaboration-hall-orchestrator.ts"), + "expected pandas repo-scan reply to keep the orchestrator file path visible", + ); + assert( + pandasVisibleText.includes("src/runtime/hall-runtime-dispatch.ts"), + "expected pandas repo-scan reply to keep the dispatch file path visible", + ); + assert(!pandasVisibleText.includes("…"), "expected pandas repo-scan reply to stay visible instead of collapsing to an ellipsis"); + + await page.locator(`[data-task-card-id="${sixthTask.taskCardId}"]`).click(); + await page.waitForFunction( + (expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle, + sixthTask.title, + { timeout: PAGE_TIMEOUT_MS }, + ); + const supportOnlyThreadText = (await page.locator("[data-hall-thread]").innerText())?.trim() || ""; + assert( + supportOnlyThreadText.includes("support-only 和 next action 串成一个可见的任务推进线程"), + "expected support-only summary line to remain visible in the thread", + ); + assert( + supportOnlyThreadText.includes("@main 你接着按这句写 3 个 hook。"), + "expected support-only execution result to keep the @main handoff line visible", + ); + + await page.locator(`[data-task-card-id="${seventhTask.taskCardId}"]`).click(); + await page.waitForFunction( + (expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle, + seventhTask.title, + { timeout: PAGE_TIMEOUT_MS }, + ); + await page.locator("[data-hall-plan-order]").click(); + await page.waitForSelector(".hall-decision-card--planner", { timeout: PAGE_TIMEOUT_MS }); + const plannerLayoutState = await page.evaluate(() => { + const composer = document.querySelector(".hall-composer-shell") as HTMLElement | null; + const thread = document.querySelector(".hall-thread") as HTMLElement | null; + const decisionPanel = document.querySelector("[data-hall-decision-panel]") as HTMLElement | null; + return { + composerDisplay: composer ? getComputedStyle(composer).display : null, + threadDisplay: thread ? getComputedStyle(thread).display : null, + decisionOverflow: decisionPanel ? getComputedStyle(decisionPanel).overflowY : null, + }; + }); + assert(plannerLayoutState.composerDisplay === "none", `expected composer to be hidden while planning, got ${JSON.stringify(plannerLayoutState)}`); + assert(plannerLayoutState.threadDisplay === "none", `expected thread timeline to be hidden while planning, got ${JSON.stringify(plannerLayoutState)}`); + const plannerMetricsBefore = await page.evaluate(() => { + const planner = document.querySelector(".hall-decision-card--planner") as HTMLElement | null; + return { + scrollTop: planner?.scrollTop ?? 0, + clientHeight: planner?.clientHeight ?? 0, + scrollHeight: planner?.scrollHeight ?? 0, + }; + }); + assert( + plannerMetricsBefore.scrollHeight > plannerMetricsBefore.clientHeight, + `expected long execution planner to overflow vertically, got ${JSON.stringify(plannerMetricsBefore)}`, + ); + await page.locator(".hall-decision-card--planner").hover(); + await page.mouse.wheel(0, 1800); + await page.waitForTimeout(500); + const plannerMetricsAfter = await page.evaluate(() => { + const planner = document.querySelector(".hall-decision-card--planner") as HTMLElement | null; + return { + scrollTop: planner?.scrollTop ?? 0, + clientHeight: planner?.clientHeight ?? 0, + scrollHeight: planner?.scrollHeight ?? 0, + }; + }); + assert( + plannerMetricsAfter.scrollTop > plannerMetricsBefore.scrollTop, + `expected planner card to scroll after wheel, got before=${JSON.stringify(plannerMetricsBefore)} after=${JSON.stringify(plannerMetricsAfter)}`, + ); + + assert(pageErrors.length === 0, `pageerror(s): ${pageErrors.join(" | ")}`); + } finally { + await browser.close(); + } +} + +async function main(): Promise { + const baseUrl = `http://127.0.0.1:${PORT}`; + const seeded = await seedRuntime(); + const child = startServer(); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + const cleanup = () => { + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + rmSync(RUNTIME_DIR, { recursive: true, force: true }); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + try { + await waitForServer(baseUrl); + await runBrowserSmoke(baseUrl, seeded.firstTask, seeded.secondTask, seeded.thirdTask, seeded.fourthTask, seeded.fifthTask, seeded.sixthTask, seeded.seventhTask, seeded.eighthTask); + console.log(`Hall release smoke passed on ${baseUrl}`); + } catch (error) { + console.error("Hall release smoke failed."); + console.error(stdout.trim()); + console.error(stderr.trim()); + throw error; + } finally { + cleanup(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/live-hall-browser-check.js b/scripts/live-hall-browser-check.js new file mode 100644 index 0000000..c0f3a45 --- /dev/null +++ b/scripts/live-hall-browser-check.js @@ -0,0 +1,118 @@ +const { chromium } = require("playwright"); + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } }); + const errors = []; + + page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(`console:${message.text()}`); + } + }); + + await page.goto("http://127.0.0.1:4310/?section=hall-chat", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + const rawHtml = await page.content(); + const hasHallRoot = rawHtml.includes("data-collaboration-hall-root"); + if (!hasHallRoot) { + console.log( + JSON.stringify( + { + currentUrl: page.url(), + pageTitle: await page.title(), + hasHallRoot, + htmlSample: rawHtml.slice(0, 1200), + errors, + }, + null, + 2, + ), + ); + await browser.close(); + return; + } + + await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000, state: "attached" }); + await page.waitForTimeout(1500); + + const initialHeadline = await page.locator("[data-hall-headline]").textContent().catch(() => null); + const initialTitle = await page.locator("[data-hall-thread-title]").textContent().catch(() => null); + const cards = page.locator("[data-task-card-id]"); + const cardCount = await cards.count(); + const selectedCardIdBefore = await page + .locator("[data-task-card-id][aria-current='page']") + .first() + .getAttribute("data-task-card-id") + .catch(() => null); + const initialBodyNodes = await page + .locator(".hall-message-body") + .evaluateAll((nodes) => nodes.slice(0, 3).map((node) => node.innerHTML)); + + let switchedTitle = null; + let selectedCardIdAfter = null; + if (cardCount > 1) { + await cards.nth(1).click({ timeout: 10000 }); + await page.waitForTimeout(800); + switchedTitle = await page.locator("[data-hall-thread-title]").textContent().catch(() => null); + selectedCardIdAfter = await page + .locator("[data-task-card-id][aria-current='page']") + .first() + .getAttribute("data-task-card-id") + .catch(() => null); + } + + await page.locator("[data-hall-compose-task]").click({ timeout: 10000 }); + await page.waitForTimeout(600); + + const composerState = await page.evaluate(() => ({ + mounted: !!document.querySelector("[data-collaboration-hall-root]"), + taskMode: document + .querySelector("[data-collaboration-hall-root]") + ?.classList.contains("is-composing-task"), + placeholder: + document.querySelector("[data-hall-composer-textarea]")?.getAttribute("placeholder") || null, + headline: document.querySelector("[data-hall-headline]")?.textContent || null, + })); + + const bodyNodesAfterSwitch = await page + .locator(".hall-message-body") + .evaluateAll((nodes) => nodes.slice(0, 3).map((node) => node.innerHTML)); + + await page.screenshot({ + path: "/tmp/hall4310_browser_check.png", + fullPage: true, + }); + + console.log( + JSON.stringify( + { + initialHeadline, + initialTitle, + currentUrl: page.url(), + pageTitle: await page.title(), + hasHallRoot, + cardCount, + selectedCardIdBefore, + switchedTitle, + selectedCardIdAfter, + initialBodyNodes, + composerState, + bodyNodesAfterSwitch, + errors, + }, + null, + 2, + ), + ); + + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/live-hall-execution-check.js b/scripts/live-hall-execution-check.js new file mode 100644 index 0000000..5002e70 --- /dev/null +++ b/scripts/live-hall-execution-check.js @@ -0,0 +1,152 @@ +const { chromium } = require("playwright"); + +async function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPlanButton(page, timeoutMs = 120000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const count = await page.locator("[data-hall-plan-order]").count().catch(() => 0); + if (count > 0) return true; + await wait(2000); + } + return false; +} + +async function askForPlanButton(page) { + await page.locator("[data-hall-composer-textarea]").fill("请收口并安排执行顺序,然后开始执行。"); + await page.locator("[data-hall-send-reply]").click(); +} + +async function ensureOrderParticipant(page, participantId) { + const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first(); + if (await chip.count()) { + await chip.click(); + } +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } }); + const errors = []; + + page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(`console:${message.text()}`); + } + }); + + await page.goto("http://127.0.0.1:4310/?section=hall-chat", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 }); + + await page.locator("[data-hall-compose-task]").click(); + await page + .locator("[data-hall-composer-textarea]") + .fill("请先扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。"); + await page.locator("[data-hall-send-reply]").click(); + + let hasPlanButton = await waitForPlanButton(page, 45000); + if (!hasPlanButton) { + await askForPlanButton(page); + hasPlanButton = await waitForPlanButton(page, 45000); + } + if (!hasPlanButton) { + throw new Error("plan-order button did not appear"); + } + + await page.locator("[data-hall-plan-order]").first().click(); + await page.waitForSelector("[data-hall-order-save]", { timeout: 20000 }); + + await ensureOrderParticipant(page, "pandas"); + await ensureOrderParticipant(page, "main"); + await ensureOrderParticipant(page, "otter"); + + await page + .locator('[data-hall-item-task="pandas"]') + .fill("扫描 control-center 代码,至少贴出 3 个真实文件路径,并说明每个文件负责什么。"); + await page.locator('[data-hall-item-handoff-to="pandas"]').selectOption("main"); + await page + .locator('[data-hall-item-handoff="pandas"]') + .fill("贴完 3 个文件路径和职责后交给 @main 评审是否够准确。"); + + await page + .locator('[data-hall-item-task="main"]') + .fill("只检查 pandas 列的文件是否真的关键、解释是否准确;不重做扫描。"); + await page.locator('[data-hall-item-handoff-to="main"]').selectOption("otter"); + await page + .locator('[data-hall-item-handoff="main"]') + .fill("确认关键文件没问题后交给 @otter 只挑 must-fix。"); + + await page.locator('[data-hall-item-task="otter"]').fill("只挑 must-fix,别扩 scope。"); + await page.locator('[data-hall-item-handoff-to="otter"]').selectOption(""); + await page.locator('[data-hall-item-handoff="otter"]').fill("没有 must-fix 就请老板评审。"); + + await page.locator("[data-hall-order-save]").click(); + await page.waitForTimeout(2500); + + const startButton = page.locator("[data-hall-start-execution]").first(); + if (await startButton.count()) { + await startButton.click(); + } else { + throw new Error("start execution button did not appear after saving order"); + } + + const timeline = []; + for (let attempt = 1; attempt <= 18; attempt += 1) { + await wait(5000); + const authors = await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []); + const bodies = await page + .locator(".hall-message .hall-message-body") + .evaluateAll((nodes) => nodes.map((node) => node.textContent || "")) + .catch(() => []); + const stage = await page.locator("[data-hall-thread-meta]").textContent().catch(() => null); + timeline.push({ + t: attempt * 5, + stage, + authors, + lastBodies: bodies.slice(-6), + }); + } + + const finalBodies = timeline.at(-1)?.lastBodies || []; + const hasVisibleFilePath = finalBodies.some((body) => /src\/ui\/collaboration-hall\.ts|src\/runtime\/collaboration-hall-orchestrator\.ts|src\/runtime\/hall-runtime-dispatch\.ts/.test(body)); + const hasWrongHandoffWarning = timeline.some((entry) => + entry.lastBodies.some((body) => body.includes("Handoff moved to")), + ); + const visibleMessages = await page + .locator(".hall-message .hall-message-body") + .evaluateAll((nodes) => nodes.map((node) => ({ + text: node.textContent || "", + lineClamp: getComputedStyle(node).webkitLineClamp, + overflow: getComputedStyle(node).overflow, + whiteSpace: getComputedStyle(node).whiteSpace, + display: getComputedStyle(node).display, + }))) + .catch(() => []); + + console.log( + JSON.stringify( + { + errors, + timeline, + hasVisibleFilePath, + hasWrongHandoffWarning, + visibleMessages: visibleMessages.slice(-4), + }, + null, + 2, + ), + ); + + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/live-hall-full-check.js b/scripts/live-hall-full-check.js new file mode 100644 index 0000000..920bbb6 --- /dev/null +++ b/scripts/live-hall-full-check.js @@ -0,0 +1,281 @@ +const { chromium } = require("playwright"); + +async function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } }); + const errors = []; + const scanPrompt = `full-check-${Date.now()} 请先扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。`; + const scanPrefix = scanPrompt.split(" ")[0]; + + page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(`console:${message.text()}`); + }); + + await page.goto("http://127.0.0.1:4310/?section=hall-chat", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 }); + + // 1) Start a fresh task and verify discussion + typing lifecycle. + await page.locator("[data-hall-compose-task]").click(); + await page + .locator("[data-hall-composer-textarea]") + .fill(scanPrompt); + await page.locator("[data-hall-send-reply]").click(); + + let selectedFreshThread = false; + for (let attempt = 0; attempt < 20; attempt += 1) { + const headerTitle = await page + .locator("[data-hall-thread-title]") + .textContent() + .catch(() => ""); + if (String(headerTitle || "").includes(scanPrefix)) { + selectedFreshThread = true; + break; + } + const createdThreadCard = page + .locator("[data-task-card-id]") + .filter({ hasText: scanPrefix }) + .first(); + if (await createdThreadCard.count().catch(() => 0)) { + await createdThreadCard.click(); + await wait(500); + const nextHeaderTitle = await page + .locator("[data-hall-thread-title]") + .textContent() + .catch(() => ""); + if (String(nextHeaderTitle || "").includes(scanPrefix)) { + selectedFreshThread = true; + break; + } + } + await wait(1500); + } + if (!selectedFreshThread) throw new Error("newly created thread never became selected"); + + let typingSeen = false; + let planButtonSeen = false; + for (let attempt = 0; attempt < 40; attempt += 1) { + const typingTexts = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []); + if (typingTexts.some((text) => String(text || "").trim().length > 0)) typingSeen = true; + if (await page.locator("[data-hall-plan-order]").count().catch(() => 0)) { + planButtonSeen = true; + break; + } + await wait(3000); + } + if (!typingSeen) throw new Error("typing strip never appeared after creating a task"); + if (!planButtonSeen) throw new Error("plan-order button never appeared"); + + // 2) Planner opens as a dedicated editing surface and can scroll. + await page.locator("[data-hall-plan-order]").first().click(); + await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 }); + + const plannerState = await page.evaluate(() => { + const planner = document.querySelector(".hall-order-planner"); + const composer = document.querySelector(".hall-composer-shell"); + const thread = document.querySelector(".hall-thread"); + const saveButton = document.querySelector("[data-hall-order-save]"); + const emptyState = document.querySelector(".hall-order-empty"); + return { + plannerOverflow: planner ? getComputedStyle(planner).overflowY : null, + plannerHeight: planner ? planner.getBoundingClientRect().height : null, + composerDisplay: composer ? getComputedStyle(composer).display : null, + threadDisplay: thread ? getComputedStyle(thread).display : null, + saveHeight: saveButton ? saveButton.getBoundingClientRect().height : null, + emptyHeight: emptyState ? emptyState.getBoundingClientRect().height : null, + }; + }); + + if (plannerState.composerDisplay !== "none") throw new Error("composer should be hidden while planning"); + if (plannerState.threadDisplay !== "none") throw new Error("thread should be hidden while planning"); + if ((plannerState.saveHeight ?? 0) > 56) throw new Error("planner buttons are stretched too tall in empty state"); + if ((plannerState.emptyHeight ?? 0) > 120) throw new Error("empty planner state is still stretched too tall"); + + // 2b) Long planner should scroll. + for (const participantId of ["coq", "main", "monkey", "otter", "pandas", "tiger"]) { + const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first(); + if (await chip.count()) await chip.click(); + } + const plannerScrolled = await page.evaluate(async () => { + const card = document.querySelector('.hall-decision-card--planner'); + if (!card) return false; + const before = card.scrollTop; + card.scrollTop = before + 240; + await new Promise((resolve) => setTimeout(resolve, 50)); + return card.scrollTop > before; + }); + if (!plannerScrolled) throw new Error("long planner did not scroll"); + await page.locator("[data-hall-order-cancel]").click(); + await wait(500); + await page.locator("[data-hall-plan-order]").first().click(); + await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 }); + + // 3) Configure execution order. + for (const participantId of ["pandas", "main", "otter"]) { + const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first(); + if (await chip.count()) await chip.click(); + } + + await page + .locator('[data-hall-item-task="pandas"]') + .fill("扫描 control-center 代码,贴出 3 个真实文件路径,并说明每个文件负责什么。"); + await page.locator('[data-hall-item-handoff-to="pandas"]').selectOption("main"); + await page + .locator('[data-hall-item-handoff="pandas"]') + .fill("贴完 3 个文件路径和职责后交给 @main 复核。"); + + await page + .locator('[data-hall-item-task="main"]') + .fill("只复核 pandas 列出的文件是不是关键入口,不重做扫描。"); + await page.locator('[data-hall-item-handoff-to="main"]').selectOption("otter"); + await page + .locator('[data-hall-item-handoff="main"]') + .fill("确认关键入口没问题后交给 @otter 只挑 must-fix。"); + + await page.locator('[data-hall-item-task="otter"]').fill("只挑 must-fix,没有问题就请老板评审。"); + await page.locator('[data-hall-item-handoff-to="otter"]').selectOption(""); + await page.locator('[data-hall-item-handoff="otter"]').fill("没有 must-fix 就请老板评审。"); + + await page.locator("[data-hall-order-save]").click(); + await wait(2500); + + const startButton = page.locator("[data-hall-start-execution]").first(); + if (!(await startButton.count())) throw new Error("start execution button missing after saving order"); + + // 4) Execution chain should produce visible repo-scan output and no wrong handoff warning. + await startButton.click(); + + let foundPaths = false; + let wrongHandoffWarning = false; + let executionSettled = false; + let visibleMessages = []; + for (let attempt = 0; attempt < 40; attempt += 1) { + await wait(5000); + visibleMessages = await page.locator(".hall-message .hall-message-body").evaluateAll((nodes) => + nodes.map((node) => ({ + text: node.textContent || "", + clamp: getComputedStyle(node).webkitLineClamp, + overflow: getComputedStyle(node).overflow, + whiteSpace: getComputedStyle(node).whiteSpace, + })), + ); + const texts = visibleMessages.map((message) => message.text); + foundPaths = texts.some((text) => + /src\/ui\/collaboration-hall\.ts|src\/runtime\/collaboration-hall-orchestrator\.ts|src\/runtime\/hall-runtime-dispatch\.ts/.test(text), + ); + wrongHandoffWarning = texts.some((text) => text.includes("Handoff moved to")); + const consoleText = await page + .locator("[data-hall-decision-panel]") + .allTextContents() + .then((items) => items.join(" ")) + .catch(() => ""); + executionSettled = foundPaths && !/阶段:\s*(执行中|卡住)|\bstage:\s*(execution|blocked)\b/i.test(consoleText); + if (executionSettled) break; + } + + if (!foundPaths) throw new Error("repo scan deliverable never became visible"); + if (wrongHandoffWarning) throw new Error("wrong handoff warning became visible"); + if (!executionSettled) { + const debug = await page.evaluate(() => ({ + panelText: document.querySelector("[data-hall-decision-panel]")?.textContent || "", + bodyText: document.body.innerText.slice(0, 3000), + })); + console.error(JSON.stringify({ executionNeverSettled: debug }, null, 2)); + throw new Error("execution chain never settled before the follow-up"); + } + + const unclampedMessages = visibleMessages.every((message) => { + const clamp = String(message.clamp || "").trim(); + return clamp === "" || clamp === "none"; + }); + if (!unclampedMessages) throw new Error("visible execution messages are still clamped"); + const textDump = visibleMessages.map((message) => message.text).join("\n"); + for (const forbidden of ["[tool]", "thinking", "hall-structured", "LOCAL_API_TOKEN", "language is not defined"]) { + if (textDump.includes(forbidden)) throw new Error(`forbidden leak visible: ${forbidden}`); + } + + // 5) Follow-up after execution should reopen discussion and get another reply. + await page.locator("[data-hall-composer-textarea]").fill("继续讨论吧,下一版怎么展开得更清楚?"); + await page.locator("[data-hall-send-reply]").click(); + + let repliedAfterExecution = false; + for (let attempt = 0; attempt < 12; attempt += 1) { + await wait(3000); + const authors = await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []); + const nonOperatorCount = authors.filter((name) => name !== "Operator").length; + if (nonOperatorCount >= 4) { + repliedAfterExecution = true; + break; + } + } + if (!repliedAfterExecution) throw new Error("follow-up after execution got no reply"); + + // 6) A new round can be planned and started again after the follow-up. + await page.locator("[data-hall-plan-order]").first().click(); + await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 }); + await page.locator("[data-hall-order-cancel]").click().catch(() => {}); + await wait(400); + await page.locator("[data-hall-plan-order]").first().click(); + await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 }); + for (const participantId of ["main", "otter"]) { + const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first(); + if (await chip.count()) await chip.click(); + } + await page.locator('[data-hall-item-task="main"]').fill("把这一版继续讨论收成第二轮可执行开头。"); + await page.locator('[data-hall-item-handoff-to="main"]').selectOption("otter"); + await page.locator('[data-hall-item-handoff="main"]').fill("收住后交给 @otter。"); + await page.locator('[data-hall-item-task="otter"]').fill("只挑 must-fix。"); + await page.locator('[data-hall-item-handoff-to="otter"]').selectOption(""); + await page.locator('[data-hall-item-handoff="otter"]').fill("没有 must-fix 就请老板评审。"); + await page.locator("[data-hall-order-save]").click(); + await wait(2000); + const secondStartExists = await page.locator("[data-hall-start-execution]").count().catch(() => 0); + if (!secondStartExists) { + const debug = await page.evaluate(() => { + const panel = document.querySelector("[data-hall-decision-panel]"); + const consoleNode = panel?.querySelector("[data-hall-current-console]"); + const selectedCard = document.querySelector('[data-task-card-id][aria-current="page"], [data-task-card-id][aria-current="true"]'); + return { + panelText: panel?.textContent || "", + consoleText: consoleNode?.textContent || "", + selectedCardText: selectedCard?.textContent || "", + bodyText: document.body.innerText.slice(0, 6000), + }; + }); + console.error(JSON.stringify({ secondRoundDebug: debug }, null, 2)); + throw new Error("second-round start execution button missing after replanning"); + } + + console.log( + JSON.stringify( + { + errors, + typingSeen, + planButtonSeen, + plannerState, + plannerScrolled, + foundPaths, + wrongHandoffWarning, + secondStartExists, + lastMessages: visibleMessages.slice(-4), + finalAuthors: await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []), + }, + null, + 2, + ), + ); + + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/live-hall-send-check.js b/scripts/live-hall-send-check.js new file mode 100644 index 0000000..31d5470 --- /dev/null +++ b/scripts/live-hall-send-check.js @@ -0,0 +1,104 @@ +const { chromium } = require("playwright"); + +async function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } }); + const errors = []; + + page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(`console:${message.text()}`); + } + }); + + await page.goto("http://127.0.0.1:4310/?section=hall-chat", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 }); + + await page.locator("[data-hall-compose-task]").click({ timeout: 10000 }); + await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能"); + await page.locator("[data-hall-composer-textarea]").press("Enter", { timeout: 10000 }); + await wait(1000); + + const selectedTaskCardId = await page + .locator("[data-task-card-id][aria-current='page']") + .first() + .getAttribute("data-task-card-id") + .catch(() => null); + + await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能"); + await page.locator("[data-hall-composer-textarea]").press("Enter", { timeout: 10000 }); + + let typingSeen = false; + let typingSnapshots = []; + for (let attempt = 0; attempt < 8; attempt += 1) { + typingSnapshots = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []); + if (typingSnapshots.some((text) => String(text || "").trim().length > 0)) { + typingSeen = true; + break; + } + await wait(1000); + } + + let replySeen = false; + let authors = []; + for (let attempt = 0; attempt < 35; attempt += 1) { + authors = await page + .locator(".hall-message .hall-message-author strong") + .allTextContents() + .catch(() => []); + if (authors.some((label) => !String(label || "").includes("Operator"))) { + replySeen = true; + break; + } + await wait(1000); + } + + const messageCount = await page.locator(".hall-message").count(); + const bodies = await page + .locator(".hall-message .hall-message-body") + .evaluateAll((nodes) => nodes.map((node) => node.textContent || "")) + .catch(() => []); + const pendingTyping = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []); + const headerStage = await page.locator("[data-hall-thread-meta]").textContent().catch(() => null); + const title = await page.locator("[data-hall-thread-title]").textContent().catch(() => null); + + await page.screenshot({ + path: "/tmp/live-hall-send-check.png", + fullPage: true, + }); + + console.log( + JSON.stringify( + { + title, + headerStage, + selectedTaskCardId, + typingSeen, + typingSnapshots, + replySeen, + messageCount, + authors, + pendingTyping, + bodies: bodies.slice(0, 10), + errors, + }, + null, + 2, + ), + ); + + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/live-hall-timeline-check.js b/scripts/live-hall-timeline-check.js new file mode 100644 index 0000000..2bc8737 --- /dev/null +++ b/scripts/live-hall-timeline-check.js @@ -0,0 +1,78 @@ +const { chromium } = require("playwright"); + +async function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } }); + const errors = []; + + page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(`console:${message.text()}`); + }); + + await page.goto("http://127.0.0.1:4310/?section=hall-chat", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 }); + + await page.locator("[data-hall-compose-task]").click({ timeout: 10000 }); + await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能"); + await page.locator("[data-hall-send-reply]").click({ timeout: 10000 }); + await wait(1000); + + await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能"); + await page.locator("[data-hall-send-reply]").click({ timeout: 10000 }); + + const timeline = []; + for (let attempt = 1; attempt <= 12; attempt += 1) { + await wait(5000); + const authors = await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []); + const bodies = await page + .locator(".hall-message .hall-message-body") + .evaluateAll((nodes) => nodes.map((node) => node.textContent || "")) + .catch(() => []); + const typing = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []); + timeline.push({ + t: attempt * 5, + authors, + typing, + count: bodies.length, + lastBodies: bodies.slice(-4), + }); + } + + const selectedTaskCardId = await page + .locator("[data-task-card-id][aria-current='page']") + .first() + .getAttribute("data-task-card-id") + .catch(() => null); + + await page.screenshot({ + path: "/tmp/live-hall-timeline-check.png", + fullPage: true, + }); + + console.log( + JSON.stringify( + { + selectedTaskCardId, + timeline, + errors, + }, + null, + 2, + ), + ); + + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/run-tests-isolated.ts b/scripts/run-tests-isolated.ts new file mode 100644 index 0000000..0f0deed --- /dev/null +++ b/scripts/run-tests-isolated.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { spawn } from "node:child_process"; + +async function main(): Promise { + const repoRoot = process.cwd(); + const testArgs = process.argv.slice(2); + const targets = testArgs.length > 0 ? testArgs : await collectTestFiles(resolve(repoRoot, "test")); + const runtimeDir = await mkdtemp(join(tmpdir(), "openclaw-control-center-test-")); + + try { + const exitCode = await runNodeTests(targets, runtimeDir); + process.exitCode = exitCode; + } finally { + await rm(runtimeDir, { recursive: true, force: true }); + } +} + +async function collectTestFiles(rootDir: string): Promise { + const output: string[] = []; + await walk(rootDir, output); + return output.sort(); +} + +async function walk(dir: string, output: string[]): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath, output); + continue; + } + if (entry.isFile() && entry.name.endsWith(".test.ts")) { + output.push(fullPath); + } + } +} + +function runNodeTests(targets: string[], runtimeDir: string): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx", "--test", "--test-concurrency=1", ...targets], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_RUNTIME_DIR: runtimeDir, + }, + stdio: "inherit", + }, + ); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (signal) { + resolvePromise(1); + return; + } + resolvePromise(code ?? 0); + }); + }); +} + +void main(); diff --git a/scripts/ui-smoke.js b/scripts/ui-smoke.js index 4a2f2f1..50cb3fa 100644 --- a/scripts/ui-smoke.js +++ b/scripts/ui-smoke.js @@ -82,10 +82,28 @@ async function checkPage(urlPath, keywords, label) { cleanup(1); } +async function checkPageExcludes(urlPath, forbidden, label) { + const body = await fetch(urlPath); + const visible = body + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, ""); + const hit = forbidden.find((kw) => visible.includes(kw)); + if (hit) { + console.error(`FAIL: ${label} — found forbidden marker "${hit}".`); + cleanup(1); + } +} + async function main() { await waitForUI(); - await checkPage("/", ["OpenClaw", "Control Center", "usage", "lang="], "GET /"); + await checkPage("/?section=overview&lang=en", ["OpenClaw", "Control Center", "usage", "lang="], "GET /?section=overview&lang=en"); await checkPage("/docs?lang=en", ["Open document workbench", "Control Center", "Docs"], "GET /docs?lang=en"); + await checkPage("/?section=hall-chat&lang=zh", ["协作大厅", "线程", "新任务"], "GET /?section=hall-chat&lang=zh"); + await checkPageExcludes( + "/?section=hall-chat&lang=zh", + ["LOCAL_API_TOKEN", "language is not defined", "Manager handed the room to Reviewer", "[tool]", "thinking"], + "GET /?section=hall-chat&lang=zh excludes legacy leaks", + ); console.log(`UI smoke passed on http://127.0.0.1:${PORT}`); cleanup(0); } diff --git a/src/clients/openclaw-live-client.ts b/src/clients/openclaw-live-client.ts index 68d6f53..da8b1d5 100644 --- a/src/clients/openclaw-live-client.ts +++ b/src/clients/openclaw-live-client.ts @@ -1,8 +1,13 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { open, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import type { + AgentRunRequest, + AgentRunResponse, + AgentRunStreamHandlers, + AgentRunTransportContext, ApprovalsActionResponse, ApprovalsApproveRequest, ApprovalsGetResponse, @@ -240,6 +245,145 @@ export class OpenClawLiveClient implements ToolClient { }; } + async agentRun(request: AgentRunRequest): Promise { + const message = request.message.trim(); + if (!message) { + throw new Error("agentRun requires a non-empty message."); + } + + const args = ["agent"]; + const sessionId = request.sessionId?.trim() + || (request.sessionKey?.trim() ? await this.resolveSessionIdByKey(request.sessionKey.trim()) : undefined); + if (sessionId) { + args.push("--session-id", sessionId); + } else { + const agentId = request.agentId?.trim(); + if (!agentId) { + throw new Error("agentRun requires either agentId, sessionKey, or sessionId."); + } + args.push("--agent", agentId); + } + + args.push("--message", message); + args.push("--thinking", normalizeThinkingLevel(request.thinking)); + if (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0) { + args.push("--timeout", String(Math.trunc(request.timeoutSeconds))); + } + if (request.deliver) args.push("--deliver"); + args.push("--json"); + + const transportOptions = buildAgentRunProcessOptions(request.context); + const rawJson = await runJson>(args, { + timeoutMs: (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0) + ? Math.max(5_000, Math.trunc(request.timeoutSeconds * 1_000)) + : 20 * 60 * 1_000, + maxBuffer: 8 * 1024 * 1024, + cwd: transportOptions.cwd, + env: transportOptions.env, + }); + + const result = asObject(rawJson.result); + const meta = asObject(result?.meta); + const agentMeta = asObject(meta?.agentMeta); + const systemPromptReport = asObject(meta?.systemPromptReport); + const payloads = Array.isArray(result?.payloads) ? result?.payloads : []; + const text = payloads + .map((item) => { + const payload = asObject(item); + return asString(payload?.text)?.trim(); + }) + .filter((item): item is string => Boolean(item)) + .join("\n\n") + .trim(); + const sessionKey = asString(systemPromptReport?.sessionKey) ?? request.sessionKey?.trim(); + const response: AgentRunResponse = { + ok: asString(rawJson.status) === "ok", + runId: asString(rawJson.runId), + status: asString(rawJson.status), + summary: asString(rawJson.summary), + text, + rawText: JSON.stringify(rawJson), + sessionId: asString(agentMeta?.sessionId) ?? sessionId, + sessionKey, + provider: asString(agentMeta?.provider) ?? asString(systemPromptReport?.provider), + model: asString(agentMeta?.model) ?? asString(systemPromptReport?.model), + rawJson, + }; + + if (sessionKey && response.sessionId) { + const cached = this.sessionCache.get(sessionKey) ?? {}; + this.sessionCache.set(sessionKey, { + ...cached, + model: response.model ?? cached.model, + }); + } + + return response; + } + + async agentRunStream( + request: AgentRunRequest, + handlers: AgentRunStreamHandlers = {}, + ): Promise { + const message = request.message.trim(); + if (!message) { + throw new Error("agentRunStream requires a non-empty message."); + } + + const args = ["agent"]; + const latestBeforeRun = await this.readLatestAgentSessionMarker(request.agentId?.trim()); + const sessionId = request.sessionId?.trim() + || (request.sessionKey?.trim() ? await this.resolveSessionIdByKey(request.sessionKey.trim()) : undefined); + const agentId = request.agentId?.trim(); + if (sessionId) { + args.push("--session-id", sessionId); + } else if (agentId) { + args.push("--agent", agentId); + } else { + throw new Error("agentRunStream requires either agentId, sessionKey, or sessionId."); + } + + args.push("--message", message); + args.push("--thinking", normalizeThinkingLevel(request.thinking)); + if (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0) { + args.push("--timeout", String(Math.trunc(request.timeoutSeconds))); + } + if (request.deliver) args.push("--deliver"); + + const timeoutMs = (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0) + ? Math.max(5_000, Math.trunc(request.timeoutSeconds * 1_000)) + : 20 * 60 * 1_000; + const transportOptions = buildAgentRunProcessOptions(request.context); + const { stdout, stderr, code } = await runStreamingText(args, handlers, { + timeoutMs, + cwd: transportOptions.cwd, + env: transportOptions.env, + }); + if (code !== 0) { + throw new Error(stderr.trim() || stdout.trim() || `openclaw agent exited with code ${code}`); + } + + const text = sanitizeAgentCliOutput(stdout); + const sessionKey = request.sessionKey?.trim() + || await this.resolveSessionKeyAfterRun({ + agentId, + beforeUpdatedAtMs: latestBeforeRun?.updatedAtMs, + beforeSessionKey: latestBeforeRun?.sessionKey, + }); + const resolvedSessionId = sessionId + || (sessionKey ? await this.resolveSessionIdByKey(sessionKey) : undefined) + || latestBeforeRun?.sessionId; + + return { + ok: true, + status: "ok", + text, + rawText: stdout, + sessionId: resolvedSessionId, + sessionKey, + }; + } + private async loadSessionsFromStores(): Promise { const openclawHome = resolveOpenClawHomePath(); const agentsPath = join(openclawHome, "agents"); @@ -352,25 +496,113 @@ export class OpenClawLiveClient implements ToolClient { const catalog = await loadCurrentAgentCatalog(); return new Set(catalog.entries.map((entry) => normalizeAgentKey(entry.agentId))); } + + private async resolveSessionIdByKey(sessionKey: string): Promise { + const sessions = (await this.sessionsList()).sessions ?? []; + return sessions.find((item) => item.sessionKey === sessionKey || item.key === sessionKey)?.sessionId; + } + + private async readLatestAgentSessionMarker( + agentId: string | undefined, + ): Promise<{ sessionKey?: string; sessionId?: string; updatedAtMs?: number } | undefined> { + const normalizedAgentId = agentId?.trim(); + if (!normalizedAgentId) return undefined; + const sessions = (await this.sessionsList()).sessions ?? []; + const match = sessions + .filter((item) => (item.agentId ?? extractAgentIdFromSessionKey(item.sessionKey ?? item.key)) === normalizedAgentId) + .sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0))[0]; + if (!match) return undefined; + return { + sessionKey: match.sessionKey ?? match.key, + sessionId: match.sessionId, + updatedAtMs: match.updatedAtMs, + }; + } + + private async resolveSessionKeyAfterRun(input: { + agentId?: string; + beforeUpdatedAtMs?: number; + beforeSessionKey?: string; + }): Promise { + const normalizedAgentId = input.agentId?.trim(); + if (!normalizedAgentId) return undefined; + const sessions = (await this.sessionsList()).sessions ?? []; + const candidates = sessions + .filter((item) => (item.agentId ?? extractAgentIdFromSessionKey(item.sessionKey ?? item.key)) === normalizedAgentId) + .sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0)); + const preferred = candidates.find((item) => (item.updatedAtMs ?? 0) > (input.beforeUpdatedAtMs ?? 0)) + ?? candidates[0]; + return preferred?.sessionKey ?? preferred?.key ?? input.beforeSessionKey ?? `agent:${normalizedAgentId}:main`; + } } -async function runJson(args: string[], options?: { timeoutMs?: number; maxBuffer?: number }): Promise { +async function runJson(args: string[], options?: { timeoutMs?: number; maxBuffer?: number; cwd?: string; env?: NodeJS.ProcessEnv }): Promise { const stdout = await runText(args, options); return JSON.parse(stdout) as T; } async function runText( args: string[], - options?: { timeoutMs?: number; maxBuffer?: number }, + options?: { timeoutMs?: number; maxBuffer?: number; cwd?: string; env?: NodeJS.ProcessEnv }, ): Promise { const { stdout } = await execFileAsync("openclaw", args, { timeout: options?.timeoutMs ?? 20_000, maxBuffer: options?.maxBuffer ?? 2 * 1024 * 1024, shell: process.platform === "win32", + cwd: options?.cwd, + env: options?.env, }); return stdout; } +async function runStreamingText( + args: string[], + handlers: AgentRunStreamHandlers, + options?: { timeoutMs?: number; cwd?: string; env?: NodeJS.ProcessEnv }, +): Promise<{ stdout: string; stderr: string; code: number }> { + return await new Promise((resolve, reject) => { + const child = spawn("openclaw", args, { + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + cwd: options?.cwd, + env: options?.env, + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, options?.timeoutMs ?? 20_000); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + handlers.onStdoutChunk?.(chunk); + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + handlers.onStderrChunk?.(chunk); + }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + stdout, + stderr, + code: typeof code === "number" ? code : 0, + }); + }); + }); +} + async function readSessionHistoryFromCli( sessionKey: string, limit: number, @@ -463,6 +695,85 @@ function asBoolean(v: unknown): boolean | undefined { return typeof v === "boolean" ? v : undefined; } +function normalizeThinkingLevel(input: AgentRunRequest["thinking"]): NonNullable { + switch (input) { + case "off": + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + return input; + default: + return "minimal"; + } +} + +function buildAgentRunProcessOptions( + context: AgentRunTransportContext | undefined, +): { cwd?: string; env?: NodeJS.ProcessEnv } { + if (!context) return {}; + const cwd = pickAgentRunWorkingDirectory(context); + const envEntries: Record = {}; + const surface = context.surface?.trim(); + if (surface) envEntries.OPENCLAW_AGENT_SURFACE = surface; + const workspaceRoot = context.workspaceRoot?.trim(); + if (workspaceRoot) envEntries.OPENCLAW_AGENT_WORKSPACE_ROOT = workspaceRoot; + const workdir = context.workdir?.trim(); + if (workdir) envEntries.OPENCLAW_AGENT_WORKDIR = workdir; + if ((context.entryFiles?.length ?? 0) > 0) { + envEntries.OPENCLAW_AGENT_ENTRY_FILES_JSON = JSON.stringify(context.entryFiles); + } + if ((context.artifactRefs?.length ?? 0) > 0) { + envEntries.OPENCLAW_AGENT_ARTIFACT_REFS_JSON = JSON.stringify(context.artifactRefs); + } + if ((context.attachmentRefs?.length ?? 0) > 0) { + envEntries.OPENCLAW_AGENT_ATTACHMENT_REFS_JSON = JSON.stringify(context.attachmentRefs); + } + const env = Object.keys(envEntries).length > 0 + ? { + ...process.env, + ...envEntries, + } + : undefined; + return { cwd, env }; +} + +function pickAgentRunWorkingDirectory(context: AgentRunTransportContext): string | undefined { + const candidates = [context.workdir, context.workspaceRoot] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +function sanitizeAgentCliOutput(raw: string): string { + const withoutAnsi = raw + .replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, "") + .replace(/\r/g, "\n"); + const lines = withoutAnsi + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !line.startsWith("🦞 OpenClaw")) + .filter((line) => !line.startsWith("Registered plugin command:")) + .filter((line) => !/^Waiting for agent reply/i.test(line)) + .filter((line) => !/^[◒◐◓◑◇│]+$/.test(line)) + .filter((line) => !/^流式中$/i.test(line)) + .filter((line) => !/^\[tool(?:[^\]]*)?\]/i.test(line)) + .filter((line) => !/^thinking\b/i.test(line)) + .filter((line) => !/^Inspecting\b/i.test(line)) + .filter((line) => !/^Checking\b/i.test(line)) + .filter((line) => !/^It seems\b/i.test(line)) + .filter((line) => !/^I should\b/i.test(line)) + .filter((line) => !/^I think\b/i.test(line)) + .filter((line) => !/^Let's\b/i.test(line)) + .filter((line) => !/^(import|export)\s+/i.test(line)); + return lines.join("\n").trim(); +} + function asObject(v: unknown): Record | undefined { return v !== null && typeof v === "object" ? (v as Record) : undefined; } diff --git a/src/clients/tool-client.ts b/src/clients/tool-client.ts index 80d9ebf..7ce07f9 100644 --- a/src/clients/tool-client.ts +++ b/src/clients/tool-client.ts @@ -1,4 +1,7 @@ import type { + AgentRunRequest, + AgentRunResponse, + AgentRunStreamHandlers, ApprovalsActionResponse, ApprovalsApproveRequest, ApprovalsGetResponse, @@ -19,6 +22,8 @@ export interface ToolClient { approvalsGet(): Promise; approvalsApprove(request: ApprovalsApproveRequest): Promise; approvalsReject(request: ApprovalsRejectRequest): Promise; + agentRun?(request: AgentRunRequest): Promise; + agentRunStream?(request: AgentRunRequest, handlers?: AgentRunStreamHandlers): Promise; } export class ReadonlyToolClient implements ToolClient { diff --git a/src/config.ts b/src/config.ts index 036b4e1..4e61db3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,17 @@ if (existsSync(DOTENV_PATH)) { } export const GATEWAY_URL = readStringEnv(process.env.GATEWAY_URL, "ws://127.0.0.1:18789"); +export const OPENCLAW_CONTROL_UI_URL = readOptionalStringEnv(process.env.OPENCLAW_CONTROL_UI_URL); +export const TASK_ROOM_BRIDGE_ENABLED = process.env.TASK_ROOM_BRIDGE_ENABLED === "true"; +export const TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL = readOptionalStringEnv( + process.env.TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL, +); +export const TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN = readOptionalStringEnv( + process.env.TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN, +); +export const TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID = readOptionalStringEnv( + process.env.TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID, +); export const READONLY_MODE = process.env.READONLY_MODE !== "false"; export const APPROVAL_ACTIONS_ENABLED = process.env.APPROVAL_ACTIONS_ENABLED === "true"; @@ -17,6 +28,26 @@ export const IMPORT_MUTATION_DRY_RUN = process.env.IMPORT_MUTATION_DRY_RUN === " export const LOCAL_TOKEN_AUTH_REQUIRED = process.env.LOCAL_TOKEN_AUTH_REQUIRED !== "false"; export const LOCAL_API_TOKEN = (process.env.LOCAL_API_TOKEN ?? "").trim(); export const LOCAL_TOKEN_HEADER = "x-local-token" as const; +export const HALL_RUNTIME_DISPATCH_ENABLED = process.env.HALL_RUNTIME_DISPATCH_ENABLED !== "false"; +export const HALL_RUNTIME_DIRECT_STREAM_ENABLED = process.env.HALL_RUNTIME_DIRECT_STREAM_ENABLED !== "false"; +export const HALL_RUNTIME_THINKING_LEVEL = readThinkingLevelEnv(process.env.HALL_RUNTIME_THINKING_LEVEL, "minimal"); +export const HALL_RUNTIME_TIMEOUT_SECONDS = parsePositiveInt( + process.env.HALL_RUNTIME_TIMEOUT_SECONDS, + 600, +); +export const HALL_RUNTIME_POLL_INTERVAL_MS = parsePositiveInt( + process.env.HALL_RUNTIME_POLL_INTERVAL_MS, + 350, +); +export const HALL_RUNTIME_HISTORY_LIMIT = parsePositiveInt( + process.env.HALL_RUNTIME_HISTORY_LIMIT, + 120, +); +export const HALL_RUNTIME_EXECUTION_CHAIN_ENABLED = process.env.HALL_RUNTIME_EXECUTION_CHAIN_ENABLED !== "false"; +export const HALL_RUNTIME_EXECUTION_MAX_TURNS = parsePositiveInt( + process.env.HALL_RUNTIME_EXECUTION_MAX_TURNS, + 3, +); export const TASK_HEARTBEAT_ENABLED = process.env.TASK_HEARTBEAT_ENABLED !== "false"; export const TASK_HEARTBEAT_DRY_RUN = process.env.TASK_HEARTBEAT_DRY_RUN !== "false"; export const TASK_HEARTBEAT_MAX_TASKS_PER_RUN = parsePositiveInt( @@ -44,3 +75,25 @@ function readStringEnv(input: string | undefined, fallback: string): string { const value = (input ?? "").trim(); return value === "" ? fallback : value; } + +function readOptionalStringEnv(input: string | undefined): string | undefined { + const value = (input ?? "").trim(); + return value === "" ? undefined : value; +} + +function readThinkingLevelEnv( + input: string | undefined, + fallback: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", +): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" { + switch ((input ?? "").trim()) { + case "off": + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + return (input ?? "").trim() as "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + default: + return fallback; + } +} diff --git a/src/contracts/openclaw-tools.ts b/src/contracts/openclaw-tools.ts index eaf1f02..5d14cbd 100644 --- a/src/contracts/openclaw-tools.ts +++ b/src/contracts/openclaw-tools.ts @@ -2,6 +2,60 @@ export interface SessionsListRequest { limit?: number; } +export type AgentRunThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +export interface AgentRunArtifactRef { + artifactId?: string; + type?: string; + label: string; + location: string; +} + +export interface AgentRunAttachmentRef { + label: string; + url: string; + mimeType?: string; +} + +export interface AgentRunTransportContext { + surface?: string; + workspaceRoot?: string; + workdir?: string; + entryFiles?: string[]; + artifactRefs?: AgentRunArtifactRef[]; + attachmentRefs?: AgentRunAttachmentRef[]; +} + +export interface AgentRunRequest { + agentId?: string; + sessionKey?: string; + sessionId?: string; + message: string; + thinking?: AgentRunThinkingLevel; + timeoutSeconds?: number; + deliver?: boolean; + context?: AgentRunTransportContext; +} + +export interface AgentRunResponse { + ok: boolean; + runId?: string; + status?: string; + summary?: string; + text: string; + rawText: string; + sessionId?: string; + sessionKey?: string; + provider?: string; + model?: string; + rawJson?: Record; +} + +export interface AgentRunStreamHandlers { + onStdoutChunk?: (chunk: string) => void; + onStderrChunk?: (chunk: string) => void; +} + export interface SessionsListItem { key?: string; sessionKey?: string; diff --git a/src/runtime/agent-dispatch.ts b/src/runtime/agent-dispatch.ts new file mode 100644 index 0000000..2045760 --- /dev/null +++ b/src/runtime/agent-dispatch.ts @@ -0,0 +1,171 @@ +import type { ChatMessage, RoomParticipantRole } from "../types"; +import type { CreateChatMessageInput } from "./chat-store"; +import type { ProjectTask } from "../types"; + +interface AgentDispatchContext { + roomId: string; + task: ProjectTask; + recentMessages: ChatMessage[]; +} + +export function buildDiscussionDispatchMessage( + role: RoomParticipantRole, + input: AgentDispatchContext, +): CreateChatMessageInput { + if (role === "planner") return buildPlannerMessage(input); + if (role === "coder") return buildCoderMessage(input); + if (role === "reviewer") return buildReviewerMessage(input); + return buildManagerMessage(input); +} + +export function buildExecutionStartedMessage( + roomId: string, + executor: RoomParticipantRole, + task: ProjectTask, +): CreateChatMessageInput { + return { + roomId, + kind: "status", + authorRole: executor, + authorLabel: titleCaseRole(executor), + content: `${titleCaseRole(executor)} accepted "${task.title}" and started execution.`, + payload: { + status: "execution_started", + executor, + taskStatus: "in_progress", + }, + }; +} + +export function buildReviewOutcomeMessage(input: { + roomId: string; + outcome: "approved" | "rejected"; + note?: string; + taskStatus: "done" | "blocked" | "in_progress"; +}): CreateChatMessageInput { + const base = + input.outcome === "approved" + ? "Reviewer approved the execution result." + : "Reviewer rejected the execution result and requested another pass."; + return { + roomId: input.roomId, + kind: "result", + authorRole: "reviewer", + authorLabel: "Reviewer", + content: input.note ? `${base} ${input.note}` : base, + payload: { + reviewOutcome: input.outcome, + taskStatus: input.taskStatus, + status: input.outcome === "approved" ? "review_passed" : "review_rejected", + }, + }; +} + +function buildPlannerMessage(input: AgentDispatchContext): CreateChatMessageInput { + const latestHumanPrompt = latestHumanRequest(input.recentMessages); + const proposal = [ + `Scope the request for "${input.task.title}".`, + latestHumanPrompt ? `Anchor the work on: ${latestHumanPrompt}.` : "Use the latest operator request as the main requirement.", + "Implement the smallest safe slice first, then verify with concrete evidence.", + ].join(" "); + + return { + roomId: input.roomId, + kind: "proposal", + authorRole: "planner", + authorLabel: "Planner", + content: proposal, + payload: { + proposal, + }, + }; +} + +function buildCoderMessage(input: AgentDispatchContext): CreateChatMessageInput { + const plan = [ + `Implementation path for "${input.task.title}":`, + "wire the room/task state first,", + "keep mutations traceable,", + "and finish with tests that prove the happy path and review flow.", + ].join(" "); + + return { + roomId: input.roomId, + kind: "proposal", + authorRole: "coder", + authorLabel: "Coder", + content: plan, + payload: { + proposal: plan, + }, + }; +} + +function buildReviewerMessage(input: AgentDispatchContext): CreateChatMessageInput { + const checklist = [ + `Review focus for "${input.task.title}":`, + "one room per task,", + "ordered discussion turns,", + "task-state sync,", + "summary persistence,", + "and regression coverage for the main API flow.", + ].join(" "); + + return { + roomId: input.roomId, + kind: "proposal", + authorRole: "reviewer", + authorLabel: "Reviewer", + content: checklist, + payload: { + proposal: checklist, + }, + }; +} + +function buildManagerMessage(input: AgentDispatchContext): CreateChatMessageInput { + const executor: RoomParticipantRole = "coder"; + const doneWhen = resolveDoneWhen(input.task); + const decision = `Use the room-first implementation plan for "${input.task.title}" and move execution to ${titleCaseRole(executor)}.`; + const proposal = `Planner, coder, and reviewer aligned on a safe incremental build for "${input.task.title}".`; + + return { + roomId: input.roomId, + kind: "decision", + authorRole: "manager", + authorLabel: "Manager", + content: `${decision} Done when: ${doneWhen}.`, + payload: { + proposal, + decision, + executor, + doneWhen, + }, + }; +} + +function latestHumanRequest(messages: ChatMessage[]): string | undefined { + const human = [...messages].reverse().find((message) => message.authorRole === "human"); + if (!human) return undefined; + const trimmed = human.content.trim().replace(/\s+/g, " "); + if (trimmed.length <= 160) return trimmed; + return `${trimmed.slice(0, 157)}...`; +} + +function resolveDoneWhen(task: ProjectTask): string { + if (task.definitionOfDone.length > 0) { + return task.definitionOfDone.join("; "); + } + if (task.dueAt) { + return `the requested changes are implemented and reviewed before ${task.dueAt}`; + } + return "the main flow works, the result is reviewed, and the task state is updated"; +} + +function titleCaseRole(role: RoomParticipantRole): string { + if (role === "human") return "Operator"; + if (role === "planner") return "Planner"; + if (role === "coder") return "Coder"; + if (role === "reviewer") return "Reviewer"; + return "Manager"; +} diff --git a/src/runtime/api-docs.ts b/src/runtime/api-docs.ts index ab3c764..ecdeaf7 100644 --- a/src/runtime/api-docs.ts +++ b/src/runtime/api-docs.ts @@ -17,7 +17,7 @@ export interface ApiDocsPayload { export function buildApiDocs(): ApiDocsPayload { return { generatedAt: new Date().toISOString(), - version: "phase-23", + version: "phase-25", safetyDefaults: { READONLY_MODE: true, APPROVAL_ACTIONS_ENABLED: false, @@ -25,6 +25,11 @@ export function buildApiDocs(): ApiDocsPayload { IMPORT_MUTATION_ENABLED: false, IMPORT_MUTATION_DRY_RUN: false, LOCAL_TOKEN_AUTH_REQUIRED: true, + HALL_RUNTIME_DISPATCH_ENABLED: true, + HALL_RUNTIME_DIRECT_STREAM_ENABLED: true, + HALL_RUNTIME_THINKING_LEVEL: "minimal", + HALL_RUNTIME_EXECUTION_CHAIN_ENABLED: true, + HALL_RUNTIME_EXECUTION_MAX_TURNS: 3, TASK_HEARTBEAT_ENABLED: true, TASK_HEARTBEAT_DRY_RUN: true, TASK_HEARTBEAT_MAX_TASKS_PER_RUN: 3, @@ -36,6 +41,8 @@ export function buildApiDocs(): ApiDocsPayload { "Live import apply requires LOCAL_API_TOKEN auth + IMPORT_MUTATION_ENABLED=true + READONLY_MODE=false; optional per-request dryRun=true keeps it non-mutating", taskHeartbeatExecutionGuard: "Live task heartbeat execution requires LOCAL_API_TOKEN when LOCAL_TOKEN_AUTH_REQUIRED=true; default mode is dry-run", + hallRuntimeDispatchNotes: + "Hall discussion / assign / handoff use the real openclaw agent runtime when HALL_RUNTIME_DISPATCH_ENABLED=true and a live ToolClient is available; hall prefers direct stdout streaming when available, falls back to session deltas when needed, and can auto-chain bounded execution turns after assign", }, routes: [ { @@ -195,6 +202,401 @@ export function buildApiDocs(): ApiDocsPayload { items: "ExceptionFeedItem[]", }, }, + { + method: "GET", + path: "/api/hall", + summary: "Read the public collaboration hall with participants, task cards, summary, and recent messages", + response: { + ok: "boolean", + hall: "CollaborationHall", + summary: "CollaborationHallSummary", + participants: "HallParticipant[]", + count: "number", + taskCards: "HallTaskCard[] with summary", + messages: "HallMessage[]", + }, + }, + { + method: "GET", + path: "/api/hall/events", + summary: "Open an SSE stream for hall invalidations and streamed agent reply drafts, including runtime-backed hall dispatch when enabled", + query: { + hallId: "optional hall id (default main)", + }, + response: { + ok: "SSE stream", + events: "connected | invalidate | draft_start | draft_delta | draft_complete | draft_abort", + }, + }, + { + method: "GET", + path: "/api/hall/messages", + summary: "Read hall messages with optional task filters", + query: { + taskCardId: "optional task card id", + taskId: "optional task id", + projectId: "optional project id", + limit: "optional 1..500 (default 120)", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + count: "number", + messages: "HallMessage[]", + }, + }, + { + method: "POST", + path: "/api/hall/messages", + summary: "Post a reply into the collaboration hall (requires local token gate)", + body: { + hallId: "optional hall id", + taskCardId: "optional task card id", + projectId: "optional project id", + taskId: "optional task id", + content: "required message text", + authorParticipantId: "optional participant id (defaults to operator)", + authorLabel: "optional author label", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + hallSummary: "CollaborationHallSummary", + taskCard: "HallTaskCard | undefined", + taskSummary: "HallTaskSummary | undefined", + message: "HallMessage", + generatedMessages: "HallMessage[]", + }, + }, + { + method: "GET", + path: "/api/hall/tasks", + summary: "List collaboration hall task cards", + query: { + stage: "optional: discussion|execution|review|blocked|completed", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + count: "number", + taskCards: "HallTaskCard[] with summary", + }, + }, + { + method: "POST", + path: "/api/hall/tasks", + summary: "Create a new hall task card from an operator request (requires local token gate)", + body: { + hallId: "optional hall id", + projectId: "optional project id", + taskId: "optional task id", + title: "optional task title", + content: "required task request", + authorParticipantId: "optional participant id", + authorLabel: "optional author label", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + hallSummary: "CollaborationHallSummary", + taskCard: "HallTaskCard", + taskSummary: "HallTaskSummary", + task: "ProjectTask", + roomId: "string | undefined", + generatedMessages: "HallMessage[]", + }, + }, + { + method: "GET", + path: "/api/hall/tasks/:taskId", + summary: "Read one hall task card and its scoped timeline", + query: { + projectId: "required project id", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + hallSummary: "CollaborationHallSummary", + taskCard: "HallTaskCard", + taskSummary: "HallTaskSummary", + task: "ProjectTask | undefined", + messages: "HallMessage[]", + }, + }, + { + method: "POST", + path: "/api/hall/tasks/:taskId/assign", + summary: "Assign one execution owner to a hall task card (requires local token gate)", + body: { + projectId: "required project id", + participantId: "optional participant id", + note: "optional assignment note", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + hallSummary: "CollaborationHallSummary", + taskCard: "HallTaskCard", + taskSummary: "HallTaskSummary", + task: "ProjectTask", + generatedMessages: "HallMessage[]", + }, + }, + { + method: "POST", + path: "/api/hall/tasks/:taskId/execution-order", + summary: "Set the planned execution order for one hall task card (requires local token gate)", + body: { + projectId: "optional project id", + taskCardId: "optional task card id", + participantIds: "required ordered participant id array", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + hallSummary: "CollaborationHallSummary", + taskCard: "HallTaskCard", + taskSummary: "HallTaskSummary", + task: "ProjectTask | undefined", + generatedMessages: "HallMessage[]", + }, + }, + { + method: "POST", + path: "/api/hall/tasks/:taskId/review", + summary: "Approve or reject a hall task card (requires local token gate)", + body: { + projectId: "required project id", + outcome: "required: approved|rejected", + note: "optional review note", + blockTask: "optional boolean", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + hallSummary: "CollaborationHallSummary", + taskCard: "HallTaskCard", + taskSummary: "HallTaskSummary", + task: "ProjectTask", + generatedMessages: "HallMessage[]", + }, + }, + { + method: "POST", + path: "/api/hall/tasks/:taskId/handoff", + summary: "Record a structured handoff inside the hall (requires local token gate)", + body: { + projectId: "required project id", + fromParticipantId: "optional current owner id", + toParticipantId: "required next owner id", + handoff: "{ goal, currentResult, doneWhen, blockers[], nextOwner, requiresInputFrom[] }", + }, + response: { + ok: "boolean", + hall: "CollaborationHall", + hallSummary: "CollaborationHallSummary", + taskCard: "HallTaskCard", + taskSummary: "HallTaskSummary", + task: "ProjectTask", + generatedMessages: "HallMessage[]", + }, + }, + { + method: "GET", + path: "/api/hall/tasks/:taskId/evidence", + summary: "Read the linked detail thread and runtime evidence for one hall task card", + query: { + projectId: "required project id", + historyLimit: "optional 1..200 (default 25)", + }, + response: { + ok: "boolean", + taskCard: "HallTaskCard", + room: "ChatRoom | null", + summary: "ChatRoomSummary | null", + storedMessages: "ChatMessage[]", + evidenceMessages: "ChatMessage[]", + }, + }, + { + method: "GET", + path: "/api/rooms", + summary: "List task collaboration rooms with optional project/task/stage filters", + query: { + projectId: "optional project id", + taskId: "optional task id", + stage: "optional: intake|discussion|assigned|executing|review|completed", + q: "optional substring search", + }, + response: { + ok: "boolean", + updatedAt: "ISO timestamp", + count: "number", + rooms: "ChatRoom[] with summary", + }, + }, + { + method: "POST", + path: "/api/rooms", + summary: "Create a task collaboration room and bind it to the task (requires local token gate)", + body: { + projectId: "required project id", + taskId: "required task id", + roomId: "optional room id", + title: "optional room title", + stage: "optional room stage", + }, + response: { + ok: "boolean", + path: "runtime/chat-rooms.json", + room: "ChatRoom", + task: "ProjectTask", + summary: "ChatRoomSummary", + }, + }, + { + method: "GET", + path: "/api/rooms/:roomId", + summary: "Get one room with stored messages and session-derived evidence", + query: { + historyLimit: "optional 1..200 (default 25)", + }, + response: { + ok: "boolean", + room: "ChatRoom", + task: "ProjectTask | undefined", + summary: "ChatRoomSummary", + storedMessages: "ChatMessage[]", + evidenceMessages: "ChatMessage[]", + }, + }, + { + method: "GET", + path: "/api/rooms/:roomId/messages", + summary: "Get merged stored messages and session-derived evidence for a room", + query: { + limit: "optional 1..1000 (default 200)", + historyLimit: "optional 1..200 (default 25)", + }, + response: { + ok: "boolean", + room: "ChatRoom", + summary: "ChatRoomSummary", + count: "number", + storedCount: "number", + evidenceCount: "number", + messages: "ChatMessage[]", + }, + }, + { + method: "GET", + path: "/api/rooms/:roomId/events", + summary: "Open an SSE stream for room invalidations and streamed agent reply drafts", + response: { + ok: "SSE stream", + events: "connected | invalidate | draft_start | draft_delta | draft_complete | draft_abort", + }, + }, + { + method: "GET", + path: "/api/rooms/:roomId/bridge-events", + summary: "Get recent outbound bridge events for one room", + query: { + limit: "optional 1..200 (default 20)", + }, + response: { + ok: "boolean", + updatedAt: "ISO timestamp", + count: "number", + events: "TaskRoomBridgeEvent[]", + }, + }, + { + method: "POST", + path: "/api/rooms/:roomId/messages", + summary: "Append a room message; human messages trigger the deterministic discussion orchestrator", + body: { + authorRole: "optional human|planner|coder|reviewer|manager (default human)", + authorLabel: "optional display label", + participantId: "optional participant id", + kind: "optional chat|proposal|decision|handoff|status|result", + content: "required message text", + mentions: "optional RoomParticipantRole[]", + sessionKey: "optional linked session key", + payload: "optional structured message payload", + }, + response: { + ok: "boolean", + room: "ChatRoom", + message: "ChatMessage", + generatedMessages: "ChatMessage[]", + summary: "ChatRoomSummary", + }, + }, + { + method: "POST", + path: "/api/rooms/:roomId/handoffs", + summary: "Record an explicit room handoff between roles (requires local token gate)", + body: { + fromRole: "required RoomParticipantRole", + toRole: "required RoomParticipantRole", + note: "optional string <= 320", + }, + response: { + ok: "boolean", + room: "ChatRoom", + generatedMessages: "ChatMessage[]", + summary: "ChatRoomSummary", + }, + }, + { + method: "POST", + path: "/api/rooms/:roomId/assign", + summary: "Assign execution to one role, sync the task to in_progress, and optionally start execution", + body: { + executorRole: "optional RoomParticipantRole (default room.assignedExecutor or coder)", + note: "optional string <= 320", + autoStartExecution: "optional boolean (default true)", + }, + response: { + ok: "boolean", + room: "ChatRoom", + task: "ProjectTask", + generatedMessages: "ChatMessage[]", + summary: "ChatRoomSummary", + }, + }, + { + method: "POST", + path: "/api/rooms/:roomId/review", + summary: "Approve or reject execution and sync the task status", + body: { + outcome: "required approved|rejected", + note: "optional string <= 320", + blockTask: "optional boolean; when rejected, prefer blocked over in_progress", + }, + response: { + ok: "boolean", + room: "ChatRoom", + task: "ProjectTask", + generatedMessages: "ChatMessage[]", + summary: "ChatRoomSummary", + }, + }, + { + method: "PATCH", + path: "/api/rooms/:roomId/stage", + summary: "Manually update the room stage and optional owner role (requires local token gate)", + body: { + stage: "required intake|discussion|assigned|executing|review|completed", + ownerRole: "optional RoomParticipantRole", + }, + response: { + ok: "boolean", + room: "ChatRoom", + summary: "ChatRoomSummary", + }, + }, { method: "GET", path: "/api/usage-cost", diff --git a/src/runtime/chat-store.ts b/src/runtime/chat-store.ts new file mode 100644 index 0000000..53f4a29 --- /dev/null +++ b/src/runtime/chat-store.ts @@ -0,0 +1,1096 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { getRuntimeDir, resolveRuntimePath } from "./runtime-path"; +import { publishRoomStreamEvent } from "./collaboration-stream"; +import type { + ChatMessage, + ChatMessagePayload, + ChatMessageStoreSnapshot, + ChatRoom, + ChatRoomStoreSnapshot, + HandoffRecord, + MessageKind, + RoomParticipant, + RoomParticipantRole, + RoomStage, +} from "../types"; + +const RUNTIME_DIR = getRuntimeDir(); +export const CHAT_ROOMS_PATH = resolveRuntimePath("chat-rooms.json"); +export const CHAT_MESSAGES_PATH = resolveRuntimePath("chat-messages.json"); + +const ROOM_ID_REGEX = /^[A-Za-z0-9._:-]+$/; +const TASK_ID_REGEX = /^[A-Za-z0-9._:-]+$/; +const PROJECT_ID_REGEX = /^[A-Za-z0-9._:-]+$/; +const DEFAULT_ROOM_STAGES: RoomStage[] = [ + "intake", + "discussion", + "assigned", + "executing", + "review", + "completed", +]; +const DEFAULT_MESSAGE_KINDS: MessageKind[] = ["chat", "proposal", "decision", "handoff", "status", "result"]; +const DEFAULT_ROOM_ROLES: RoomParticipantRole[] = ["human", "planner", "coder", "reviewer", "manager"]; + +const EMPTY_ROOM_STORE: ChatRoomStoreSnapshot = { + rooms: [], + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +const EMPTY_MESSAGE_STORE: ChatMessageStoreSnapshot = { + messages: [], + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +export class ChatStoreValidationError extends Error { + readonly statusCode: number; + readonly issues: string[]; + + constructor(message: string, issues: string[] = [], statusCode = 400) { + super(message); + this.name = "ChatStoreValidationError"; + this.issues = issues; + this.statusCode = statusCode; + } +} + +export interface CreateChatRoomInput { + projectId: string; + taskId: string; + roomId?: string; + title?: string; + stage?: RoomStage; + ownerRole?: RoomParticipantRole; + assignedExecutor?: RoomParticipantRole; + participants?: RoomParticipant[]; + sessionKeys?: string[]; + proposal?: string; + decision?: string; + doneWhen?: string; +} + +export interface CreateChatMessageInput { + roomId: string; + kind?: MessageKind; + authorRole: RoomParticipantRole; + authorLabel?: string; + participantId?: string; + content: string; + mentions?: RoomParticipantRole[]; + sessionKey?: string; + payload?: ChatMessagePayload; + messageId?: string; + createdAt?: string; +} + +export interface UpdateChatRoomInput { + roomId: string; + stage?: RoomStage; + ownerRole?: RoomParticipantRole; + assignedExecutor?: RoomParticipantRole | null; + sessionKeys?: string[]; + proposal?: string | null; + decision?: string | null; + doneWhen?: string | null; +} + +export interface DeleteChatRoomInput { + roomId: string; + deleteMessages?: boolean; +} + +export interface CreateChatHandoffInput { + roomId: string; + fromRole: RoomParticipantRole; + toRole: RoomParticipantRole; + note?: string; + createdAt?: string; +} + +export interface ChatRoomMutationResult { + path: string; + room: ChatRoom; +} + +export interface ChatMessageMutationResult { + path: string; + room: ChatRoom; + message: ChatMessage; +} + +export interface ChatHandoffMutationResult { + path: string; + room: ChatRoom; + handoff: HandoffRecord; +} + +export interface ChatRoomDeleteResult { + path: string; + room: ChatRoom; + removedMessages: number; +} + +export async function loadChatRoomStore(): Promise { + try { + const raw = await readFile(CHAT_ROOMS_PATH, "utf8"); + return normalizeChatRoomStore(JSON.parse(raw)); + } catch { + return cloneEmptyRoomStore(); + } +} + +export async function loadChatMessageStore(): Promise { + try { + const raw = await readFile(CHAT_MESSAGES_PATH, "utf8"); + return normalizeChatMessageStore(JSON.parse(raw)); + } catch { + return cloneEmptyMessageStore(); + } +} + +export async function saveChatRoomStore(next: ChatRoomStoreSnapshot): Promise { + const normalized = normalizeChatRoomStore({ + ...next, + updatedAt: new Date().toISOString(), + }); + await mkdir(RUNTIME_DIR, { recursive: true }); + await writeFile(CHAT_ROOMS_PATH, JSON.stringify(normalized, null, 2), "utf8"); + return CHAT_ROOMS_PATH; +} + +export async function saveChatMessageStore(next: ChatMessageStoreSnapshot): Promise { + const normalized = normalizeChatMessageStore({ + ...next, + updatedAt: new Date().toISOString(), + }); + await mkdir(RUNTIME_DIR, { recursive: true }); + await writeFile(CHAT_MESSAGES_PATH, JSON.stringify(normalized, null, 2), "utf8"); + return CHAT_MESSAGES_PATH; +} + +export function listChatRooms(store: ChatRoomStoreSnapshot): ChatRoom[] { + return [...store.rooms].sort(compareRooms); +} + +export function getChatRoom(store: ChatRoomStoreSnapshot, roomId: string): ChatRoom | undefined { + return store.rooms.find((room) => room.roomId === roomId.trim()); +} + +export function getChatRoomByTask( + store: ChatRoomStoreSnapshot, + taskId: string, + projectId?: string, +): ChatRoom | undefined { + const normalizedTaskId = taskId.trim(); + const normalizedProjectId = projectId?.trim(); + return store.rooms.find( + (room) => room.taskId === normalizedTaskId && (!normalizedProjectId || room.projectId === normalizedProjectId), + ); +} + +export function listChatMessages(store: ChatMessageStoreSnapshot, roomId: string): ChatMessage[] { + const normalizedRoomId = roomId.trim(); + return store.messages + .filter((message) => message.roomId === normalizedRoomId) + .sort(compareMessages); +} + +export async function createChatRoom(input: unknown): Promise { + const payload = validateCreateChatRoomInput(input); + const store = await loadChatRoomStore(); + if (store.rooms.some((room) => room.roomId === payload.roomId)) { + throw new ChatStoreValidationError(`roomId '${payload.roomId}' already exists.`, ["roomId"], 409); + } + if (getChatRoomByTask(store, payload.taskId, payload.projectId)) { + throw new ChatStoreValidationError( + `task '${payload.projectId}:${payload.taskId}' already has a primary room.`, + ["taskId"], + 409, + ); + } + + const now = new Date().toISOString(); + const room: ChatRoom = { + roomId: payload.roomId, + projectId: payload.projectId, + taskId: payload.taskId, + title: payload.title ?? payload.taskId, + stage: payload.stage ?? "intake", + ownerRole: payload.ownerRole ?? "human", + assignedExecutor: payload.assignedExecutor, + proposal: payload.proposal, + decision: payload.decision, + doneWhen: payload.doneWhen, + participants: payload.participants ?? defaultRoomParticipants(), + handoffs: [], + sessionKeys: payload.sessionKeys ?? [], + lastMessageAt: undefined, + createdAt: now, + updatedAt: now, + }; + + store.rooms.push(room); + store.updatedAt = now; + const path = await saveChatRoomStore(store); + publishRoomStreamEvent({ + type: "invalidate", + roomId: room.roomId, + projectId: room.projectId, + taskId: room.taskId, + reason: "room_created", + }); + return { path, room }; +} + +export async function updateChatRoom(input: UpdateChatRoomInput): Promise { + const payload = validateUpdateChatRoomInput(input); + const store = await loadChatRoomStore(); + const room = getChatRoom(store, payload.roomId); + if (!room) { + throw new ChatStoreValidationError(`roomId '${payload.roomId}' was not found.`, [], 404); + } + + const now = new Date().toISOString(); + if (payload.stage) room.stage = payload.stage; + if (payload.ownerRole) room.ownerRole = payload.ownerRole; + if (payload.assignedExecutor !== undefined) { + room.assignedExecutor = payload.assignedExecutor ?? undefined; + } + if (payload.sessionKeys) room.sessionKeys = payload.sessionKeys; + if (payload.proposal !== undefined) room.proposal = payload.proposal ?? undefined; + if (payload.decision !== undefined) room.decision = payload.decision ?? undefined; + if (payload.doneWhen !== undefined) room.doneWhen = payload.doneWhen ?? undefined; + room.updatedAt = now; + store.updatedAt = now; + + const path = await saveChatRoomStore(store); + publishRoomStreamEvent({ + type: "invalidate", + roomId: room.roomId, + projectId: room.projectId, + taskId: room.taskId, + reason: "room_updated", + }); + return { path, room }; +} + +export async function appendChatMessage(input: unknown): Promise { + const payload = validateCreateChatMessageInput(input); + const [roomStore, messageStore] = await Promise.all([loadChatRoomStore(), loadChatMessageStore()]); + const room = getChatRoom(roomStore, payload.roomId); + if (!room) { + throw new ChatStoreValidationError(`roomId '${payload.roomId}' was not found.`, ["roomId"], 404); + } + + const message: ChatMessage = { + roomId: payload.roomId, + messageId: payload.messageId ?? randomUUID(), + kind: payload.kind ?? "chat", + authorRole: payload.authorRole, + authorLabel: payload.authorLabel ?? defaultParticipantLabel(payload.authorRole), + participantId: payload.participantId, + content: payload.content, + mentions: payload.mentions ?? [], + sessionKey: payload.sessionKey, + payload: payload.payload, + createdAt: payload.createdAt ?? new Date().toISOString(), + }; + + messageStore.messages.push(message); + messageStore.updatedAt = message.createdAt; + room.lastMessageAt = message.createdAt; + room.updatedAt = message.createdAt; + + if (message.payload?.proposal) room.proposal = message.payload.proposal; + if (message.payload?.decision) room.decision = message.payload.decision; + if (message.payload?.executor) room.assignedExecutor = message.payload.executor; + if (message.payload?.doneWhen) room.doneWhen = message.payload.doneWhen; + if (message.payload?.sessionKey && !room.sessionKeys.includes(message.payload.sessionKey)) { + room.sessionKeys = [...room.sessionKeys, message.payload.sessionKey]; + } + if (message.sessionKey && !room.sessionKeys.includes(message.sessionKey)) { + room.sessionKeys = [...room.sessionKeys, message.sessionKey]; + } + roomStore.updatedAt = message.createdAt; + + const [roomsPath, messagesPath] = await Promise.all([ + saveChatRoomStore(roomStore), + saveChatMessageStore(messageStore), + ]); + publishRoomStreamEvent({ + type: "invalidate", + roomId: room.roomId, + projectId: room.projectId, + taskId: room.taskId, + reason: "message_created", + messageId: message.messageId, + messageKind: message.kind, + authorRole: message.authorRole, + authorLabel: message.authorLabel, + }); + return { + path: `${roomsPath}|${messagesPath}`, + room, + message, + }; +} + +export async function createChatHandoff(input: unknown): Promise { + const payload = validateCreateChatHandoffInput(input); + const store = await loadChatRoomStore(); + const room = getChatRoom(store, payload.roomId); + if (!room) { + throw new ChatStoreValidationError(`roomId '${payload.roomId}' was not found.`, ["roomId"], 404); + } + + const handoff: HandoffRecord = { + handoffId: randomUUID(), + roomId: payload.roomId, + taskId: room.taskId, + fromRole: payload.fromRole, + toRole: payload.toRole, + note: payload.note, + createdAt: payload.createdAt ?? new Date().toISOString(), + }; + + room.handoffs.push(handoff); + room.ownerRole = payload.toRole; + room.updatedAt = handoff.createdAt; + store.updatedAt = handoff.createdAt; + + const path = await saveChatRoomStore(store); + return { path, room, handoff }; +} + +export async function deleteChatRoom(input: DeleteChatRoomInput): Promise { + const payload = validateDeleteChatRoomInput(input); + const [roomStore, messageStore] = await Promise.all([loadChatRoomStore(), loadChatMessageStore()]); + const room = getChatRoom(roomStore, payload.roomId); + if (!room) { + throw new ChatStoreValidationError(`roomId '${payload.roomId}' was not found.`, ["roomId"], 404); + } + + const deletedRoom = { ...room }; + const now = new Date().toISOString(); + roomStore.rooms = roomStore.rooms.filter((item) => item.roomId !== payload.roomId); + roomStore.updatedAt = now; + + let removedMessages = 0; + if (payload.deleteMessages) { + const nextMessages = messageStore.messages.filter((message) => message.roomId !== payload.roomId); + removedMessages = messageStore.messages.length - nextMessages.length; + messageStore.messages = nextMessages; + messageStore.updatedAt = now; + } + + const pathParts = await Promise.all([ + saveChatRoomStore(roomStore), + payload.deleteMessages ? saveChatMessageStore(messageStore) : Promise.resolve(CHAT_MESSAGES_PATH), + ]); + + publishRoomStreamEvent({ + type: "invalidate", + roomId: deletedRoom.roomId, + projectId: deletedRoom.projectId, + taskId: deletedRoom.taskId, + reason: "room_deleted", + }); + return { + path: pathParts.join("|"), + room: deletedRoom, + removedMessages, + }; +} + +export function defaultRoomParticipants(): RoomParticipant[] { + return DEFAULT_ROOM_ROLES.map((role) => ({ + participantId: role, + role, + label: defaultParticipantLabel(role), + active: true, + })); +} + +function validateCreateChatRoomInput(input: unknown): CreateChatRoomInput & { roomId: string } { + const obj = ensureObject(input, "create room payload"); + const issues: string[] = []; + const projectId = requiredProjectId(obj.projectId, "projectId", issues); + const taskId = requiredTaskId(obj.taskId, "taskId", issues); + const roomId = optionalRoomId(obj.roomId, "roomId", issues) ?? `${projectId}:${taskId}`; + const title = optionalBoundedString(obj.title, "title", 180, issues); + const stage = optionalRoomStage(obj.stage, "stage", issues); + const ownerRole = optionalRoomParticipantRole(obj.ownerRole, "ownerRole", issues); + const assignedExecutor = optionalRoomParticipantRole(obj.assignedExecutor, "assignedExecutor", issues); + const sessionKeys = optionalStringArray(obj.sessionKeys, "sessionKeys", issues, 200); + const participants = optionalParticipants(obj.participants, "participants", issues); + const proposal = optionalBoundedString(obj.proposal, "proposal", 600, issues); + const decision = optionalBoundedString(obj.decision, "decision", 600, issues); + const doneWhen = optionalBoundedString(obj.doneWhen, "doneWhen", 240, issues); + + if (issues.length > 0) { + throw new ChatStoreValidationError("Invalid create room payload.", issues, 400); + } + + return { + roomId, + projectId, + taskId, + title, + stage, + ownerRole, + assignedExecutor, + sessionKeys, + participants, + proposal, + decision, + doneWhen, + }; +} + +function validateUpdateChatRoomInput(input: UpdateChatRoomInput): UpdateChatRoomInput { + const obj = ensureObject(input, "update room payload"); + const issues: string[] = []; + const roomId = requiredRoomId(obj.roomId, "roomId", issues); + const stage = optionalRoomStage(obj.stage, "stage", issues); + const ownerRole = optionalRoomParticipantRole(obj.ownerRole, "ownerRole", issues); + const sessionKeys = optionalStringArray(obj.sessionKeys, "sessionKeys", issues, 200); + const proposal = optionalNullableBoundedString(obj.proposal, "proposal", 600, issues); + const decision = optionalNullableBoundedString(obj.decision, "decision", 600, issues); + const doneWhen = optionalNullableBoundedString(obj.doneWhen, "doneWhen", 240, issues); + const assignedExecutor = optionalNullableRoomParticipantRole(obj.assignedExecutor, "assignedExecutor", issues); + + if ( + stage === undefined && + ownerRole === undefined && + sessionKeys === undefined && + proposal === undefined && + decision === undefined && + doneWhen === undefined && + assignedExecutor === undefined + ) { + issues.push("at least one updatable room field is required"); + } + + if (issues.length > 0) { + throw new ChatStoreValidationError("Invalid update room payload.", issues, 400); + } + + return { + roomId, + stage, + ownerRole, + sessionKeys, + proposal, + decision, + doneWhen, + assignedExecutor, + }; +} + +function validateDeleteChatRoomInput(input: DeleteChatRoomInput): DeleteChatRoomInput & { roomId: string } { + const obj = ensureObject(input, "delete room payload"); + const issues: string[] = []; + const roomId = requiredRoomId(obj.roomId, "roomId", issues); + const deleteMessages = obj.deleteMessages === true; + + if (issues.length > 0) { + throw new ChatStoreValidationError("Invalid delete room payload.", issues, 400); + } + + return { + roomId, + deleteMessages, + }; +} + +function validateCreateChatMessageInput(input: unknown): CreateChatMessageInput { + const obj = ensureObject(input, "create message payload"); + const issues: string[] = []; + const roomId = requiredRoomId(obj.roomId, "roomId", issues); + const kind = optionalMessageKind(obj.kind, "kind", issues); + const authorRole = requiredRoomParticipantRole(obj.authorRole, "authorRole", issues); + const authorLabel = optionalBoundedString(obj.authorLabel, "authorLabel", 80, issues); + const participantId = optionalBoundedString(obj.participantId, "participantId", 80, issues); + const content = requiredText(obj.content, "content", 4000, issues); + const mentions = optionalRoleArray(obj.mentions, "mentions", issues); + const sessionKey = optionalBoundedString(obj.sessionKey, "sessionKey", 220, issues); + const payload = optionalMessagePayload(obj.payload, "payload", issues); + const messageId = optionalBoundedString(obj.messageId, "messageId", 120, issues); + const createdAt = optionalIsoString(obj.createdAt, "createdAt", issues); + + if (issues.length > 0) { + throw new ChatStoreValidationError("Invalid create message payload.", issues, 400); + } + + return { + roomId, + kind, + authorRole, + authorLabel, + participantId, + content, + mentions, + sessionKey, + payload, + messageId, + createdAt, + }; +} + +function validateCreateChatHandoffInput(input: unknown): CreateChatHandoffInput { + const obj = ensureObject(input, "create handoff payload"); + const issues: string[] = []; + const roomId = requiredRoomId(obj.roomId, "roomId", issues); + const fromRole = requiredRoomParticipantRole(obj.fromRole, "fromRole", issues); + const toRole = requiredRoomParticipantRole(obj.toRole, "toRole", issues); + const note = optionalBoundedString(obj.note, "note", 320, issues); + const createdAt = optionalIsoString(obj.createdAt, "createdAt", issues); + + if (issues.length > 0) { + throw new ChatStoreValidationError("Invalid handoff payload.", issues, 400); + } + + return { + roomId, + fromRole, + toRole, + note, + createdAt, + }; +} + +function normalizeChatRoomStore(input: unknown): ChatRoomStoreSnapshot { + const obj = asObject(input); + if (!obj) return cloneEmptyRoomStore(); + + return { + rooms: normalizeRooms(asArray(obj.rooms)), + updatedAt: asIsoString(obj.updatedAt), + }; +} + +function normalizeChatMessageStore(input: unknown): ChatMessageStoreSnapshot { + const obj = asObject(input); + if (!obj) return cloneEmptyMessageStore(); + + return { + messages: normalizeMessages(asArray(obj.messages)), + updatedAt: asIsoString(obj.updatedAt), + }; +} + +function normalizeRooms(rooms: unknown[] | undefined): ChatRoom[] { + if (!rooms) return []; + const unique = new Map(); + for (const input of rooms) { + const room = normalizeRoom(input); + if (!room) continue; + unique.set(room.roomId, room); + } + return [...unique.values()].sort(compareRooms); +} + +function normalizeMessages(messages: unknown[] | undefined): ChatMessage[] { + if (!messages) return []; + return messages + .map((message) => normalizeMessage(message)) + .filter((message): message is ChatMessage => Boolean(message)) + .sort(compareMessages); +} + +function normalizeRoom(input: unknown): ChatRoom | null { + const obj = asObject(input); + if (!obj) return null; + + const roomId = asString(obj.roomId)?.trim(); + const projectId = asString(obj.projectId)?.trim(); + const taskId = asString(obj.taskId)?.trim(); + if (!roomId || !ROOM_ID_REGEX.test(roomId) || !projectId || !taskId) return null; + + return { + roomId, + projectId, + taskId, + title: asString(obj.title)?.trim() || taskId, + stage: normalizeRoomStage(asString(obj.stage)), + ownerRole: normalizeRoomParticipantRole(asString(obj.ownerRole)) ?? "human", + assignedExecutor: normalizeOptionalRole(asString(obj.assignedExecutor)), + proposal: asString(obj.proposal)?.trim() || undefined, + decision: asString(obj.decision)?.trim() || undefined, + doneWhen: asString(obj.doneWhen)?.trim() || undefined, + participants: normalizeParticipants(asArray(obj.participants)), + handoffs: normalizeHandoffs(asArray(obj.handoffs), roomId, taskId), + sessionKeys: toUniqueStringArray(obj.sessionKeys, 200), + summaryId: asString(obj.summaryId)?.trim() || undefined, + lastMessageAt: asOptionalIsoString(obj.lastMessageAt), + createdAt: asIsoString(obj.createdAt), + updatedAt: asIsoString(obj.updatedAt), + }; +} + +function normalizeMessage(input: unknown): ChatMessage | null { + const obj = asObject(input); + if (!obj) return null; + + const roomId = asString(obj.roomId)?.trim(); + const messageId = asString(obj.messageId)?.trim(); + const authorRole = normalizeOptionalRole(asString(obj.authorRole)); + if (!roomId || !messageId || !authorRole) return null; + + return { + roomId, + messageId, + kind: normalizeMessageKind(asString(obj.kind)), + authorRole, + authorLabel: asString(obj.authorLabel)?.trim() || defaultParticipantLabel(authorRole), + participantId: asString(obj.participantId)?.trim() || undefined, + content: asString(obj.content) ?? "", + mentions: normalizeRoleArray(asArray(obj.mentions) ?? []), + sessionKey: asString(obj.sessionKey)?.trim() || undefined, + payload: normalizePayload(asObject(obj.payload)), + createdAt: asIsoString(obj.createdAt), + }; +} + +function normalizeParticipants(participants: unknown[] | undefined): RoomParticipant[] { + if (!participants || participants.length === 0) return defaultRoomParticipants(); + + const unique = new Map(); + for (const input of participants) { + const participant = normalizeParticipant(input); + if (!participant) continue; + unique.set(participant.role, participant); + } + + for (const role of DEFAULT_ROOM_ROLES) { + if (!unique.has(role)) { + unique.set(role, { + participantId: role, + role, + label: defaultParticipantLabel(role), + active: true, + }); + } + } + + return DEFAULT_ROOM_ROLES.map((role) => unique.get(role) as RoomParticipant); +} + +function normalizeParticipant(input: unknown): RoomParticipant | null { + const obj = asObject(input); + if (!obj) return null; + const role = normalizeOptionalRole(asString(obj.role)); + if (!role) return null; + + return { + participantId: asString(obj.participantId)?.trim() || role, + role, + label: asString(obj.label)?.trim() || defaultParticipantLabel(role), + agentId: asString(obj.agentId)?.trim() || undefined, + sessionKey: asString(obj.sessionKey)?.trim() || undefined, + active: asBoolean(obj.active) !== false, + }; +} + +function normalizeHandoffs( + handoffs: unknown[] | undefined, + roomId: string, + taskId: string, +): HandoffRecord[] { + if (!handoffs) return []; + return handoffs + .map((input) => normalizeHandoff(input, roomId, taskId)) + .filter((handoff): handoff is HandoffRecord => Boolean(handoff)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +function normalizeHandoff(input: unknown, roomId: string, taskId: string): HandoffRecord | null { + const obj = asObject(input); + if (!obj) return null; + const fromRole = normalizeOptionalRole(asString(obj.fromRole)); + const toRole = normalizeOptionalRole(asString(obj.toRole)); + const handoffId = asString(obj.handoffId)?.trim(); + if (!handoffId || !fromRole || !toRole) return null; + + return { + handoffId, + roomId, + taskId, + fromRole, + toRole, + note: asString(obj.note)?.trim() || undefined, + createdAt: asIsoString(obj.createdAt), + }; +} + +function normalizePayload(input: Record | undefined): ChatMessagePayload | undefined { + if (!input) return undefined; + const payload: ChatMessagePayload = {}; + const executor = normalizeOptionalRole(asString(input.executor)); + const fromRole = normalizeOptionalRole(asString(input.fromRole)); + const targetRole = normalizeOptionalRole(asString(input.targetRole)); + const taskStatus = normalizeOptionalTaskState(asString(input.taskStatus)); + const reviewOutcome = asString(input.reviewOutcome); + + if (typeof input.proposal === "string" && input.proposal.trim()) payload.proposal = input.proposal.trim(); + if (typeof input.decision === "string" && input.decision.trim()) payload.decision = input.decision.trim(); + if (executor) payload.executor = executor; + if (typeof input.doneWhen === "string" && input.doneWhen.trim()) payload.doneWhen = input.doneWhen.trim(); + if (fromRole) payload.fromRole = fromRole; + if (targetRole) payload.targetRole = targetRole; + if (typeof input.handoffId === "string" && input.handoffId.trim()) payload.handoffId = input.handoffId.trim(); + if (typeof input.status === "string" && input.status.trim()) payload.status = input.status.trim(); + if (taskStatus) payload.taskStatus = taskStatus; + if (reviewOutcome === "approved" || reviewOutcome === "rejected") payload.reviewOutcome = reviewOutcome; + if (typeof input.sessionKey === "string" && input.sessionKey.trim()) payload.sessionKey = input.sessionKey.trim(); + if (typeof input.sourceSessionKey === "string" && input.sourceSessionKey.trim()) { + payload.sourceSessionKey = input.sourceSessionKey.trim(); + } + if (typeof input.sourceTool === "string" && input.sourceTool.trim()) payload.sourceTool = input.sourceTool.trim(); + + return Object.keys(payload).length > 0 ? payload : undefined; +} + +function compareRooms(a: ChatRoom, b: ChatRoom): number { + return toSortableMs(b.updatedAt || b.createdAt) - toSortableMs(a.updatedAt || a.createdAt); +} + +function compareMessages(a: ChatMessage, b: ChatMessage): number { + const delta = toSortableMs(a.createdAt) - toSortableMs(b.createdAt); + return delta !== 0 ? delta : a.messageId.localeCompare(b.messageId); +} + +function cloneEmptyRoomStore(): ChatRoomStoreSnapshot { + return { + rooms: [], + updatedAt: EMPTY_ROOM_STORE.updatedAt, + }; +} + +function cloneEmptyMessageStore(): ChatMessageStoreSnapshot { + return { + messages: [], + updatedAt: EMPTY_MESSAGE_STORE.updatedAt, + }; +} + +function ensureObject(input: unknown, label: string): Record { + const obj = asObject(input); + if (!obj) throw new ChatStoreValidationError(`${label} must be a JSON object.`, [], 400); + return obj; +} + +function requiredProjectId(value: unknown, field: string, issues: string[]): string { + if (typeof value !== "string" || value.trim() === "") { + issues.push(`${field} must be a non-empty string`); + return ""; + } + const trimmed = value.trim(); + if (!PROJECT_ID_REGEX.test(trimmed)) { + issues.push(`${field} may only contain letters, numbers, '.', '_', ':', '-'`); + } + if (trimmed.length > 100) { + issues.push(`${field} must be <= 100 characters`); + } + return trimmed; +} + +function requiredTaskId(value: unknown, field: string, issues: string[]): string { + if (typeof value !== "string" || value.trim() === "") { + issues.push(`${field} must be a non-empty string`); + return ""; + } + const trimmed = value.trim(); + if (!TASK_ID_REGEX.test(trimmed)) { + issues.push(`${field} may only contain letters, numbers, '.', '_', ':', '-'`); + } + if (trimmed.length > 120) { + issues.push(`${field} must be <= 120 characters`); + } + return trimmed; +} + +function requiredRoomId(value: unknown, field: string, issues: string[]): string { + const roomId = optionalRoomId(value, field, issues); + if (!roomId) issues.push(`${field} is required`); + return roomId ?? ""; +} + +function optionalRoomId(value: unknown, field: string, issues: string[]): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.trim() === "") { + issues.push(`${field} must be a non-empty string`); + return undefined; + } + const trimmed = value.trim(); + if (!ROOM_ID_REGEX.test(trimmed)) { + issues.push(`${field} may only contain letters, numbers, '.', '_', ':', '-'`); + return undefined; + } + if (trimmed.length > 140) { + issues.push(`${field} must be <= 140 characters`); + return undefined; + } + return trimmed; +} + +function requiredText(value: unknown, field: string, maxLength: number, issues: string[]): string { + if (typeof value !== "string" || value.trim() === "") { + issues.push(`${field} must be a non-empty string`); + return ""; + } + if (value.length > maxLength) { + issues.push(`${field} must be <= ${maxLength} characters`); + } + return value.trim(); +} + +function optionalBoundedString( + value: unknown, + field: string, + maxLength: number, + issues: string[], +): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") { + issues.push(`${field} must be a string`); + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (trimmed.length > maxLength) { + issues.push(`${field} must be <= ${maxLength} characters`); + return undefined; + } + return trimmed; +} + +function optionalNullableBoundedString( + value: unknown, + field: string, + maxLength: number, + issues: string[], +): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + const normalized = optionalBoundedString(value, field, maxLength, issues); + return normalized ?? null; +} + +function optionalIsoString(value: unknown, field: string, issues: string[]): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || Number.isNaN(Date.parse(value))) { + issues.push(`${field} must be an ISO date-time string`); + return undefined; + } + return new Date(value).toISOString(); +} + +function optionalStringArray( + value: unknown, + field: string, + issues: string[], + maxLength: number, +): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + issues.push(`${field} must be an array of strings`); + return undefined; + } + + const normalized = [...new Set(value.map((item) => item.trim()).filter(Boolean))]; + if (normalized.some((item) => item.length > maxLength)) { + issues.push(`${field} values must be <= ${maxLength} characters`); + } + return normalized; +} + +function optionalParticipants( + value: unknown, + field: string, + issues: string[], +): RoomParticipant[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + issues.push(`${field} must be an array`); + return undefined; + } + const participants = normalizeParticipants(value); + if (participants.length === 0) { + issues.push(`${field} must contain at least one valid participant`); + return undefined; + } + return participants; +} + +function optionalMessagePayload( + value: unknown, + field: string, + issues: string[], +): ChatMessagePayload | undefined { + if (value === undefined) return undefined; + const obj = asObject(value); + if (!obj) { + issues.push(`${field} must be an object`); + return undefined; + } + return normalizePayload(obj); +} + +function optionalRoleArray( + value: unknown, + field: string, + issues: string[], +): RoomParticipantRole[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + issues.push(`${field} must be an array`); + return undefined; + } + const roles = normalizeRoleArray(value); + if (roles.length !== value.length) { + issues.push(`${field} must contain only known room roles`); + return undefined; + } + return roles; +} + +function optionalRoomStage(value: unknown, field: string, issues: string[]): RoomStage | undefined { + if (value === undefined) return undefined; + const normalized = typeof value === "string" ? normalizeRoomStage(value) : undefined; + if (!normalized || !DEFAULT_ROOM_STAGES.includes(normalized)) { + issues.push(`${field} must be one of: ${DEFAULT_ROOM_STAGES.join(", ")}`); + return undefined; + } + return normalized; +} + +function optionalMessageKind(value: unknown, field: string, issues: string[]): MessageKind | undefined { + if (value === undefined) return undefined; + const normalized = typeof value === "string" ? normalizeMessageKind(value) : undefined; + if (!normalized || !DEFAULT_MESSAGE_KINDS.includes(normalized)) { + issues.push(`${field} must be one of: ${DEFAULT_MESSAGE_KINDS.join(", ")}`); + return undefined; + } + return normalized; +} + +function optionalRoomParticipantRole( + value: unknown, + field: string, + issues: string[], +): RoomParticipantRole | undefined { + if (value === undefined) return undefined; + const normalized = typeof value === "string" ? normalizeRoomParticipantRole(value) : undefined; + if (!normalized) { + issues.push(`${field} must be one of: ${DEFAULT_ROOM_ROLES.join(", ")}`); + } + return normalized; +} + +function optionalNullableRoomParticipantRole( + value: unknown, + field: string, + issues: string[], +): RoomParticipantRole | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + return optionalRoomParticipantRole(value, field, issues) ?? null; +} + +function requiredRoomParticipantRole( + value: unknown, + field: string, + issues: string[], +): RoomParticipantRole { + const normalized = optionalRoomParticipantRole(value, field, issues); + if (!normalized) issues.push(`${field} is required`); + return normalized ?? "human"; +} + +function normalizeRoomStage(value: string | undefined): RoomStage { + return value && DEFAULT_ROOM_STAGES.includes(value as RoomStage) ? (value as RoomStage) : "intake"; +} + +function normalizeMessageKind(value: string | undefined): MessageKind { + return value && DEFAULT_MESSAGE_KINDS.includes(value as MessageKind) ? (value as MessageKind) : "chat"; +} + +function normalizeRoomParticipantRole(value: string | undefined): RoomParticipantRole | undefined { + return value && DEFAULT_ROOM_ROLES.includes(value as RoomParticipantRole) + ? (value as RoomParticipantRole) + : undefined; +} + +function normalizeOptionalRole(value: string | undefined): RoomParticipantRole | undefined { + return normalizeRoomParticipantRole(value); +} + +function normalizeOptionalTaskState(value: string | undefined): ChatMessagePayload["taskStatus"] | undefined { + if (value === "todo" || value === "in_progress" || value === "blocked" || value === "done") { + return value; + } + return undefined; +} + +function normalizeRoleArray(values: unknown[]): RoomParticipantRole[] { + return [...new Set( + values + .map((value) => (typeof value === "string" ? normalizeRoomParticipantRole(value) : undefined)) + .filter((value): value is RoomParticipantRole => Boolean(value)), + )]; +} + +function toUniqueStringArray(value: unknown, maxLength: number): string[] { + if (!Array.isArray(value)) return []; + return [...new Set( + value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0 && item.length <= maxLength), + )]; +} + +function defaultParticipantLabel(role: RoomParticipantRole): string { + if (role === "human") return "Operator"; + if (role === "planner") return "Planner"; + if (role === "coder") return "Coder"; + if (role === "reviewer") return "Reviewer"; + return "Manager"; +} + +function asIsoString(value: unknown): string { + if (typeof value === "string" && !Number.isNaN(Date.parse(value))) { + return new Date(value).toISOString(); + } + return new Date().toISOString(); +} + +function asOptionalIsoString(value: unknown): string | undefined { + if (typeof value !== "string" || Number.isNaN(Date.parse(value))) return undefined; + return new Date(value).toISOString(); +} + +function asObject(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function toSortableMs(value: string | undefined): number { + const parsed = value ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? parsed : 0; +} diff --git a/src/runtime/chat-summary-store.ts b/src/runtime/chat-summary-store.ts new file mode 100644 index 0000000..a3d07bd --- /dev/null +++ b/src/runtime/chat-summary-store.ts @@ -0,0 +1,244 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { + ChatMessage, + ChatRoom, + ChatRoomSummary, + ChatSummaryStoreSnapshot, + RoomParticipantRole, + RoomStage, +} from "../types"; + +const RUNTIME_DIR = join(process.cwd(), "runtime"); +export const CHAT_SUMMARIES_PATH = join(RUNTIME_DIR, "chat-summaries.json"); + +const EMPTY_SUMMARY_STORE: ChatSummaryStoreSnapshot = { + summaries: [], + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +export interface ChatSummaryMutationResult { + path: string; + summary: ChatRoomSummary; +} + +export async function loadChatSummaryStore(): Promise { + try { + const raw = await readFile(CHAT_SUMMARIES_PATH, "utf8"); + return normalizeChatSummaryStore(JSON.parse(raw)); + } catch { + return cloneEmptySummaryStore(); + } +} + +export async function saveChatSummaryStore(next: ChatSummaryStoreSnapshot): Promise { + const normalized = normalizeChatSummaryStore({ + ...next, + updatedAt: new Date().toISOString(), + }); + await mkdir(RUNTIME_DIR, { recursive: true }); + await writeFile(CHAT_SUMMARIES_PATH, JSON.stringify(normalized, null, 2), "utf8"); + return CHAT_SUMMARIES_PATH; +} + +export function getChatRoomSummary( + store: ChatSummaryStoreSnapshot, + roomId: string, +): ChatRoomSummary | undefined { + return store.summaries.find((summary) => summary.roomId === roomId.trim()); +} + +export function listChatRoomSummaries(store: ChatSummaryStoreSnapshot): ChatRoomSummary[] { + return [...store.summaries].sort((a, b) => toSortableMs(b.updatedAt) - toSortableMs(a.updatedAt)); +} + +export async function upsertChatRoomSummary( + room: ChatRoom, + messages: ChatMessage[], +): Promise { + const store = await loadChatSummaryStore(); + const summary = buildChatRoomSummary(room, messages); + const existingIndex = store.summaries.findIndex((item) => item.roomId === room.roomId); + if (existingIndex >= 0) { + store.summaries[existingIndex] = summary; + } else { + store.summaries.push(summary); + } + store.updatedAt = summary.updatedAt; + const path = await saveChatSummaryStore(store); + return { path, summary }; +} + +export function buildChatRoomSummary(room: ChatRoom, messages: ChatMessage[]): ChatRoomSummary { + const ordered = [...messages].sort((a, b) => toSortableMs(a.createdAt) - toSortableMs(b.createdAt)); + const lastDecisionMessage = [...ordered] + .reverse() + .find((message) => message.kind === "decision" || message.payload?.decision); + const lastProposalMessage = [...ordered] + .reverse() + .find((message) => message.kind === "proposal" || message.payload?.proposal); + const latestHumanMessage = [...ordered].reverse().find((message) => message.authorRole === "human"); + const latestQuestion = extractQuestion(latestHumanMessage?.content ?? ""); + const openQuestions = latestQuestion ? [latestQuestion] : []; + const headline = resolveHeadline(room, lastDecisionMessage, lastProposalMessage); + const latestDecision = lastDecisionMessage?.payload?.decision ?? room.decision; + const currentOwner = resolveCurrentOwner(room.stage, room.ownerRole, room.assignedExecutor); + + return { + roomId: room.roomId, + headline, + latestDecision, + currentOwner, + nextAction: buildNextAction(room.stage, room.assignedExecutor, latestDecision), + openQuestions, + messageCount: ordered.length, + updatedAt: room.updatedAt, + }; +} + +function resolveHeadline( + room: ChatRoom, + lastDecisionMessage: ChatMessage | undefined, + lastProposalMessage: ChatMessage | undefined, +): string { + const decision = lastDecisionMessage?.payload?.decision ?? room.decision; + if (decision) return decision; + const proposal = lastProposalMessage?.payload?.proposal ?? room.proposal; + if (proposal) return proposal; + return room.title; +} + +function resolveCurrentOwner( + stage: RoomStage, + ownerRole: RoomParticipantRole, + assignedExecutor?: RoomParticipantRole, +): RoomParticipantRole { + if ((stage === "assigned" || stage === "executing") && assignedExecutor) return assignedExecutor; + return ownerRole; +} + +function buildNextAction( + stage: RoomStage, + assignedExecutor: RoomParticipantRole | undefined, + latestDecision: string | undefined, +): string { + if (stage === "intake") return "Collect the first human request and begin discussion."; + if (stage === "discussion") return "Finish planner/coder/reviewer discussion and capture a manager decision."; + if (stage === "assigned") { + return assignedExecutor + ? `${titleCaseRole(assignedExecutor)} should acknowledge the handoff and start execution.` + : "Confirm the executor and acknowledge the handoff."; + } + if (stage === "executing") return "Keep posting execution status until the task is ready for review."; + if (stage === "review") return "Approve or reject the execution result and sync the task state."; + return latestDecision ? `Completed: ${latestDecision}` : "Completed and waiting for follow-up."; +} + +function extractQuestion(content: string): string | undefined { + const trimmed = content.trim(); + if (!trimmed) return undefined; + const match = trimmed.match(/([^??]+\?|[^??]+?)$/u); + if (!match) return undefined; + const question = match[1].trim(); + return question.length > 220 ? `${question.slice(0, 217)}...` : question; +} + +function titleCaseRole(role: RoomParticipantRole): string { + if (role === "human") return "Operator"; + if (role === "planner") return "Planner"; + if (role === "coder") return "Coder"; + if (role === "reviewer") return "Reviewer"; + return "Manager"; +} + +function normalizeChatSummaryStore(input: unknown): ChatSummaryStoreSnapshot { + const obj = asObject(input); + if (!obj) return cloneEmptySummaryStore(); + + return { + summaries: normalizeSummaries(asArray(obj.summaries)), + updatedAt: asIsoString(obj.updatedAt), + }; +} + +function normalizeSummaries(summaries: unknown[] | undefined): ChatRoomSummary[] { + if (!summaries) return []; + return summaries + .map((summary) => normalizeSummary(summary)) + .filter((summary): summary is ChatRoomSummary => Boolean(summary)) + .sort((a, b) => toSortableMs(b.updatedAt) - toSortableMs(a.updatedAt)); +} + +function normalizeSummary(input: unknown): ChatRoomSummary | null { + const obj = asObject(input); + if (!obj) return null; + const roomId = asString(obj.roomId)?.trim(); + const headline = asString(obj.headline)?.trim(); + if (!roomId || !headline) return null; + + return { + roomId, + headline, + latestDecision: asString(obj.latestDecision)?.trim() || undefined, + currentOwner: normalizeOptionalRole(asString(obj.currentOwner)), + nextAction: asString(obj.nextAction)?.trim() || "Open the room and review the latest state.", + openQuestions: toStringArray(obj.openQuestions, 220), + messageCount: asFiniteNumber(obj.messageCount) ?? 0, + updatedAt: asIsoString(obj.updatedAt), + }; +} + +function cloneEmptySummaryStore(): ChatSummaryStoreSnapshot { + return { + summaries: [], + updatedAt: EMPTY_SUMMARY_STORE.updatedAt, + }; +} + +function normalizeOptionalRole(value: string | undefined): RoomParticipantRole | undefined { + if (value === "human" || value === "planner" || value === "coder" || value === "reviewer" || value === "manager") { + return value; + } + return undefined; +} + +function toStringArray(value: unknown, maxLength: number): string[] { + if (!Array.isArray(value)) return []; + return [...new Set( + value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .map((item) => (item.length > maxLength ? `${item.slice(0, maxLength - 3)}...` : item)), + )]; +} + +function asObject(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asIsoString(value: unknown): string { + if (typeof value === "string" && !Number.isNaN(Date.parse(value))) { + return new Date(value).toISOString(); + } + return new Date().toISOString(); +} + +function toSortableMs(value: string | undefined): number { + const parsed = value ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? parsed : 0; +} diff --git a/src/runtime/collaboration-hall-orchestrator.ts b/src/runtime/collaboration-hall-orchestrator.ts new file mode 100644 index 0000000..9a09930 --- /dev/null +++ b/src/runtime/collaboration-hall-orchestrator.ts @@ -0,0 +1,4126 @@ +import { randomUUID } from "node:crypto"; +import type { ToolClient } from "../clients/tool-client"; +import { createToolClient } from "../clients/factory"; +import { + HALL_RUNTIME_EXECUTION_CHAIN_ENABLED, + HALL_RUNTIME_EXECUTION_MAX_TURNS, +} from "../config"; +import { + appendChatMessage, + createChatRoom, + deleteChatRoom, + getChatRoom, + getChatRoomByTask, + loadChatRoomStore, +} from "./chat-store"; +import { + acquireHallExecutionLock, + assertHallExecutionAllowed, + releaseHallExecutionLock, +} from "./hall-execution-lock"; +import { buildStructuredHandoffPacket, summarizeStructuredHandoff, type CreateStructuredHandoffInput } from "./hall-handoff"; +import { resolveHallMentionTargets } from "./hall-mention-router"; +import { pickPrimaryParticipantByRole, resolveHallParticipantsFromRoster } from "./hall-role-resolver"; +import { + abortHallDraftReply, + abortHallDraftRepliesForTask, + beginHallDraftReply, + completeHallDraftReply, + isHallDraftCanceled, + streamHallDraftReply, +} from "./collaboration-stream"; +import { inferHallDiscussionDomainFromText, type HallDiscussionDomain } from "./hall-discussion-domain"; +import { + buildDiscussionParticipantQueue, + closeDiscussionCycle, + coerceTaskStage, + markDiscussionSpeakerComplete, + openDiscussionCycle, + resolveDefaultSpeakerForStage, + resolveNextDiscussionSpeaker, +} from "./hall-speaker-policy"; +import { + DEFAULT_COLLABORATION_HALL_ID, + CollaborationHallStoreValidationError, + archiveHallTaskCard, + appendHallMessage, + createHallTaskCard, + deleteHallMessagesForTaskCard, + deleteHallTaskCard, + ensureDefaultCollaborationHall, + getHallTaskCard, + getHallTaskCardByTask, + listHallMessages, + listHallTaskCards, + loadCollaborationHallMessageStore, + loadCollaborationHallStore, + loadCollaborationTaskCardStore, + saveCollaborationHallMessageStore, + saveCollaborationHallStore, + updateHallTaskCard, +} from "./collaboration-hall-store"; +import { + buildCollaborationHallSummary, + buildHallTaskSummary, + upsertCollaborationHallSummary, + upsertHallTaskSummary, +} from "./collaboration-hall-summary-store"; +import { loadBestEffortAgentRoster } from "./agent-roster"; +import { + canDispatchHallToRuntime, + dispatchHallRuntimeTurn, + type HallRuntimeChainDirective, + type HallRuntimeDispatchResult, +} from "./hall-runtime-dispatch"; +import { appendOperationAudit } from "./operation-audit"; +import { loadProjectStore, saveProjectStore } from "./project-store"; +import { readRoomDetail, recordRoomHandoff, submitRoomReview } from "./room-orchestrator"; +import { createTask, deleteTask, loadTaskStore, patchTask } from "./task-store"; +import { publishTaskRoomBridgeEvent } from "./task-room-bridge"; +import type { + ChatMessage, + CollaborationHall, + CollaborationHallSummary, + HallExecutionItem, + HallMessage, + HallParticipant, + HallSemanticRole, + HallTaskCard, + HallTaskSummary, + MessageKind, + ProjectTask, + RoomParticipantRole, + StructuredHandoffPacket, + TaskArtifact, + TaskState, +} from "../types"; + +export const DEFAULT_COLLABORATION_HALL_PROJECT_ID = "collaboration-hall"; + +type HallOperatorIntent = "greeting" | "light_chat" | "discussion_request" | "task_request"; +type HallResponseLanguage = "zh" | "en"; + +export interface HallReadResult { + hall: CollaborationHall; + hallSummary: CollaborationHallSummary; + participants: HallParticipant[]; + messages: HallMessage[]; + taskCards: HallTaskCard[]; + taskSummaries: HallTaskSummary[]; +} + +export interface HallTaskDetailResult { + hall: CollaborationHall; + hallSummary: CollaborationHallSummary; + taskCard: HallTaskCard; + taskSummary: HallTaskSummary; + task?: ProjectTask; + messages: HallMessage[]; +} + +export interface CreateHallTaskInput { + hallId?: string; + projectId?: string; + taskId?: string; + title?: string; + content: string; + authorParticipantId?: string; + authorLabel?: string; +} + +export interface HallMessageInput { + hallId?: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + content: string; + authorParticipantId?: string; + authorLabel?: string; +} + +export interface HallMutationResult { + hall: CollaborationHall; + hallSummary: CollaborationHallSummary; + taskCard?: HallTaskCard; + taskSummary?: HallTaskSummary; + task?: ProjectTask; + roomId?: string; + message?: HallMessage; + generatedMessages: HallMessage[]; +} + +export interface AssignHallTaskInput { + taskCardId: string; + ownerParticipantId?: string; + note?: string; +} + +export interface SetHallExecutionOrderInput { + taskCardId: string; + participantIds: string[]; + executionItems?: HallExecutionItem[]; + note?: string; +} + +export interface ReviewHallTaskInput { + taskCardId: string; + outcome: "approved" | "rejected"; + note?: string; + blockTask?: boolean; +} + +export interface StopHallTaskInput { + taskCardId: string; + note?: string; +} + +export interface HallHandoffInput { + taskCardId: string; + fromParticipantId: string; + toParticipantId: string; + handoff: CreateStructuredHandoffInput; +} + +export interface ArchiveHallTaskInput { + taskCardId: string; + archivedByParticipantId?: string; + archivedByLabel?: string; +} + +export interface DeleteHallTaskInput { + taskCardId: string; +} + +export interface HallOrchestratorRuntimeOptions { + toolClient?: ToolClient; + skipDiscussion?: boolean; +} + +const pendingHallBackgroundWork = new Set>(); + +export async function waitForHallBackgroundWork(): Promise { + const pending = [...pendingHallBackgroundWork]; + if (pending.length === 0) return; + await Promise.allSettled(pending); +} + +export async function readCollaborationHall(hallId = DEFAULT_COLLABORATION_HALL_ID): Promise { + const hall = await requireHall(hallId); + const [messageStore, taskCardStore] = await Promise.all([ + loadCollaborationHallMessageStore(), + loadCollaborationTaskCardStore(), + ]); + const allTaskCards = listHallTaskCards(taskCardStore, { hallId, includeArchived: true }); + const taskCards = allTaskCards.filter((card) => !card.archivedAt); + const visibleTaskCardIds = new Set(taskCards.map((card) => card.taskCardId)); + const messages = await reconcileHallMessages(hallId, messageStore, allTaskCards); + const visibleMessages = messages.filter((message) => !message.taskCardId || visibleTaskCardIds.has(message.taskCardId)); + const reconciledHall = await reconcileHallState(hall, messages, taskCards); + const hallSummary = buildCollaborationHallSummary(reconciledHall, visibleMessages, taskCards); + const taskSummaries = taskCards.map((card) => buildHallTaskSummary(card, messages)); + return { + hall: reconciledHall, + hallSummary, + participants: reconciledHall.participants, + messages, + taskCards, + taskSummaries, + }; +} + +async function reconcileHallMessages( + hallId: string, + messageStore: Awaited>, + taskCards: HallTaskCard[], +): Promise { + const liveTaskCardIds = new Set(taskCards.map((taskCard) => taskCard.taskCardId)); + const orphanedMessageIds = new Set( + messageStore.messages + .filter((message) => message.hallId === hallId) + .filter((message) => Boolean(message.taskCardId) && !liveTaskCardIds.has(message.taskCardId as string)) + .map((message) => message.messageId), + ); + if (orphanedMessageIds.size > 0) { + messageStore.messages = messageStore.messages.filter((message) => !orphanedMessageIds.has(message.messageId)); + messageStore.updatedAt = new Date().toISOString(); + await saveCollaborationHallMessageStore(messageStore); + } + return listHallMessages(messageStore, { hallId }); +} + +async function reconcileHallState( + hall: CollaborationHall, + messages: HallMessage[], + taskCards: HallTaskCard[], +): Promise { + const nextMessageIds = messages.map((message) => message.messageId); + const nextTaskCardIds = taskCards.map((taskCard) => taskCard.taskCardId); + const nextLastMessageId = nextMessageIds.at(-1) ?? null; + const nextLatestMessageAt = messages.at(-1)?.createdAt ?? hall.latestMessageAt; + const sameMessageIds = + hall.messageIds.length === nextMessageIds.length + && hall.messageIds.every((messageId, index) => messageId === nextMessageIds[index]); + const sameTaskCardIds = + hall.taskCardIds.length === nextTaskCardIds.length + && hall.taskCardIds.every((taskCardId, index) => taskCardId === nextTaskCardIds[index]); + const unchanged = + sameMessageIds + && sameTaskCardIds + && hall.lastMessageId === nextLastMessageId + && hall.latestMessageAt === nextLatestMessageAt; + if (unchanged) return hall; + + const nextHall: CollaborationHall = { + ...hall, + messageIds: nextMessageIds, + taskCardIds: nextTaskCardIds, + lastMessageId: nextLastMessageId, + latestMessageAt: nextLatestMessageAt, + updatedAt: new Date().toISOString(), + }; + const hallStore = await loadCollaborationHallStore(); + const hallIndex = hallStore.halls.findIndex((item) => item.hallId === hall.hallId); + if (hallIndex >= 0) { + hallStore.halls[hallIndex] = nextHall; + hallStore.updatedAt = nextHall.updatedAt; + await saveCollaborationHallStore(hallStore); + } + return nextHall; +} + +export async function readCollaborationHallTaskDetail( + taskCardId: string, + options: HallOrchestratorRuntimeOptions = {}, +): Promise { + const { hall, hallSummary, messages } = await readCollaborationHall(); + const taskCardStore = await loadCollaborationTaskCardStore(); + const taskCard = getHallTaskCard(taskCardStore, taskCardId); + if (!taskCard) { + throw new CollaborationHallStoreValidationError(`task card '${taskCardId}' was not found.`, ["taskCardId"], 404); + } + const taskStore = await loadTaskStore(); + const task = taskStore.tasks.find((item) => item.projectId === taskCard.projectId && item.taskId === taskCard.taskId); + const detailMessages = await buildHallTaskDetailMessages(taskCard, messages, hall.participants); + const taskSummary = buildHallTaskSummary(taskCard, detailMessages); + return { + hall, + hallSummary, + taskCard, + taskSummary, + task, + messages: detailMessages, + }; +} + +async function buildHallTaskDetailMessages( + taskCard: HallTaskCard, + hallMessages: HallMessage[], + participants: HallParticipant[], +): Promise { + const scopedHallMessages = hallMessages.filter( + (message) => + (message.taskCardId === taskCard.taskCardId || message.taskId === taskCard.taskId) + && shouldDisplayHallTimelineMessage(message), + ); + if (!taskCard.roomId) return scopedHallMessages; + try { + const linkedRoomDetail = await readRoomDetail(taskCard.roomId); + const roomMessages = linkedRoomDetail.messages + .filter((message) => shouldMergeLinkedRoomMessage(message)) + .map((message) => mapRoomMessageToHallMessage(taskCard, participants, message)); + return mergeHallTaskMessages(scopedHallMessages, roomMessages); + } catch { + return scopedHallMessages; + } +} + +function shouldDisplayHallTimelineMessage(message: Pick): boolean { + if (message.kind !== "handoff") return true; + return !isLegacyLinkedRoomHandoffMessage(message.content); +} + +function shouldMergeLinkedRoomMessage(message: ChatMessage): boolean { + if (message.kind !== "handoff") return true; + return !isLegacyLinkedRoomHandoffMessage(message.content); +} + +function isLegacyLinkedRoomHandoffMessage(content: string): boolean { + const normalized = content.trim(); + return /^(Operator|Planner|Coder|Reviewer|Manager) handed the room to (Operator|Planner|Coder|Reviewer|Manager)\.$/.test(normalized); +} + +function mapRoomMessageToHallMessage( + taskCard: HallTaskCard, + participants: HallParticipant[], + message: ChatMessage, +): HallMessage { + const participant = resolveHallParticipantForRoomMessage(participants, message); + const mentionTargets = mapRoomMentionsToHallMentionTargets(participants, message.mentions); + return { + hallId: taskCard.hallId, + messageId: `linked-room:${message.messageId}`, + kind: mapRoomKindToHallKind(message.kind), + authorParticipantId: participant?.participantId ?? fallbackHallParticipantIdForRoomMessage(message), + authorLabel: participant?.displayName ?? message.authorLabel, + authorSemanticRole: participant?.semanticRole ?? mapRoomRoleToHallSemanticRole(message.authorRole), + content: message.content, + targetParticipantIds: mentionTargets.map((item) => item.participantId), + mentionTargets, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: { + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + proposal: message.payload?.proposal, + decision: message.payload?.decision, + doneWhen: message.payload?.doneWhen, + reviewOutcome: message.payload?.reviewOutcome, + taskStatus: message.payload?.taskStatus, + status: message.payload?.status, + sessionKey: message.payload?.sessionKey, + sourceSessionKey: message.payload?.sourceSessionKey, + sourceTool: message.payload?.sourceTool, + }, + createdAt: message.createdAt, + }; +} + +function mapRoomKindToHallKind(kind: MessageKind): HallMessage["kind"] { + switch (kind) { + case "proposal": + case "decision": + case "handoff": + case "status": + case "result": + return kind; + default: + return "chat"; + } +} + +function mapRoomRoleToHallSemanticRole(role: RoomParticipantRole): HallSemanticRole { + switch (role) { + case "planner": + return "planner"; + case "reviewer": + return "reviewer"; + case "manager": + return "manager"; + case "human": + return "generalist"; + default: + return "coder"; + } +} + +function resolveHallParticipantForRoomMessage( + participants: HallParticipant[], + message: ChatMessage, +): HallParticipant | undefined { + const normalizedAuthor = message.authorLabel.trim().toLowerCase(); + if (normalizedAuthor.length > 0) { + const byName = participants.find((participant) => { + if (participant.displayName.trim().toLowerCase() === normalizedAuthor) return true; + return participant.aliases.some((alias) => alias.trim().toLowerCase() === normalizedAuthor); + }); + if (byName) return byName; + } + if (message.authorRole === "human") { + return participants.find((participant) => participant.isHuman); + } + const semanticRole = mapRoomRoleToHallSemanticRole(message.authorRole); + return participants.find((participant) => participant.active !== false && participant.semanticRole === semanticRole); +} + +function fallbackHallParticipantIdForRoomMessage(message: ChatMessage): string { + if (message.authorRole === "human") return "operator"; + const author = message.authorLabel.trim().toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, ""); + return author ? `linked-room:${author}` : `linked-room:${message.authorRole}`; +} + +function mapRoomMentionsToHallMentionTargets( + participants: HallParticipant[], + mentions: RoomParticipantRole[], +) { + return mentions + .map((role) => { + if (role === "human") { + const human = participants.find((participant) => participant.isHuman); + return human + ? { + raw: "@operator", + participantId: human.participantId, + displayName: human.displayName, + semanticRole: human.semanticRole, + } + : undefined; + } + const semanticRole = mapRoomRoleToHallSemanticRole(role); + const participant = participants.find((item) => item.active !== false && item.semanticRole === semanticRole); + return participant + ? { + raw: `@${participant.displayName}`, + participantId: participant.participantId, + displayName: participant.displayName, + semanticRole: participant.semanticRole, + } + : undefined; + }) + .filter((item): item is HallMessage["mentionTargets"][number] => Boolean(item)); +} + +function mergeHallTaskMessages(primary: HallMessage[], secondary: HallMessage[]): HallMessage[] { + const merged = [...primary]; + for (const message of secondary) { + if (merged.some((existing) => areEquivalentHallMessages(existing, message))) continue; + merged.push(message); + } + return merged.sort(compareHallTimelineMessages); +} + +function areEquivalentHallMessages(a: HallMessage, b: HallMessage): boolean { + if (a.kind !== b.kind) return false; + if ((a.taskCardId ?? "") !== (b.taskCardId ?? "")) return false; + if (normalizeHallAuthorLabel(a.authorLabel) !== normalizeHallAuthorLabel(b.authorLabel)) return false; + if (normalizeHallMessageContent(a.content) !== normalizeHallMessageContent(b.content)) return false; + const delta = Math.abs(Date.parse(a.createdAt || "") - Date.parse(b.createdAt || "")); + return delta <= 5_000; +} + +function normalizeHallAuthorLabel(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeHallMessageContent(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function compareHallTimelineMessages(left: HallMessage, right: HallMessage): number { + const leftTime = Date.parse(left.createdAt || ""); + const rightTime = Date.parse(right.createdAt || ""); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return left.messageId.localeCompare(right.messageId); +} + +function shouldRouteOperatorMessageBackToDiscussion( + taskCard: HallTaskCard, + content: string, + mentionTargets: { participantId: string }[], +): boolean { + if (taskCard.stage === "discussion" || taskCard.stage === "blocked") return false; + const intent = classifyHallOperatorIntent(content); + if (requestsDiscussionContinuation(content)) return true; + if (requestsExecutionContinuation(content)) return false; + if (mentionTargets.length > 0) return true; + return intent === "discussion_request" && /[??]/.test(content); +} + +function matchesExplicitHallMentionForParticipant( + content: string, + participant: HallParticipant | undefined, +): boolean { + if (!participant) return false; + const candidates = [ + participant.participantId, + participant.displayName, + ...(Array.isArray(participant.aliases) ? participant.aliases : []), + ] + .map((value) => String(value || "").trim()) + .filter(Boolean); + if (candidates.length === 0) return false; + return candidates.some((candidate) => { + const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s(])@${escaped}(?=$|[\\s),.!?;:])`, "i").test(content); + }); +} + +async function reopenHallTaskToDiscussion( + taskCard: HallTaskCard, + hall: CollaborationHall, + releaseReason: string, +): Promise { + const reopened = releaseHallExecutionLock(taskCard, releaseReason); + const currentParticipantId = reopened.currentExecutionItem?.participantId?.trim() + || reopened.currentOwnerParticipantId?.trim() + || ""; + const nextExecutionOrder = sanitizeExecutionOrder( + hall.participants, + [ + currentParticipantId, + ...reopened.plannedExecutionOrder, + ].filter(Boolean), + ); + const nextExecutionItems = deriveExecutionItemsFromOrder( + hall.participants, + nextExecutionOrder, + reopened, + { + existingItems: [ + ...(reopened.currentExecutionItem ? [reopened.currentExecutionItem] : []), + ...reopened.plannedExecutionItems, + ], + primaryDoneWhen: reopened.doneWhen, + }, + ); + return ( + await updateHallTaskCard({ + taskCardId: reopened.taskCardId, + stage: "discussion", + status: "todo", + currentOwnerParticipantId: null, + currentOwnerLabel: null, + currentExecutionItem: null, + executionLock: reopened.executionLock, + plannedExecutionOrder: nextExecutionOrder, + plannedExecutionItems: nextExecutionItems, + }) + ).taskCard; +} + +export async function createHallTaskFromOperatorRequest( + input: CreateHallTaskInput, + options: HallOrchestratorRuntimeOptions = {}, +): Promise { + const context = await ensureHallContext(input.hallId); + const authorParticipantId = input.authorParticipantId?.trim() || "operator"; + const authorLabel = input.authorLabel?.trim() || "Operator"; + const projectId = normalizeTaskKey(input.projectId) || DEFAULT_COLLABORATION_HALL_PROJECT_ID; + const taskId = normalizeTaskKey(input.taskId) || buildTaskId(input.title ?? input.content); + const title = deriveTaskTitle(input.title ?? input.content); + const description = input.content.trim(); + + await ensureHallProject(projectId); + + const createdTask = await createTask({ + projectId, + taskId, + title, + status: "todo", + owner: authorLabel, + definitionOfDone: [], + sessionKeys: [], + }); + + const roomStore = await loadChatRoomStore(); + const existingRoom = getChatRoomByTask(roomStore, projectId, taskId); + const room = existingRoom ?? ( + await createChatRoom({ + projectId, + taskId, + title, + }) + ).room; + const patchedTask = await patchTask({ + taskId, + projectId, + roomId: room.roomId, + }); + + let taskCard = ( + await createHallTaskCard({ + hallId: context.hall.hallId, + projectId, + taskId, + roomId: room.roomId, + title, + description, + createdByParticipantId: authorParticipantId, + currentOwnerParticipantId: undefined, + currentOwnerLabel: undefined, + mentionedParticipantIds: [], + blockers: [], + requiresInputFrom: [], + sessionKeys: [], + }) + ).taskCard; + taskCard = openDiscussionCycle( + taskCard, + authorParticipantId, + context.hall.participants, + buildDynamicDiscussionParticipantQueue(context.hall, taskCard, patchedTask.task, input.content), + ); + taskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + discussionCycle: taskCard.discussionCycle, + stage: "discussion", + roomId: room.roomId, + }) + ).taskCard; + + const initialMessage = ( + await appendHallMessage({ + hallId: context.hall.hallId, + kind: "task", + authorParticipantId, + authorLabel, + content: description, + projectId, + taskId, + taskCardId: taskCard.taskCardId, + roomId: room.roomId, + payload: { + projectId, + taskId, + taskCardId: taskCard.taskCardId, + roomId: room.roomId, + taskStage: taskCard.stage, + taskStatus: patchedTask.task.status, + }, + }) + ).message; + const createdRoom = await requireLinkedRoom(room.roomId); + await publishTaskRoomBridgeEvent({ + type: "room_created", + room: createdRoom, + task: patchedTask.task, + note: "Room auto-created from collaboration hall task creation.", + }); + + await appendOperationAudit({ + action: "hall_task_create", + source: "api", + ok: true, + detail: `created hall task ${projectId}:${taskId}`, + metadata: { + taskCardId: taskCard.taskCardId, + roomId: room.roomId, + }, + }); + + const hallRead = await readCollaborationHall(context.hall.hallId); + const taskDetail = await readCollaborationHallTaskDetail(taskCard.taskCardId); + + if (!options.skipDiscussion) { + scheduleHallDiscussion( + taskCard.taskCardId, + { + triggerMessage: initialMessage, + toolClient: options.toolClient, + }, + ); + } + + return { + hall: hallRead.hall, + hallSummary: hallRead.hallSummary, + taskCard: taskDetail.taskCard, + taskSummary: taskDetail.taskSummary, + task: patchedTask.task, + roomId: room.roomId, + message: initialMessage, + generatedMessages: [], + }; +} + +export async function postHallMessage( + input: HallMessageInput, + options: HallOrchestratorRuntimeOptions = {}, +): Promise { + const context = await ensureHallContext(input.hallId); + const authorParticipantId = input.authorParticipantId?.trim() || "operator"; + const authorLabel = input.authorLabel?.trim() || "Operator"; + const normalizedContent = input.content.trim(); + const taskCard = input.taskCardId + ? await requireTaskCard(input.taskCardId) + : input.projectId && input.taskId + ? await requireTaskCardByProjectTask(input.projectId, input.taskId) + : undefined; + const mentionRouting = resolveHallMentionTargets(input.content, context.hall.participants); + const hasDirectedMention = mentionRouting.targets.length > 0 && !mentionRouting.broadcastAll; + const defaultSpeaker = resolveDefaultSpeakerForStage(taskCard, context.hall.participants); + const targetParticipantIds = mentionRouting.broadcastAll + ? buildDiscussionParticipantQueue(context.hall.participants) + : mentionRouting.targets.length > 0 + ? mentionRouting.targets.map((target) => target.participantId) + : defaultSpeaker + ? [defaultSpeaker] + : []; + + if (!taskCard && authorParticipantId === "operator" && shouldPromoteHallMessageToTask(input.content, hasDirectedMention)) { + return createHallTaskFromOperatorRequest({ + hallId: context.hall.hallId, + content: input.content, + authorParticipantId, + authorLabel, + }, options); + } + + if (taskCard && authorParticipantId === "operator" && normalizedContent && mentionRouting.targets.length === 0) { + const recentMessages = await loadRecentHallThreadMessages(taskCard, 6); + const duplicateMessage = [...recentMessages] + .reverse() + .find((message) => { + if (message.authorParticipantId !== "operator") return false; + if (message.content.trim() !== normalizedContent) return false; + const ageMs = Date.now() - Date.parse(message.createdAt || ""); + return Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= 30_000; + }); + if (duplicateMessage) { + const refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + const taskStore = await loadTaskStore(); + const task = taskStore.tasks.find((item) => item.projectId === taskCard.projectId && item.taskId === taskCard.taskId); + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + task, + roomId: taskCard.roomId, + message: duplicateMessage, + generatedMessages: [], + }; + } + } + + if (taskCard?.stage === "execution" && authorParticipantId !== "operator") { + assertHallExecutionAllowed(taskCard, authorParticipantId); + } + + let nextTaskCard = taskCard; + let openedImplicitDiscussionCycle = false; + if (nextTaskCard && authorParticipantId === "operator" && shouldRouteOperatorMessageBackToDiscussion(nextTaskCard, input.content, mentionRouting.targets)) { + nextTaskCard = await reopenHallTaskToDiscussion(nextTaskCard, context.hall, "discussion_reopened"); + } + + if (nextTaskCard && authorParticipantId === "operator" && nextTaskCard.stage === "discussion" && mentionRouting.targets.length === 0) { + nextTaskCard = openDiscussionCycle( + nextTaskCard, + authorParticipantId, + context.hall.participants, + buildDynamicDiscussionParticipantQueue(context.hall, nextTaskCard, undefined, input.content), + ); + nextTaskCard = (await updateHallTaskCard({ + taskCardId: nextTaskCard.taskCardId, + discussionCycle: nextTaskCard.discussionCycle, + stage: "discussion", + })).taskCard; + openedImplicitDiscussionCycle = true; + } + + const message = ( + await appendHallMessage({ + hallId: context.hall.hallId, + kind: "chat", + authorParticipantId, + authorLabel, + content: normalizedContent, + targetParticipantIds, + mentionTargets: mentionRouting.targets, + projectId: nextTaskCard?.projectId, + taskId: nextTaskCard?.taskId, + taskCardId: nextTaskCard?.taskCardId, + roomId: nextTaskCard?.roomId, + payload: nextTaskCard + ? { + projectId: nextTaskCard.projectId, + taskId: nextTaskCard.taskId, + taskCardId: nextTaskCard.taskCardId, + roomId: nextTaskCard.roomId, + taskStage: nextTaskCard.stage, + taskStatus: nextTaskCard.status, + } + : undefined, + }) + ).message; + + if ( + openedImplicitDiscussionCycle + && nextTaskCard?.discussionCycle + && message.taskCardId === nextTaskCard.taskCardId + && Date.parse(nextTaskCard.discussionCycle.openedAt) < Date.parse(message.createdAt) + ) { + nextTaskCard = ( + await updateHallTaskCard({ + taskCardId: nextTaskCard.taskCardId, + discussionCycle: { + ...nextTaskCard.discussionCycle, + openedAt: message.createdAt, + }, + stage: "discussion", + }) + ).taskCard; + } + + if (nextTaskCard && authorParticipantId === "operator" && nextTaskCard.stage === "blocked") { + const resumeOwnerParticipantId = nextTaskCard.currentOwnerParticipantId ?? nextTaskCard.plannedExecutionOrder[0]; + const resumeOwnerExplicitlyMentioned = matchesExplicitHallMentionForParticipant( + input.content, + resumeOwnerParticipantId ? findParticipant(context.hall.participants, resumeOwnerParticipantId) : undefined, + ); + if (resumeOwnerParticipantId && (mentionRouting.targets.length === 0 || resumeOwnerExplicitlyMentioned)) { + const resumed = await assignHallTaskExecution({ + taskCardId: nextTaskCard.taskCardId, + ownerParticipantId: resumeOwnerParticipantId, + note: input.content.trim(), + }, options); + return { + hall: resumed.hall, + hallSummary: resumed.hallSummary, + taskCard: resumed.taskCard, + taskSummary: resumed.taskSummary, + task: resumed.task, + roomId: resumed.roomId, + message, + generatedMessages: resumed.generatedMessages, + }; + } + + if (mentionRouting.targets.length === 0) { + nextTaskCard = openDiscussionCycle( + nextTaskCard, + authorParticipantId, + context.hall.participants, + buildDynamicDiscussionParticipantQueue(context.hall, nextTaskCard, undefined, input.content), + ); + nextTaskCard = ( + await updateHallTaskCard({ + taskCardId: nextTaskCard.taskCardId, + discussionCycle: nextTaskCard.discussionCycle, + stage: "discussion", + status: "todo", + }) + ).taskCard; + } + } + + if (!nextTaskCard) { + if (authorParticipantId === "operator") { + const generatedMessages: HallMessage[] = []; + const lobbyTargets = resolveLobbyParticipants(context.hall.participants, targetParticipantIds); + for (const participant of lobbyTargets) { + generatedMessages.push(await appendLobbyHallReply({ + hall: context.hall, + participant, + triggerMessage: message, + })); + } + const hallRead = await readCollaborationHall(context.hall.hallId); + return { + hall: hallRead.hall, + hallSummary: hallRead.hallSummary, + message, + generatedMessages, + }; + } + const hallRead = await readCollaborationHall(context.hall.hallId); + return { + hall: hallRead.hall, + hallSummary: hallRead.hallSummary, + message, + generatedMessages: [], + }; + } + + const taskStore = await loadTaskStore(); + const task = taskStore.tasks.find((item) => item.projectId === nextTaskCard?.projectId && item.taskId === nextTaskCard?.taskId); + if (nextTaskCard.roomId) { + const linkedRoom = await requireLinkedRoom(nextTaskCard.roomId); + await publishTaskRoomBridgeEvent({ + type: "message_posted", + room: linkedRoom, + task, + note: "Hall message mirrored to linked task context.", + }); + } + + const hallRead = await readCollaborationHall(context.hall.hallId); + const taskDetail = await readCollaborationHallTaskDetail(nextTaskCard.taskCardId); + + scheduleHallDiscussion( + nextTaskCard.taskCardId, + { + triggerMessage: message, + explicitTargetParticipantIds: targetParticipantIds, + strictMentions: mentionRouting.targets.length > 0 && !mentionRouting.broadcastAll, + toolClient: options.toolClient, + }, + ); + + return { + hall: hallRead.hall, + hallSummary: hallRead.hallSummary, + taskCard: taskDetail.taskCard, + taskSummary: taskDetail.taskSummary, + task, + roomId: nextTaskCard.roomId, + message, + generatedMessages: [], + }; +} + +function shouldPromoteHallMessageToTask(content: string, hasExplicitMention: boolean): boolean { + if (hasExplicitMention) return false; + const intent = classifyHallOperatorIntent(content); + return intent === "discussion_request" || intent === "task_request"; +} + +function classifyHallOperatorIntent(content: string): HallOperatorIntent { + const trimmed = content.trim(); + if (!trimmed) return "light_chat"; + if (isHallGreetingOnly(trimmed)) return "greeting"; + + const normalized = trimmed.toLowerCase(); + const strongTaskSignal = [ + /\b(build|fix|implement|create|design|plan|make|ship|debug|investigate|review|prototype|brainstorm|research|analyze|animate|visuali[sz]e)\b/i, + /(帮我|请帮|请你|麻烦|需要|我想|我想要|我想做|希望|制作|做一个|设计|策划|规划|分析|研究|实现|修|新增|创建|检查|排查|审核|产出|整理|写一个|准备|生成|可视化|动画|方案|创意|策略|发布|故事板|脚本)/, + ].some((pattern) => pattern.test(trimmed)); + if (strongTaskSignal) return "task_request"; + + const discussionSignal = [ + /[??]/, + /\b(how|what|why|which|should|could|can|ideas?|advice|approach|direction|options?)\b/i, + /(如何|怎么|为什么|是否|要不要|应该|可以怎么|思路|建议|方向|想法|比较|评估|怎么做|做什么)/, + ].some((pattern) => pattern.test(trimmed)); + if (discussionSignal) return "discussion_request"; + + if (normalized.length >= 18) return "discussion_request"; + if (/[,。!?,.!?]/.test(trimmed) && trimmed.length >= 12) return "discussion_request"; + return "light_chat"; +} + +function isHallGreetingOnly(content: string): boolean { + const normalized = content.trim().toLowerCase(); + return [ + "hi", + "hello", + "hey", + "yo", + "hola", + "你好", + "您好", + "嗨", + "在吗", + "有人吗", + ].includes(normalized); +} + +function resolveLobbyParticipants( + participants: HallParticipant[], + targetParticipantIds: string[], +): HallParticipant[] { + const uniqueTargets = [...new Set(targetParticipantIds.filter(Boolean))]; + if (uniqueTargets.length > 0) { + return uniqueTargets + .map((participantId) => findParticipant(participants, participantId)) + .filter((participant): participant is HallParticipant => Boolean(participant)); + } + const defaultParticipant = pickPrimaryParticipantByRole(participants, "planner") + ?? pickPrimaryParticipantByRole(participants, "manager") + ?? participants[0]; + return defaultParticipant ? [defaultParticipant] : []; +} + +async function appendLobbyHallReply(input: { + hall: CollaborationHall; + participant: HallParticipant; + triggerMessage: HallMessage; +}): Promise { + const content = buildLobbyHallReply(input.participant, input.triggerMessage.content); + const draftId = await streamHallDraftReply({ + hallId: input.hall.hallId, + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + messageKind: "chat", + content, + }); + const message = ( + await appendHallMessage({ + hallId: input.hall.hallId, + kind: "chat", + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + content, + targetParticipantIds: [], + payload: { + status: "hall_lobby_reply", + }, + }) + ).message; + completeHallDraftReply({ + hallId: input.hall.hallId, + draftId, + messageId: message.messageId, + content, + }); + return message; +} + +function buildLobbyHallReply(participant: HallParticipant, rawContent: string): string { + const language = inferHallResponseLanguage(rawContent); + const intent = classifyHallOperatorIntent(rawContent); + if (isHallGreetingOnly(rawContent)) { + if (participant.semanticRole === "planner") { + return language === "zh" + ? `${participant.displayName} 在。你可以直接描述想完成的任务、限制和 done_when;如果只是想点名某个人,也可以直接 @ 他。` + : `${participant.displayName} is here. You can describe the task, constraints, and done_when directly, or @ a specific agent if you want to address someone.`; + } + return language === "zh" + ? `${participant.displayName} 在。你可以先说清楚任务目标,或者直接 @ 某个 agent 开始对话。` + : `${participant.displayName} is here. You can clarify the goal first, or start by @mentioning a specific agent.`; + } + if (intent === "discussion_request" || intent === "task_request") { + if (participant.semanticRole === "planner") { + return language === "zh" + ? `${participant.displayName} 收到。我会先把这件事收敛成一条可讨论的任务线程,然后拉相关 agent 一起讨论目标、限制、风险和执行顺序。` + : `${participant.displayName} got it. I will first turn this into a discussable task thread, then bring the relevant agents in to discuss goals, constraints, risks, and execution order.`; + } + if (participant.semanticRole === "manager") { + return language === "zh" + ? `${participant.displayName} 收到。我们会先在大厅里展开讨论,再由你来决定谁先执行、谁后执行。` + : `${participant.displayName} got it. We will discuss it in the hall first, then you can decide who should execute first and who should follow.`; + } + } + if (participant.semanticRole === "planner") { + return language === "zh" + ? `${participant.displayName} 收到。你这条消息还没有绑定任务线程;如果这是一个新任务,我可以先帮你把目标、限制和完成标准收敛成第一张线程卡。` + : `${participant.displayName} got it. This message is not attached to a task thread yet; if this is a new task, I can first help turn the goal, constraints, and definition of done into the first thread card.`; + } + if (participant.semanticRole === "manager") { + return language === "zh" + ? `${participant.displayName} 收到。先在大厅里把任务目标说清楚,我们再决定由谁执行。` + : `${participant.displayName} got it. Let us clarify the task goal in the hall first, then decide who should execute it.`; + } + return language === "zh" + ? `${participant.displayName} 收到了你的大厅消息。如果这是一个新任务,请先说明目标和完成标准;如果是定向问题,也可以继续直接 @ 我。` + : `${participant.displayName} received your hall message. If this is a new task, please explain the goal and definition of done first; if it is a targeted question, you can keep @mentioning me directly.`; +} + +function sanitizeExecutionOrder( + participants: HallParticipant[], + participantIds: string[], + options: { excludeParticipantId?: string } = {}, +): string[] { + const exclude = options.excludeParticipantId?.trim(); + const seen = new Set(); + const ordered: string[] = []; + for (const rawId of participantIds) { + const participantId = rawId.trim(); + if (!participantId) continue; + if (exclude && participantId === exclude) continue; + if (seen.has(participantId)) continue; + if (participants.length > 0 && !findParticipant(participants, participantId)) continue; + seen.add(participantId); + ordered.push(participantId); + } + return ordered; +} + +function sanitizeExecutionItems( + participants: HallParticipant[], + items: HallExecutionItem[] | undefined, + options: { excludeParticipantId?: string } = {}, +): HallExecutionItem[] { + if (!Array.isArray(items)) return []; + const exclude = options.excludeParticipantId?.trim(); + const seen = new Set(); + const ordered: HallExecutionItem[] = []; + for (const candidate of items) { + const participantId = candidate?.participantId?.trim(); + const task = candidate?.task?.trim(); + if (!participantId || !task) continue; + if (exclude && participantId === exclude) continue; + if (seen.has(participantId)) continue; + if (participants.length > 0 && !findParticipant(participants, participantId)) continue; + seen.add(participantId); + ordered.push({ + itemId: candidate.itemId?.trim() || randomUUID(), + participantId, + task, + handoffToParticipantId: candidate.handoffToParticipantId?.trim() || undefined, + handoffWhen: candidate.handoffWhen?.trim() || undefined, + }); + } + return ordered; +} + +function deriveExecutionItemsFromOrder( + participants: HallParticipant[], + participantIds: string[], + taskCard: HallTaskCard, + options: { existingItems?: HallExecutionItem[]; primaryDoneWhen?: string } = {}, +): HallExecutionItem[] { + const existing = new Map( + (options.existingItems || []).map((item) => [item.participantId, item] as const), + ); + const doneWhen = options.primaryDoneWhen?.trim() || taskCard.doneWhen?.trim() || undefined; + return participantIds.map((participantId, index) => { + const participant = findParticipant(participants, participantId); + const cached = existing.get(participantId); + const nextParticipantId = cached?.handoffToParticipantId && participantIds.includes(cached.handoffToParticipantId) + ? cached.handoffToParticipantId + : participantIds[index + 1]; + const nextParticipant = nextParticipantId ? findParticipant(participants, nextParticipantId) : undefined; + return { + itemId: cached?.itemId || randomUUID(), + participantId, + task: cached?.task || buildExecutionItemTask(taskCard, participant, index), + handoffToParticipantId: nextParticipant?.participantId, + handoffWhen: cached?.handoffWhen || buildExecutionItemHandoff(taskCard, participant, nextParticipant, index, doneWhen), + }; + }); +} + +function buildSuggestedExecutionPlan( + hall: CollaborationHall, + taskCard: HallTaskCard, + recommendedOwnerParticipantId: string, + task?: ProjectTask, +): { executionOrder: string[]; executionItems: HallExecutionItem[] } { + const domain = inferHallDiscussionDomain(taskCard, task); + const signals = `${taskCard.title}\n${taskCard.description}\n${taskCard.proposal ?? ""}\n${taskCard.decision ?? ""}\n${taskCard.doneWhen ?? ""}\n${taskCard.latestSummary ?? ""}`; + const mentioned = sanitizeExecutionOrder(hall.participants, taskCard.mentionedParticipantIds, { + excludeParticipantId: recommendedOwnerParticipantId, + }); + const requiresInput = sanitizeExecutionOrder(hall.participants, taskCard.requiresInputFrom, { + excludeParticipantId: recommendedOwnerParticipantId, + }); + const contributors = listRecentDiscussionParticipants(hall, taskCard, { excludeParticipantId: recommendedOwnerParticipantId }); + const discussionPool = [...new Set([...mentioned, ...requiresInput, ...contributors])]; + const order: string[] = []; + const push = (participantId: string | undefined) => { + if (!participantId) return; + if (order.includes(participantId)) return; + if (!findParticipant(hall.participants, participantId)) return; + order.push(participantId); + }; + + push(recommendedOwnerParticipantId); + + const explicitMultiStep = requiresMultiStepExecution(signals); + const explicitReview = requiresReviewFollowup(signals); + + if (explicitMultiStep || explicitReview || discussionPool.length > 0) { + const reviewer = pickPreferredExecutionFollowup(hall, taskCard, discussionPool, recommendedOwnerParticipantId, { + preferredRoles: ["reviewer"], + }); + const collaborator = pickPreferredExecutionFollowup(hall, taskCard, discussionPool, recommendedOwnerParticipantId, { + preferredRoles: followupRoleOrderForDomain(domain, recommendedOwnerParticipantId, hall.participants), + excludeParticipantIds: reviewer ? [reviewer] : [], + }); + + if (domain === "creative" && explicitMultiStep) { + push(collaborator ?? reviewer); + if (explicitReview) push(reviewer ?? collaborator); + } else if (domain === "engineering") { + if (explicitMultiStep) push(collaborator); + if (explicitReview) push(reviewer); + } else if (domain === "research" || domain === "analysis" || domain === "operations" || domain === "product") { + if (explicitReview || discussionPool.length > 0) push(reviewer ?? collaborator); + if (explicitMultiStep && collaborator && !order.includes(collaborator)) push(collaborator); + } else { + if (explicitMultiStep || explicitReview) push(reviewer ?? collaborator); + } + } + + const executionOrder = sanitizeExecutionOrder(hall.participants, order); + const existingItems = executionOrder.length > 0 + ? taskCard.plannedExecutionItems.filter((item) => executionOrder.includes(item.participantId)) + : taskCard.plannedExecutionItems; + return { + executionOrder, + executionItems: deriveExecutionItemsFromOrder(hall.participants, executionOrder, taskCard, { + existingItems, + primaryDoneWhen: task?.definitionOfDone.length ? task.definitionOfDone.join("; ") : taskCard.doneWhen, + }), + }; +} + +function shiftExecutionItemsForOwner(taskCard: HallTaskCard, ownerParticipantId: string | undefined): HallExecutionItem[] { + if (!ownerParticipantId) return taskCard.plannedExecutionItems || []; + const seen = new Set(); + return (taskCard.plannedExecutionItems || []).filter((item) => { + if (item.participantId === ownerParticipantId) return false; + if (seen.has(item.participantId)) return false; + seen.add(item.participantId); + return true; + }); +} + +function listRecentDiscussionParticipants( + hall: CollaborationHall, + taskCard: HallTaskCard, + options: { excludeParticipantId?: string } = {}, +): string[] { + const exclude = options.excludeParticipantId; + const ordered: string[] = []; + const push = (participantId: string | undefined) => { + if (!participantId || participantId === "operator" || participantId === exclude) return; + if (ordered.includes(participantId)) return; + const participant = findParticipant(hall.participants, participantId); + if (!participant || participant.semanticRole === "manager") return; + ordered.push(participantId); + }; + + for (const participantId of taskCard.discussionCycle?.completedParticipantIds || []) { + push(participantId); + } + + return ordered; +} + +function requiresMultiStepExecution(text: string): boolean { + return /(交接|handoff|接着做|然后|下一步|多阶段|多步|配合|分工|review|审核|检查|风险|验证|素材|brief|storyboard|样片|sample|first pass|feedback|handoff|next step|follow-up|multi-step|collaborat)/i.test( + text, + ); +} + +function requiresReviewFollowup(text: string): boolean { + return /(review|审核|评审|检查|风险|验证|feedback|approve|approval|sign-?off)/i.test(text); +} + +function followupRoleOrderForDomain( + domain: HallDiscussionDomain, + ownerParticipantId: string, + participants: HallParticipant[], +): HallSemanticRole[] { + const ownerRole = findParticipant(participants, ownerParticipantId)?.semanticRole; + if (domain === "creative") { + return ownerRole === "planner" ? ["coder", "reviewer", "generalist"] : ["planner", "reviewer", "generalist"]; + } + if (domain === "engineering") { + return ownerRole === "coder" ? ["reviewer", "planner", "generalist"] : ["coder", "reviewer", "generalist"]; + } + if (domain === "research" || domain === "analysis" || domain === "operations" || domain === "product") { + return ownerRole === "reviewer" ? ["planner", "generalist", "coder"] : ["reviewer", "planner", "generalist"]; + } + return ["reviewer", "planner", "coder", "generalist"]; +} + +function pickPreferredExecutionFollowup( + hall: CollaborationHall, + taskCard: HallTaskCard, + candidateIds: string[], + ownerParticipantId: string, + options: { preferredRoles: HallSemanticRole[]; excludeParticipantIds?: string[] } , +): string | undefined { + const excluded = new Set([ownerParticipantId, ...(options.excludeParticipantIds || [])]); + for (const role of options.preferredRoles) { + const match = candidateIds + .map((participantId) => findParticipant(hall.participants, participantId)) + .find((participant) => participant && !excluded.has(participant.participantId) && participant.semanticRole === role); + if (match) return match.participantId; + } + + for (const participantId of candidateIds) { + if (excluded.has(participantId)) continue; + return participantId; + } + + for (const role of options.preferredRoles) { + const participant = pickParticipantForRole(hall.participants, role); + if (participant && !excluded.has(participant.participantId)) return participant.participantId; + } + + return undefined; +} + +function buildExecutionItemTask( + taskCard: HallTaskCard, + participant: HallParticipant | undefined, + index: number, +): string { + const title = taskCard.title.trim(); + const lower = `${title} ${taskCard.description || ""}`.toLowerCase(); + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}`); + const focus = summarizeExecutionFocus(taskCard, language); + if (!participant) { + return language === "zh" + ? (index === 0 + ? `先把“${title}”的第一步做成可评审结果${focus ? `,重点是:${focus}` : "。"}` + : `承接“${title}”的下一步并把结果贴回大厅${focus ? `,重点延续:${focus}` : "。"}` + ) + : (index === 0 + ? `Take the first concrete pass on "${title}"${focus ? `, focusing on ${focus}` : ""}.` + : `Support the next step for "${title}"${focus ? `, continuing the work on ${focus}` : ""}.` + ); + } + if (participant.semanticRole === "planner") { + if (language === "zh") { + return /video|story|narrative|motion|animation|campaign/.test(lower) + ? `先把“${title}”的 brief 钉住:目标受众、核心信息、故事线和第一版样片范围${focus ? `,重点围绕:${focus}` : "。"}` + : `先把“${title}”收成一版明确 brief:范围、约束和成功标准${focus ? `,重点围绕:${focus}` : "。"}` + ; + } + return /video|story|narrative|motion|animation|campaign/.test(lower) + ? `Lock the brief for "${title}": audience, storyline, scope, and the smallest convincing first cut${focus ? `, with special attention to ${focus}` : ""}.` + : `Turn "${title}" into a clear brief with scope, constraints, and success criteria${focus ? `, centered on ${focus}` : ""}.`; + } + if (participant.semanticRole === "coder") { + if (language === "zh") { + return /video|story|narrative|motion|animation/.test(lower) + ? `为“${title}”做第一版可评审样片 / storyboard / motion sample,不直接做满${focus ? `,重点落实:${focus}` : "。"}` + : `完成“${title}”的第一版执行结果,并把产物贴回群里${focus ? `,重点落实:${focus}` : "。"}` + ; + } + return /video|story|narrative|motion|animation/.test(lower) + ? `Build the first executable or video-ready sample for "${title}" so the team can review something concrete${focus ? `, especially ${focus}` : ""}.` + : `Deliver the first implementation slice for "${title}" and leave a concrete artifact in the thread${focus ? `, focusing on ${focus}` : ""}.`; + } + if (participant.semanticRole === "reviewer") { + if (language === "zh") return `只看上一位交付的结果,指出必须改的一点;没硬 blocker 就直接交给下一位${focus ? `,重点盯:${focus}` : "。"}` + ; + return `Review the previous pass for "${title}", call out only the must-fix point, and if there is no real blocker, send it straight to the next owner${focus ? `, especially around ${focus}` : ""}.`; + } + if (participant.semanticRole === "manager") { + if (language === "zh") return `收住这轮结果,锁一句结论和下一步;后面还有 owner 就直接交棒${focus ? `,重点别漏:${focus}` : "。"}` + ; + return `Close the loop on "${title}", confirm the action items and next decision, and decide whether the chain should continue${focus ? `, making sure ${focus} is covered` : ""}.`; + } + if (language === "zh") { + return index === 0 + ? `先把“${title}”做成第一版可评审结果${focus ? `,重点是:${focus}` : "。"}` + : `承接“${title}”的下一步并继续推进${focus ? `,重点延续:${focus}` : "。"}` + ; + } + return index === 0 + ? `Take the first practical pass on "${title}" and share a reviewable result${focus ? `, focusing on ${focus}` : ""}.` + : `Pick up the next step for "${title}" and move the chain forward${focus ? `, continuing the work on ${focus}` : ""}.`; +} + +function summarizeExecutionFocus(taskCard: HallTaskCard, language: HallResponseLanguage): string | undefined { + const raw = [taskCard.decision, taskCard.proposal, taskCard.latestSummary, taskCard.description] + .map((value) => value?.trim()) + .find(Boolean); + if (!raw) return undefined; + const singleLine = raw.replace(/\s+/g, " ").trim(); + const sentence = singleLine.split(/[。!?.!?]/)[0]?.trim(); + if (!sentence) return undefined; + const stripped = sentence + .replace(new RegExp(`^${escapeRegExp(taskCard.title)}[::,,\\s-]*`, "i"), "") + .replace(/^(关于|针对|For|About)\s*/i, "") + .trim(); + if (!stripped) return undefined; + const max = language === "zh" ? 34 : 64; + return stripped.length > max ? `${stripped.slice(0, max).trim()}…` : stripped; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildExecutionItemHandoff( + taskCard: HallTaskCard, + participant: HallParticipant | undefined, + nextParticipant: HallParticipant | undefined, + index: number, + doneWhen: string | undefined, +): string | undefined { + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}`); + if (index === 0 && doneWhen) { + return nextParticipant + ? (language === "zh" + ? `做到“${doneWhen}”就把结果贴回大厅,@${nextParticipant.displayName} 接着做。` + : `When this pass reaches "${doneWhen}" or is at least reviewable, post the result in the hall and @${nextParticipant.displayName} to continue.`) + : doneWhen; + } + if (nextParticipant) { + return language === "zh" + ? `把结果贴回大厅,@${nextParticipant.displayName} 接着做。` + : `Post the result in the hall and @${nextParticipant.displayName} with what changed, where the artifact lives, what remains, and what the next step should be.`; + } + if (participant?.semanticRole === "reviewer") { + return language === "zh" + ? "当审核结论和必须修改项已经在大厅里说清楚时收尾。" + : "Close once the review verdict and required changes are explicit in the hall."; + } + return doneWhen; +} + +function findExecutionItemForParticipant( + taskCard: HallTaskCard, + participantId: string | undefined, +): HallExecutionItem | undefined { + if (!participantId) return undefined; + const currentItem = taskCard.currentExecutionItem; + if (currentItem?.participantId === participantId) return currentItem; + return taskCard.plannedExecutionItems.find((item) => item.participantId === participantId); +} + +function getCurrentExecutionItem(taskCard: HallTaskCard): HallExecutionItem | undefined { + return findExecutionItemForParticipant(taskCard, taskCard.currentOwnerParticipantId); +} + +function summarizeExecutionItemTask( + taskCard: HallTaskCard, + participantId: string | undefined, + language: HallResponseLanguage, +): string | undefined { + const item = findExecutionItemForParticipant(taskCard, participantId); + const task = item?.task?.trim(); + if (!task) return undefined; + const max = language === "zh" ? 72 : 120; + return task.length > max ? `${task.slice(0, max).trim()}…` : task; +} + +function buildBlockedExecutionSummary(taskCard: HallTaskCard, participant: HallParticipant): string { + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${taskCard.latestSummary ?? ""}`); + const taskSummary = summarizeExecutionItemTask(taskCard, participant.participantId, language); + if (language === "zh") { + return taskSummary + ? `${participant.displayName} 这一步先卡住了,缺的是“${taskSummary}”相关信息。补齐后直接继续这一棒。` + : `${participant.displayName} 这一步先卡住了,补齐信息后再继续。`; + } + return taskSummary + ? `${participant.displayName} marked the chain as blocked while working on "${taskSummary}". Once the missing input is back in the hall, continue this same step.` + : `${participant.displayName} marked the chain as blocked and is waiting for the missing input before continuing.`; +} + +function buildReadyForReviewSummary(taskCard: HallTaskCard, participant: HallParticipant): string { + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${taskCard.latestSummary ?? ""}`); + const taskSummary = summarizeExecutionItemTask(taskCard, participant.participantId, language); + if (language === "zh") { + return taskSummary + ? `${participant.displayName} 把“${taskSummary}”做到可评审了,现在请老板评审。` + : `${participant.displayName} 这一步已经可评审了,现在请老板评审。`; + } + return taskSummary + ? `${participant.displayName} moved "${taskSummary}" to a reviewable state and handed this step into review.` + : `${participant.displayName} moved the current execution step into review.`; +} + +function buildReviewSummary( + hall: CollaborationHall, + taskCard: HallTaskCard, + reviewer: HallParticipant, + outcome: "approved" | "changes_requested", + note?: string, +): string { + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${note ?? ""}`); + const owner = findParticipant(hall.participants, taskCard.currentOwnerParticipantId); + const taskSummary = summarizeExecutionItemTask(taskCard, taskCard.currentOwnerParticipantId, language); + const trimmedNote = note?.trim(); + if (language === "zh") { + if (outcome === "approved") { + return taskSummary + ? `${reviewer.displayName} 看过了,“${taskSummary}”可以过。${trimmedNote ? ` ${trimmedNote}` : ""}`.trim() + : `${reviewer.displayName} 看过了,这一轮可以过。${trimmedNote ? ` ${trimmedNote}` : ""}`.trim(); + } + const ownerMention = owner ? `@${owner.displayName}` : "当前 owner"; + return taskSummary + ? `${reviewer.displayName} 看过了,这一步还不能过。${ownerMention} 先把“${taskSummary}”改掉。${trimmedNote ? ` ${trimmedNote}` : ""}`.trim() + : `${reviewer.displayName} 看过了,这一轮还不能过。${ownerMention} 先按 review 改一轮。${trimmedNote ? ` ${trimmedNote}` : ""}`.trim(); + } + if (outcome === "approved") { + return taskSummary + ? `${reviewer.displayName} reviewed it and this pass on "${taskSummary}" is good to ship.${trimmedNote ? ` ${trimmedNote}` : ""}`.trim() + : `${reviewer.displayName} reviewed it and this pass is good to ship.${trimmedNote ? ` ${trimmedNote}` : ""}`.trim(); + } + const ownerMention = owner ? `@${owner.displayName}` : "the current owner"; + return taskSummary + ? `${reviewer.displayName} reviewed it and this pass on "${taskSummary}" still needs work. ${ownerMention}, please revise this step and bring it back.${trimmedNote ? ` ${trimmedNote}` : ""}`.trim() + : `${reviewer.displayName} reviewed it and this pass still needs work. ${ownerMention}, please revise it and bring it back.${trimmedNote ? ` ${trimmedNote}` : ""}`.trim(); +} + +function shiftExecutionQueueForOwner(taskCard: HallTaskCard, ownerParticipantId: string | undefined): string[] { + if (!ownerParticipantId) return taskCard.plannedExecutionOrder; + const seen = new Set(); + return taskCard.plannedExecutionOrder.filter((participantId) => { + if (participantId === ownerParticipantId) return false; + if (seen.has(participantId)) return false; + seen.add(participantId); + return true; + }); +} + +export async function setHallTaskExecutionOrder(input: SetHallExecutionOrderInput): Promise { + const context = await ensureHallContext(); + let taskCard = await requireTaskCard(input.taskCardId); + const hasLockedActiveExecution = taskCard.stage === "execution" || taskCard.stage === "blocked"; + const activeExecutionParticipantId = hasLockedActiveExecution + ? (taskCard.currentExecutionItem?.participantId?.trim() || taskCard.currentOwnerParticipantId?.trim() || undefined) + : undefined; + const executionItems = sanitizeExecutionItems(context.hall.participants, input.executionItems); + const executionOrder = executionItems.length > 0 + ? executionItems.map((item) => item.participantId) + : sanitizeExecutionOrder(context.hall.participants, input.participantIds); + const explicitCurrentExecutionItem = activeExecutionParticipantId + ? executionItems.find((item) => item.participantId === activeExecutionParticipantId) + : undefined; + const normalizedExecutionItems = executionItems.length > 0 + ? deriveExecutionItemsFromOrder( + context.hall.participants, + executionOrder, + taskCard, + { existingItems: executionItems, primaryDoneWhen: taskCard.doneWhen }, + ) + : deriveExecutionItemsFromOrder( + context.hall.participants, + executionOrder, + taskCard, + { existingItems: taskCard.plannedExecutionItems, primaryDoneWhen: taskCard.doneWhen }, + ); + const currentExecutionItem = activeExecutionParticipantId + ? explicitCurrentExecutionItem + ?? normalizedExecutionItems.find((item) => item.participantId === activeExecutionParticipantId) + ?? taskCard.currentExecutionItem + ?? findExecutionItemForParticipant(taskCard, activeExecutionParticipantId) + : null; + const plannedExecutionItems = activeExecutionParticipantId + ? normalizedExecutionItems.filter((item) => item.participantId !== activeExecutionParticipantId) + : normalizedExecutionItems; + const plannedExecutionOrder = activeExecutionParticipantId + ? plannedExecutionItems.map((item) => item.participantId) + : executionOrder; + const activeExecutionParticipant = activeExecutionParticipantId + ? findParticipant(context.hall.participants, activeExecutionParticipantId) + : undefined; + taskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + plannedExecutionOrder, + plannedExecutionItems, + currentExecutionItem: hasLockedActiveExecution + ? currentExecutionItem + : null, + currentOwnerParticipantId: hasLockedActiveExecution + ? (activeExecutionParticipantId ?? taskCard.currentOwnerParticipantId) + : null, + currentOwnerLabel: hasLockedActiveExecution + ? (activeExecutionParticipant?.displayName ?? taskCard.currentOwnerLabel) + : null, + latestSummary: input.note?.trim() || taskCard.latestSummary, + }) + ).taskCard; + + const generatedMessages: HallMessage[] = []; + + const refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + await appendOperationAudit({ + action: "hall_task_execution_order", + source: "api", + ok: true, + detail: `updated execution order for hall task ${taskCard.projectId}:${taskCard.taskId}`, + metadata: { + taskCardId: taskCard.taskCardId, + executionOrder, + }, + }); + + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + generatedMessages, + task: (await loadTaskStore()).tasks.find((item) => item.projectId === taskCard.projectId && item.taskId === taskCard.taskId), + roomId: taskCard.roomId, + }; +} + +export async function assignHallTaskExecution( + input: AssignHallTaskInput, + options: HallOrchestratorRuntimeOptions = {}, +): Promise { + const context = await ensureHallContext(); + let taskCard = await requireTaskCard(input.taskCardId); + abortHallDraftRepliesForTask({ + hallId: context.hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + reason: "execution_started", + }); + const ownerParticipant = + findParticipant( + context.hall.participants, + input.ownerParticipantId + ?? taskCard.plannedExecutionOrder[0] + ?? taskCard.currentOwnerParticipantId, + ) + ?? pickPrimaryParticipantByRole(context.hall.participants, "coder") + ?? context.hall.participants[0]; + if (!ownerParticipant) { + throw new CollaborationHallStoreValidationError("No hall participants are available for assignment.", [], 409); + } + + const reorderedExecutionOrder = [ + ownerParticipant.participantId, + ...taskCard.plannedExecutionOrder.filter((participantId) => participantId !== ownerParticipant.participantId), + ]; + const reorderedExecutionItems = deriveExecutionItemsFromOrder( + context.hall.participants, + reorderedExecutionOrder, + taskCard, + { + existingItems: taskCard.plannedExecutionItems, + primaryDoneWhen: taskCard.doneWhen, + }, + ); + const fallbackNextParticipant = reorderedExecutionOrder[1] + ? findParticipant(context.hall.participants, reorderedExecutionOrder[1]) + : undefined; + const ownerExecutionItem = + (taskCard.currentExecutionItem?.participantId === ownerParticipant.participantId ? taskCard.currentExecutionItem : undefined) + ?? reorderedExecutionItems.find((item) => item.participantId === ownerParticipant.participantId); + const stableOwnerExecutionItem = ownerExecutionItem + ?? findExecutionItemForParticipant(taskCard, ownerParticipant.participantId) + ?? { + itemId: randomUUID(), + participantId: ownerParticipant.participantId, + task: buildExecutionItemTask(taskCard, ownerParticipant, 0), + handoffToParticipantId: fallbackNextParticipant?.participantId, + handoffWhen: buildExecutionItemHandoff(taskCard, ownerParticipant, fallbackNextParticipant, 0, taskCard.doneWhen), + }; + + taskCard = acquireHallExecutionLock(taskCard, { + ownerParticipantId: ownerParticipant.participantId, + ownerLabel: ownerParticipant.displayName, + }); + taskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + stage: "execution", + status: "in_progress", + currentOwnerParticipantId: ownerParticipant.participantId, + currentOwnerLabel: ownerParticipant.displayName, + executionLock: taskCard.executionLock, + plannedExecutionOrder: reorderedExecutionOrder.slice(1), + plannedExecutionItems: reorderedExecutionItems.filter((item) => item.participantId !== ownerParticipant.participantId), + currentExecutionItem: stableOwnerExecutionItem, + latestSummary: input.note ?? taskCard.latestSummary, + }) + ).taskCard; + + let patchedTask = await patchTask({ + taskId: taskCard.taskId, + projectId: taskCard.projectId, + status: "in_progress", + owner: ownerParticipant.displayName, + roomId: taskCard.roomId, + }); + + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${taskCard.decision ?? ""}\n${taskCard.latestSummary ?? ""}`); + const ownerTask = stableOwnerExecutionItem?.task?.trim(); + const ownerHandoff = stableOwnerExecutionItem?.handoffWhen?.trim(); + const handoffContent = (() => { + if (language === "zh") { + if (input.note?.trim()) { + return `${ownerParticipant.displayName} 接棒。先做:${ownerTask || "推进第一步执行"}。${input.note.trim()}`; + } + return `${ownerParticipant.displayName} 接棒。先做:${ownerTask || "推进第一步执行"}。${ownerHandoff ? ownerHandoff : "做完就把结果贴回大厅。"}` + } + if (input.note?.trim()) { + return `${ownerParticipant.displayName} took this on. First step: ${ownerTask || "move the next execution slice forward"}. ${input.note.trim()}`; + } + return `${ownerParticipant.displayName} took this on. First step: ${ownerTask || "move the next execution slice forward"}. ${ownerHandoff ? `Then hand off like this: ${ownerHandoff}` : "Then post the result back to the hall and decide the next handoff."}`; + })(); + const generatedMessages: HallMessage[] = []; + const usedRuntimeChain = canDispatchHallToRuntime(options.toolClient, ownerParticipant); + if (usedRuntimeChain) { + const chain = await runHallRuntimeExecutionChain({ + hall: context.hall, + taskCard, + participant: ownerParticipant, + task: patchedTask.task, + toolClient: options.toolClient!, + mode: "execution", + note: input.note, + targetParticipantIds: [ownerParticipant.participantId], + }); + taskCard = chain.taskCard; + if (chain.task) patchedTask = { ...patchedTask, task: chain.task }; + generatedMessages.push(...chain.generatedMessages); + } else { + const ownerMessage = await appendStreamedGeneratedHallMessage({ + hallId: context.hall.hallId, + kind: "status", + participant: ownerParticipant, + content: handoffContent, + targetParticipantIds: [ownerParticipant.participantId], + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: { + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + taskStage: taskCard.stage, + taskStatus: patchedTask.task.status, + nextOwnerParticipantId: ownerParticipant.participantId, + status: "execution_started", + }, + }); + if (ownerMessage) generatedMessages.push(ownerMessage); + } + + if (taskCard.roomId) { + if (!usedRuntimeChain) { + await appendChatMessage({ + roomId: taskCard.roomId, + kind: "status", + authorRole: toRoomParticipantRole(ownerParticipant), + authorLabel: ownerParticipant.displayName, + content: handoffContent, + payload: { + executor: toRoomParticipantRole(ownerParticipant), + status: "execution_started", + taskStatus: "in_progress", + }, + }); + } + const linkedRoom = await requireLinkedRoom(taskCard.roomId); + await publishTaskRoomBridgeEvent({ + type: "executor_assigned", + room: linkedRoom, + task: patchedTask.task, + note: handoffContent, + }); + } + + let refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + if ( + refreshed.taskCard.stage === "execution" + && !refreshed.taskCard.currentExecutionItem + && stableOwnerExecutionItem + ) { + taskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + currentExecutionItem: stableOwnerExecutionItem, + }) + ).taskCard; + refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + } + await appendOperationAudit({ + action: "hall_task_assign", + source: "api", + ok: true, + detail: `assigned hall task ${taskCard.projectId}:${taskCard.taskId} to ${ownerParticipant.displayName}`, + metadata: { + taskCardId: taskCard.taskCardId, + ownerParticipantId: ownerParticipant.participantId, + }, + }); + + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + task: patchedTask.task, + roomId: taskCard.roomId, + generatedMessages, + }; +} + +export async function submitHallTaskReview(input: ReviewHallTaskInput): Promise { + const context = await ensureHallContext(); + let taskCard = await requireTaskCard(input.taskCardId); + const reviewer = pickPrimaryParticipantByRole(context.hall.participants, "reviewer") + ?? pickPrimaryParticipantByRole(context.hall.participants, "manager") + ?? context.hall.participants[0]; + if (!reviewer) { + throw new CollaborationHallStoreValidationError("No hall participants are available for review.", [], 409); + } + + const nextTaskStatus: TaskState = input.outcome === "approved" ? "done" : input.blockTask ? "blocked" : "in_progress"; + const previousOwnerLabel = taskCard.currentOwnerLabel; + taskCard = releaseHallExecutionLock(taskCard, input.outcome === "approved" ? "review-approved" : "review-requested-changes"); + taskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + stage: input.outcome === "approved" ? "completed" : input.blockTask ? "blocked" : "review", + status: nextTaskStatus, + currentOwnerParticipantId: + input.outcome === "approved" ? null : taskCard.currentOwnerParticipantId, + currentOwnerLabel: + input.outcome === "approved" ? null : taskCard.currentOwnerLabel, + currentExecutionItem: input.outcome === "approved" ? null : taskCard.currentExecutionItem, + executionLock: taskCard.executionLock, + latestSummary: input.note ?? taskCard.latestSummary, + blockers: input.outcome === "approved" ? [] : taskCard.blockers, + }) + ).taskCard; + + const patchedTask = await patchTask({ + taskId: taskCard.taskId, + projectId: taskCard.projectId, + status: nextTaskStatus, + owner: input.outcome === "approved" ? previousOwnerLabel ?? reviewer.displayName : previousOwnerLabel ?? reviewer.displayName, + roomId: taskCard.roomId, + }); + + const reviewText = buildReviewSummary( + context.hall, + taskCard, + reviewer, + input.outcome === "approved" ? "approved" : "changes_requested", + input.note, + ); + const generatedMessages: HallMessage[] = []; + const reviewMessage = await appendStreamedGeneratedHallMessage({ + hallId: context.hall.hallId, + kind: "review", + participant: reviewer, + content: reviewText, + targetParticipantIds: input.outcome === "approved" ? [] : [taskCard.currentOwnerParticipantId ?? reviewer.participantId], + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: { + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + artifactRefs: patchedTask.task.artifacts, + reviewOutcome: input.outcome, + taskStatus: nextTaskStatus, + taskStage: taskCard.stage, + status: input.outcome === "approved" ? "review_passed" : "review_rejected", + }, + }); + if (reviewMessage) generatedMessages.push(reviewMessage); + + if (taskCard.roomId) { + await submitRoomReview({ + roomId: taskCard.roomId, + outcome: input.outcome, + note: input.note, + blockTask: input.blockTask, + }); + const linkedRoom = await requireLinkedRoom(taskCard.roomId); + await publishTaskRoomBridgeEvent({ + type: "review_submitted", + room: linkedRoom, + task: patchedTask.task, + note: reviewText, + }); + } + + const refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + await appendOperationAudit({ + action: "hall_task_review", + source: "api", + ok: true, + detail: `reviewed hall task ${taskCard.projectId}:${taskCard.taskId} with outcome ${input.outcome}`, + metadata: { + taskCardId: taskCard.taskCardId, + outcome: input.outcome, + taskStatus: nextTaskStatus, + }, + }); + + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + task: patchedTask.task, + roomId: taskCard.roomId, + generatedMessages, + }; +} + +export async function stopHallTaskExecution(input: StopHallTaskInput): Promise { + const context = await ensureHallContext(); + let taskCard = await requireTaskCard(input.taskCardId); + const previousOwnerLabel = taskCard.currentOwnerLabel; + abortHallDraftRepliesForTask({ + hallId: context.hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + reason: "stopped_by_operator", + }); + taskCard = releaseHallExecutionLock(taskCard, "stopped_by_operator"); + taskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + stage: "discussion", + status: "todo", + currentOwnerParticipantId: null, + currentOwnerLabel: null, + currentExecutionItem: null, + executionLock: taskCard.executionLock, + latestSummary: input.note?.trim() || taskCard.latestSummary, + }) + ).taskCard; + + const patchedTask = await patchTask({ + taskId: taskCard.taskId, + projectId: taskCard.projectId, + status: "todo", + owner: "Operator", + roomId: taskCard.roomId, + }); + + const stopText = input.note?.trim() + ? `Execution stopped. ${input.note.trim()}` + : `Execution stopped. ${previousOwnerLabel ? `${previousOwnerLabel} returned the thread to discussion.` : "The thread returned to discussion."}`; + const generatedMessages = [ + await appendHallSystemMessage({ + hallId: context.hall.hallId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + content: stopText, + payload: { + taskStage: "discussion", + taskStatus: "todo", + status: "execution_stopped", + }, + }), + ]; + + const refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + await appendOperationAudit({ + action: "hall_task_stop", + source: "api", + ok: true, + detail: `stopped hall task ${taskCard.projectId}:${taskCard.taskId}`, + metadata: { + taskCardId: taskCard.taskCardId, + }, + }); + + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + task: patchedTask.task, + roomId: taskCard.roomId, + generatedMessages, + }; +} + +export async function archiveHallTaskThread(input: ArchiveHallTaskInput): Promise { + const context = await ensureHallContext(); + const taskCard = await requireTaskCard(input.taskCardId); + await archiveHallTaskCard({ + taskCardId: taskCard.taskCardId, + archivedByParticipantId: input.archivedByParticipantId ?? "operator", + archivedByLabel: input.archivedByLabel ?? "Operator", + }); + + const hallRead = await readCollaborationHall(context.hall.hallId); + await appendOperationAudit({ + action: "hall_task_archive", + source: "api", + ok: true, + detail: `archived hall task ${taskCard.projectId}:${taskCard.taskId}`, + metadata: { + taskCardId: taskCard.taskCardId, + archivedByParticipantId: input.archivedByParticipantId ?? "operator", + }, + }); + + return { + hall: hallRead.hall, + hallSummary: hallRead.hallSummary, + task: (await loadTaskStore()).tasks.find((item) => item.projectId === taskCard.projectId && item.taskId === taskCard.taskId), + roomId: taskCard.roomId, + generatedMessages: [], + }; +} + +export async function deleteHallTaskThread(input: DeleteHallTaskInput): Promise { + const context = await ensureHallContext(); + const taskCard = await requireTaskCard(input.taskCardId); + + abortHallDraftRepliesForTask({ + hallId: context.hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + reason: "thread_deleted", + }); + + if (taskCard.roomId) { + const roomStore = await loadChatRoomStore(); + if (getChatRoom(roomStore, taskCard.roomId)) { + await deleteChatRoom({ + roomId: taskCard.roomId, + deleteMessages: true, + }); + } + } + + const taskStore = await loadTaskStore(); + if (taskStore.tasks.some((item) => item.projectId === taskCard.projectId && item.taskId === taskCard.taskId)) { + await deleteTask({ + taskId: taskCard.taskId, + projectId: taskCard.projectId, + }); + } + + await deleteHallMessagesForTaskCard({ + hallId: taskCard.hallId, + taskCardId: taskCard.taskCardId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + }); + await deleteHallTaskCard({ + taskCardId: taskCard.taskCardId, + }); + + const hallRead = await readCollaborationHall(context.hall.hallId); + await appendOperationAudit({ + action: "hall_task_delete", + source: "api", + ok: true, + detail: `deleted hall task ${taskCard.projectId}:${taskCard.taskId}`, + metadata: { + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + }, + }); + + return { + hall: hallRead.hall, + hallSummary: hallRead.hallSummary, + roomId: taskCard.roomId, + generatedMessages: [], + }; +} + +export async function recordHallTaskHandoff( + input: HallHandoffInput, + options: HallOrchestratorRuntimeOptions = {}, +): Promise { + const context = await ensureHallContext(); + let taskCard = await requireTaskCard(input.taskCardId); + const fromParticipant = requireHallParticipant(context.hall.participants, input.fromParticipantId, "fromParticipantId"); + const toParticipant = requireHallParticipant(context.hall.participants, input.toParticipantId, "toParticipantId"); + const handoff = buildStructuredHandoffPacket(input.handoff); + const handoffSummary = summarizeStructuredHandoff(handoff, { + language: inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${taskCard.latestSummary ?? ""}`), + }); + const queuedNextOwner = taskCard.plannedExecutionOrder[0]; + const handoffMatchesQueue = !queuedNextOwner || queuedNextOwner === toParticipant.participantId; + + taskCard = releaseHallExecutionLock(taskCard, `handoff:${toParticipant.participantId}`); + taskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + stage: "execution", + status: "in_progress", + currentOwnerParticipantId: toParticipant.participantId, + currentOwnerLabel: toParticipant.displayName, + executionLock: taskCard.executionLock, + blockers: handoff.blockers, + requiresInputFrom: handoff.requiresInputFrom, + doneWhen: handoff.doneWhen, + plannedExecutionOrder: handoffMatchesQueue + ? shiftExecutionQueueForOwner(taskCard, toParticipant.participantId) + : taskCard.plannedExecutionOrder, + plannedExecutionItems: handoffMatchesQueue + ? shiftExecutionItemsForOwner(taskCard, toParticipant.participantId) + : taskCard.plannedExecutionItems, + currentExecutionItem: findExecutionItemForParticipant(taskCard, toParticipant.participantId), + latestSummary: handoff.currentResult, + }) + ).taskCard; + taskCard = acquireHallExecutionLock(taskCard, { + ownerParticipantId: toParticipant.participantId, + ownerLabel: toParticipant.displayName, + }); + taskCard = (await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + executionLock: taskCard.executionLock, + stage: "execution", + })).taskCard; + + let patchedTask = await patchTask({ + taskId: taskCard.taskId, + projectId: taskCard.projectId, + status: "in_progress", + owner: toParticipant.displayName, + roomId: taskCard.roomId, + }); + + const generatedMessages: HallMessage[] = []; + if (!handoffMatchesQueue && queuedNextOwner) { + const expected = findParticipant(context.hall.participants, queuedNextOwner)?.displayName ?? queuedNextOwner; + generatedMessages.push(await appendHallSystemMessage({ + hallId: context.hall.hallId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + content: `Handoff moved to ${toParticipant.displayName}, but the planned next owner was ${expected}. Review or update the execution order if needed.`, + payload: { + taskStage: taskCard.stage, + taskStatus: taskCard.status, + status: "handoff_order_mismatch", + nextOwnerParticipantId: toParticipant.participantId, + executionOrder: taskCard.plannedExecutionOrder, + }, + })); + } + const shouldAutoDispatchToNextOwner = handoffMatchesQueue || !queuedNextOwner; + if (shouldAutoDispatchToNextOwner && canDispatchHallToRuntime(options.toolClient, toParticipant)) { + const chain = await runHallRuntimeExecutionChain({ + hall: context.hall, + taskCard, + participant: toParticipant, + task: patchedTask.task, + toolClient: options.toolClient!, + mode: "handoff", + handoff, + targetParticipantIds: [toParticipant.participantId], + }); + taskCard = chain.taskCard; + if (chain.task) patchedTask = { ...patchedTask, task: chain.task }; + generatedMessages.push(...chain.generatedMessages); + } else { + const handoffMessage = await appendStreamedGeneratedHallMessage({ + hallId: context.hall.hallId, + kind: "handoff", + participant: fromParticipant, + content: handoffSummary, + targetParticipantIds: [toParticipant.participantId], + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: { + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + handoff, + nextOwnerParticipantId: toParticipant.participantId, + doneWhen: handoff.doneWhen, + taskStatus: patchedTask.task.status, + taskStage: taskCard.stage, + status: "handoff_recorded", + }, + }); + if (handoffMessage) generatedMessages.push(handoffMessage); + } + + if ((handoff.artifactRefs ?? []).length > 0) { + const mergedArtifacts = mergeTaskArtifacts(patchedTask.task.artifacts, handoff.artifactRefs); + if (!sameTaskArtifacts(patchedTask.task.artifacts, mergedArtifacts)) { + patchedTask = await patchTask({ + taskId: taskCard.taskId, + projectId: taskCard.projectId, + artifacts: mergedArtifacts, + }); + } + } + + if (taskCard.roomId) { + await recordRoomHandoff({ + roomId: taskCard.roomId, + fromRole: toRoomParticipantRole(fromParticipant), + toRole: toRoomParticipantRole(toParticipant), + note: truncateLinkedRoomHandoffNote(handoffSummary), + }); + const linkedRoom = await requireLinkedRoom(taskCard.roomId); + await publishTaskRoomBridgeEvent({ + type: "handoff_recorded", + room: linkedRoom, + task: patchedTask.task, + note: handoffSummary, + }); + } + + const refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + await appendOperationAudit({ + action: "hall_task_handoff", + source: "api", + ok: true, + detail: `handed off hall task ${taskCard.projectId}:${taskCard.taskId} from ${fromParticipant.displayName} to ${toParticipant.displayName}`, + metadata: { + taskCardId: taskCard.taskCardId, + fromParticipantId: fromParticipant.participantId, + toParticipantId: toParticipant.participantId, + }, + }); + + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + task: patchedTask.task, + roomId: taskCard.roomId, + generatedMessages, + }; +} + +async function runHallDiscussion( + taskCardId: string, + options: { + triggerMessage?: HallMessage; + explicitTargetParticipantIds?: string[]; + strictMentions?: boolean; + toolClient?: ToolClient; + } = {}, +): Promise<{ + hall: CollaborationHall; + hallSummary: CollaborationHallSummary; + taskCard: HallTaskCard; + taskSummary: HallTaskSummary; + generatedMessages: HallMessage[]; +}> { + const context = await ensureHallContext(); + let taskCard = await requireTaskCard(taskCardId); + const generatedMessages: HallMessage[] = []; + const presenceDrafts = new Map(); + const explicitTargets = [...new Set((options.explicitTargetParticipantIds ?? []).filter(Boolean))]; + const taskStore = await loadTaskStore(); + const task = taskStore.tasks.find((item) => item.projectId === taskCard.projectId && item.taskId === taskCard.taskId); + let currentTriggerMessage = options.triggerMessage; + let cycleTriggerMessage = options.triggerMessage; + + const abortPresenceDraft = (participantId: string | undefined, reason: string) => { + if (!participantId) return; + const draftId = presenceDrafts.get(participantId); + if (!draftId) return; + abortHallDraftReply({ + hallId: context.hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + draftId, + reason, + }); + presenceDrafts.delete(participantId); + }; + + const primeUpcomingDiscussionPresence = ( + currentParticipantId: string | undefined, + plannedParticipantIds?: string[], + ) => { + const sourceParticipantIds = plannedParticipantIds && plannedParticipantIds.length > 0 + ? plannedParticipantIds + : taskCard.discussionCycle?.expectedParticipantIds ?? []; + const futureParticipants = sourceParticipantIds + .filter((participantId) => participantId !== currentParticipantId) + .filter((participantId) => !taskCard.discussionCycle?.completedParticipantIds.includes(participantId)) + .slice(0, 2); + for (const participantId of futureParticipants) { + if (presenceDrafts.has(participantId)) continue; + const participant = findParticipant(context.hall.participants, participantId); + if (!participant) continue; + const draftId = beginHallDraftReply({ + hallId: context.hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + authorParticipantId: participant.participantId, + authorLabel: participant.displayName, + authorSemanticRole: participant.semanticRole, + messageKind: participant.semanticRole === "manager" ? "decision" : "proposal", + content: "", + }); + presenceDrafts.set(participantId, draftId); + } + }; + + const clearAllPresenceDrafts = (reason: string) => { + for (const participantId of [...presenceDrafts.keys()]) { + abortPresenceDraft(participantId, reason); + } + }; + + try { + if (taskCard.stage !== "discussion") { + const defaultSpeaker = resolveDefaultSpeakerForStage(taskCard, context.hall.participants); + const targetIds = options.strictMentions + ? explicitTargets + : explicitTargets.length > 0 + ? explicitTargets.slice(0, 1) + : defaultSpeaker + ? [defaultSpeaker] + : []; + for (const participantId of targetIds) { + const participant = findParticipant(context.hall.participants, participantId); + if (!participant) continue; + const created = await appendGeneratedHallReply( + context.hall, + taskCard, + participant, + task, + currentTriggerMessage, + options.toolClient, + ); + taskCard = created.taskCard; + if (created.message) { + generatedMessages.push(created.message); + currentTriggerMessage = created.message; + } else { + break; + } + if (!options.strictMentions) break; + } + + const refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + generatedMessages, + }; + } + + let recentThreadMessages = await loadRecentHallThreadMessages(taskCard); + if (!cycleTriggerMessage || cycleTriggerMessage.authorParticipantId !== "operator") { + cycleTriggerMessage = [...recentThreadMessages] + .reverse() + .find((message) => message.authorParticipantId === "operator") + ?? currentTriggerMessage; + } + const discussionTriggerMessage = cycleTriggerMessage ?? currentTriggerMessage; + const spokenParticipantIds = new Set(); + const explicitQueue = options.strictMentions && explicitTargets.length > 0 ? explicitTargets.slice() : []; + const maxDiscussionTurns = explicitQueue.length > 0 ? explicitQueue.length : 3; + + for (let turn = 0; turn < maxDiscussionTurns; turn += 1) { + const plannedParticipantIds = explicitQueue.length > 0 + ? explicitQueue.filter((participantId) => !spokenParticipantIds.has(participantId)) + : determineDiscussionTurnParticipants({ + hall: context.hall, + taskCard, + task, + triggerMessage: discussionTriggerMessage, + recentThreadMessages: [...recentThreadMessages, ...generatedMessages], + }).filter((participantId) => !spokenParticipantIds.has(participantId)); + const participantId = plannedParticipantIds[0]; + if (!participantId) break; + const participant = findParticipant(context.hall.participants, participantId); + if (!participant) continue; + abortPresenceDraft(participant.participantId, "discussion_turn_started"); + primeUpcomingDiscussionPresence( + participant.participantId, + explicitQueue.length > 0 + ? explicitQueue.filter((queuedParticipantId) => !spokenParticipantIds.has(queuedParticipantId)) + : undefined, + ); + const created = await appendGeneratedHallReply( + context.hall, + taskCard, + participant, + task, + discussionTriggerMessage, + options.toolClient, + ); + taskCard = created.taskCard; + if (created.message) { + generatedMessages.push(created.message); + recentThreadMessages = [...recentThreadMessages, created.message].slice(-12); + currentTriggerMessage = created.message; + } else { + break; + } + spokenParticipantIds.add(participant.participantId); + if (participant.semanticRole === "manager") { + break; + } + } + + const refreshed = await refreshHallAndTaskSummary(context.hall.hallId, taskCard); + return { + hall: refreshed.hall, + hallSummary: refreshed.hallSummary, + taskCard: refreshed.taskCard, + taskSummary: refreshed.taskSummary, + generatedMessages, + }; + } finally { + clearAllPresenceDrafts("discussion_cycle_finished"); + } +} + +async function loadRecentHallThreadMessages(taskCard: HallTaskCard, limit = 12): Promise { + const messageStore = await loadCollaborationHallMessageStore(); + return listHallMessages(messageStore, { hallId: taskCard.hallId }) + .filter((message) => message.taskCardId === taskCard.taskCardId || message.taskId === taskCard.taskId) + .slice(-limit); +} + +function determineDiscussionTurnParticipants(input: { + hall: CollaborationHall; + taskCard: HallTaskCard; + task?: ProjectTask; + triggerMessage?: HallMessage; + recentThreadMessages: HallMessage[]; +}): string[] { + const cycleOpenedAt = input.taskCard.discussionCycle?.openedAt; + const cycleTriggerMessage = [...input.recentThreadMessages] + .reverse() + .find((message) => { + if (message.authorParticipantId !== "operator") return false; + if (message.taskCardId !== input.taskCard.taskCardId && message.taskId !== input.taskCard.taskId) return false; + return !cycleOpenedAt || message.createdAt >= cycleOpenedAt; + }); + const triggerText = cycleTriggerMessage?.content?.trim() + || input.triggerMessage?.content?.trim() + || `${input.taskCard.title}\n${input.taskCard.description}\n${input.task?.title ?? ""}`; + const explicitTargets = [ + ...new Set( + ( + cycleTriggerMessage?.mentionTargets?.map((target) => target.participantId) + ?? input.triggerMessage?.mentionTargets?.map((target) => target.participantId) + ?? [] + ).filter(Boolean), + ), + ]; + const candidateIds = [ + ...new Set( + (input.taskCard.discussionCycle?.expectedParticipantIds?.length + ? input.taskCard.discussionCycle.expectedParticipantIds + : buildDynamicDiscussionParticipantQueue(input.hall, input.taskCard, input.task, triggerText)), + ), + ]; + const candidates = candidateIds + .map((participantId) => findParticipant(input.hall.participants, participantId)) + .filter((participant): participant is HallParticipant => Boolean(participant)); + const manager = candidates.find((participant) => participant.semanticRole === "manager"); + const nonManagers = candidates.filter((participant) => participant.semanticRole !== "manager"); + const currentCycleContributorCount = input.taskCard.discussionCycle?.completedParticipantIds.length ?? 0; + const historicalAgentContributors = countDistinctAgentContributors(input.recentThreadMessages); + const priorAgentContributors = Math.max( + currentCycleContributorCount, + countDistinctAgentContributors( + input.recentThreadMessages, + input.taskCard.discussionCycle?.openedAt, + ), + ); + const followupIntent = classifyHallDiscussionFollowupIntent(triggerText); + const wantsContinuation = requestsDiscussionContinuation(triggerText); + const wantsConcreteDeliverable = followupIntent === "direct_deliverable_request" + || followupIntent === "repo_scan_request" + || followupIntent === "review_request"; + const wantsDecision = !wantsContinuation && followupIntent === "decision_request"; + const wantsManyVoices = requestsMultiPerspectiveDiscussion(triggerText); + const kickoffDiscussion = priorAgentContributors === 0 && historicalAgentContributors === 0; + const needsTwoImplicitVoices = explicitTargets.length === 0; + const planned: string[] = []; + + const push = (participantId: string | undefined) => { + if (!participantId || planned.includes(participantId)) return; + planned.push(participantId); + }; + + const lead = nonManagers[0] ?? manager; + const complement = pickComplementaryDiscussionParticipant(nonManagers, lead, input.taskCard, input.task) + ?? nonManagers[1]; + + if (explicitTargets.length > 0 && wantsConcreteDeliverable) { + push(explicitTargets[0]); + return planned.slice(0, 1); + } + + if (needsTwoImplicitVoices) { + if (priorAgentContributors < 1) { + push(lead?.participantId ?? complement?.participantId ?? manager?.participantId); + return planned.slice(0, 1); + } + if (priorAgentContributors < 2) { + push(complement?.participantId ?? manager?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + } + + if (kickoffDiscussion) { + push(lead?.participantId); + if (!explicitTargets.length) { + push(complement?.participantId ?? manager?.participantId); + } + return planned.slice(0, 2); + } + + if (wantsDecision) { + if (wantsConcreteDeliverable) { + push(manager?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + if (needsTwoImplicitVoices && priorAgentContributors < 1) { + push(lead?.participantId); + return planned.slice(0, 1); + } + if (needsTwoImplicitVoices && priorAgentContributors < 2) { + push(complement?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + if (currentCycleContributorCount === 0 && historicalAgentContributors >= 1) { + push(manager?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + if (!wantsManyVoices && priorAgentContributors >= 1) { + push(manager?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + if (priorAgentContributors >= 2) { + push(manager?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + push(complement?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + + if (wantsContinuation) { + if (needsTwoImplicitVoices && priorAgentContributors < 1) { + push(lead?.participantId ?? complement?.participantId); + return planned.slice(0, 1); + } + if (needsTwoImplicitVoices && priorAgentContributors < 2) { + push(complement?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + if (explicitTargets.length > 0) { + push(lead?.participantId ?? complement?.participantId); + return planned.slice(0, 1); + } + return []; + } + + if (wantsManyVoices) { + if (priorAgentContributors < 1) { + push(lead?.participantId); + return planned.slice(0, 1); + } + if (priorAgentContributors < 2) { + push(complement?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + return []; + } + + if (!explicitTargets.length && priorAgentContributors < 1) { + push(lead?.participantId ?? complement?.participantId); + return planned.slice(0, 1); + } + + if (!explicitTargets.length && priorAgentContributors < 2) { + push(complement?.participantId ?? lead?.participantId); + return planned.slice(0, 1); + } + + return []; +} + +function requestsMultiPerspectiveDiscussion(text: string): boolean { + return /(大家|你们|各自|分别|一起讨论|多角度|不同角度|给点意见|怎么看|有什么意见|brainstorm|different perspectives|everyone|each of you|discuss together)/i.test(text); +} + +function requestsDiscussionDecision(text: string): boolean { + return /(收口|总结|拍板|定一下|决策|给个结论|下结论|谁先做|谁来做|谁负责|执行顺序|下一步|owner|executor|decision|summari[sz]e|wrap up|who should|next step)/i.test(text); +} + +function requestsDiscussionContinuation(text: string): boolean { + return /(继续讨论|继续聊|先讨论|先聊|先只讨论|只讨论|不急着收口|先别收口|先不要收口|不急着总结|先别总结|先不要总结|不要总结|先不拍板|先别拍板|先不要拍板|不要拍板|先别给结论|先不要给结论|别急着定|先不要定|先别定)/i.test(text); +} + +function requestsExecutionContinuation(text: string): boolean { + return /(继续执行|继续这一步|继续当前步骤|继续当前执行|按原计划继续|接着做这一步|接着做当前步骤|接着往下做|就继续做|继续推进|resume execution|continue execution|continue this step|keep going|keep working on|finish this step)/i.test(text); +} + +function classifyHallDiscussionFollowupIntent( + text: string, +): "discussion_request" | "direct_deliverable_request" | "repo_scan_request" | "review_request" | "decision_request" { + const normalized = text.trim(); + if (!normalized) return "discussion_request"; + if (requestsDiscussionContinuation(normalized)) return "discussion_request"; + if (/(收一下|收个口|给个结论|拍板|定一下|做决定|作决定|第一执行者|建议第一位执行者|先给.*第一步|给.*第一步|谁先做|谁来做第一步|谁来先做|执行顺序|下一步由谁|谁负责|owner|executor|decision|wrap up|summari[sz]e)/i.test(normalized)) { + return "decision_request"; + } + if (/(repo|repository|codebase|source code|scan code|scan the repo|implementation|file path|workspace|代码|源码|仓库|扫描代码|扫代码|实现|文件)/i.test(normalized)) { + return "repo_scan_request"; + } + if (/(must-fix|review|审核|评审|检查|挑一下|挑出|只挑|硬问题|硬缺口)/i.test(normalized)) { + return "review_request"; + } + if (/(给我|给一下|直接给|你给|你来|你去|请你|帮我|直接出|出一下|写一下|写一版|给一版|直接贴|贴一下|去扫|扫一下|看一下|查一下|产出|生成|整理|总结|扫描|scan|inspect|check|review|write|draft|produce|generate|show me|give me|please give|please write|please scan|can you|could you|完整的?.*(开头|口播|脚本|文案|版本)|三个?.*(开头|视频开头|口播开头)|3 个.*(开头|视频开头|口播开头|hook|thumbnail))/i.test(normalized)) { + return "direct_deliverable_request"; + } + return "discussion_request"; +} + +function requestsConcreteDeliverable(text: string): boolean { + return /(先出|先给|直接出|先写|写一个|给我一版|来一版|草稿|初稿|脚本|beat sheet|storyboard|分镜|outline|大纲|第一版|draft|first draft|first pass|deliverable|产出一版|出个方案|出一个方案)/i.test(text); +} + +function countDistinctAgentContributors(messages: HallMessage[], openedAt?: string): number { + const contributors = new Set(); + const openedAtTs = openedAt ? Date.parse(openedAt) : Number.NaN; + for (const message of messages) { + if (!Number.isNaN(openedAtTs)) { + const createdAtTs = Date.parse(message.createdAt); + if (!Number.isNaN(createdAtTs) && createdAtTs < openedAtTs) continue; + } + if (message.authorParticipantId === "operator") continue; + if (!message.authorSemanticRole) continue; + contributors.add(message.authorParticipantId); + } + return contributors.size; +} + +function pickComplementaryDiscussionParticipant( + candidates: HallParticipant[], + lead: HallParticipant | undefined, + taskCard: HallTaskCard, + task?: ProjectTask, +): HallParticipant | undefined { + if (!lead) return candidates[1]; + const domain = inferHallDiscussionDomain(taskCard, task); + const preferredRoles = complementaryDiscussionRoles(lead.semanticRole, domain); + for (const role of preferredRoles) { + const match = candidates.find((participant) => participant.participantId !== lead.participantId && participant.semanticRole === role); + if (match) return match; + } + return candidates.find((participant) => participant.participantId !== lead.participantId); +} + +function complementaryDiscussionRoles( + leadRole: HallSemanticRole, + domain: HallDiscussionDomain, +): HallSemanticRole[] { + if (leadRole === "planner") { + if (domain === "research" || domain === "product" || domain === "operations") return ["reviewer", "coder", "generalist"]; + return ["coder", "reviewer", "generalist"]; + } + if (leadRole === "coder") return ["reviewer", "planner", "generalist"]; + if (leadRole === "reviewer") return ["planner", "coder", "generalist"]; + return ["planner", "coder", "reviewer", "generalist"]; +} + +function scheduleHallDiscussion( + taskCardId: string, + options: Parameters[1], + afterDiscussion?: (result: Awaited>) => Promise | void, +): void { + let pending: Promise | undefined; + pending = (async () => { + try { + const result = await runHallDiscussion(taskCardId, options); + if (afterDiscussion) { + await afterDiscussion(result); + } + } catch (error) { + await appendOperationAudit({ + action: "hall_task_message", + source: "api", + ok: false, + detail: error instanceof Error ? error.message : String(error), + metadata: { taskCardId }, + }); + } finally { + if (pending) pendingHallBackgroundWork.delete(pending); + } + })(); + pendingHallBackgroundWork.add(pending); +} + +async function runHallRuntimeExecutionChain(input: { + hall: CollaborationHall; + taskCard: HallTaskCard; + participant: HallParticipant; + task?: ProjectTask; + toolClient: ToolClient; + mode: "execution" | "handoff"; + note?: string; + handoff?: StructuredHandoffPacket; + triggerMessage?: HallMessage; + targetParticipantIds: string[]; +}): Promise<{ taskCard: HallTaskCard; task?: ProjectTask; generatedMessages: HallMessage[] }> { + let taskCard = input.taskCard; + let task = input.task; + let mode = input.mode; + let note = input.note; + let handoff = input.handoff; + let triggerMessage = input.triggerMessage; + const generatedMessages: HallMessage[] = []; + const visibleTurnBudget = HALL_RUNTIME_EXECUTION_CHAIN_ENABLED + ? Math.max(1, HALL_RUNTIME_EXECUTION_MAX_TURNS) + : 1; + const hiddenRetryBudget = Math.max(2, visibleTurnBudget * 2); + let visibleTurns = 0; + let hiddenRetries = 0; + + for (;;) { + let runtimeResult: HallRuntimeDispatchResult; + try { + runtimeResult = await dispatchHallRuntimeTurn({ + client: input.toolClient, + hall: input.hall, + taskCard, + participant: input.participant, + task, + triggerMessage, + mode, + handoff, + note, + }); + } catch (error) { + generatedMessages.push(await appendRuntimeFailureHallMessage(input.hall, taskCard, input.participant, error)); + return { taskCard, task, generatedMessages }; + } + + if (runtimeResult.canceled) { + return { taskCard, task, generatedMessages }; + } + + let persistedRuntimeMessage: HallMessage | undefined; + if (!runtimeResult.suppressVisibleMessage) { + persistedRuntimeMessage = await appendPersistedHallMessage({ + hallId: input.hall.hallId, + kind: runtimeResult.kind, + participant: input.participant, + content: runtimeResult.content, + targetParticipantIds: input.targetParticipantIds, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: runtimeResult.payload, + }); + generatedMessages.push(persistedRuntimeMessage); + visibleTurns += 1; + + taskCard = await linkHallRuntimeArtifacts({ + taskCard, + task, + participant: input.participant, + message: persistedRuntimeMessage, + runtimeResult, + }); + if (task) { + const refreshedTaskStore = await loadTaskStore(); + task = refreshedTaskStore.tasks.find((item) => item.projectId === taskCard.projectId && item.taskId === taskCard.taskId) ?? task; + } + } + + const directive = runtimeResult.chainDirective; + if (runtimeResult.suppressVisibleMessage && directive?.nextAction === "continue") { + hiddenRetries += 1; + if (hiddenRetries < hiddenRetryBudget) { + note = buildHallExecutionContinuationNote(taskCard, directive, visibleTurns, visibleTurnBudget); + mode = "execution"; + handoff = undefined; + triggerMessage = undefined; + continue; + } + generatedMessages.push(await appendHallSystemMessage({ + hallId: input.hall.hallId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + content: buildMissingConcreteDeliverableSummary(taskCard, input.participant), + payload: { + taskStage: taskCard.stage, + taskStatus: taskCard.status, + status: "execution_missing_deliverable", + }, + })); + return { taskCard, task, generatedMessages }; + } + + if (!shouldContinueHallExecutionChain(directive, visibleTurns, visibleTurnBudget)) { + const transition = await applyHallExecutionDirective({ + hall: input.hall, + taskCard, + task, + participant: input.participant, + directive, + toolClient: input.toolClient, + }); + taskCard = transition.taskCard; + task = transition.task; + generatedMessages.push(...transition.generatedMessages); + return { taskCard, task, generatedMessages }; + } + + note = buildHallExecutionContinuationNote(taskCard, directive, visibleTurns, visibleTurnBudget); + mode = "execution"; + handoff = undefined; + triggerMessage = undefined; + } +} + +function shouldContinueHallExecutionChain( + directive: HallRuntimeChainDirective | undefined, + completedTurns: number, + automaticTurnBudget: number, +): boolean { + return directive?.nextAction === "continue" && completedTurns + 1 < automaticTurnBudget; +} + +function buildHallExecutionContinuationNote( + taskCard: HallTaskCard, + directive: HallRuntimeChainDirective | undefined, + completedTurns: number, + automaticTurnBudget: number, +): string { + const focus = directive?.nextStep?.trim(); + return [ + `Continue the same execution chain in the current session.`, + `Automatic execution turn ${completedTurns + 1} of ${automaticTurnBudget}.`, + focus ? `Focus next on: ${focus}` : "", + taskCard.latestSummary ? `Most recent summary: ${taskCard.latestSummary}` : "", + ].filter(Boolean).join(" "); +} + +function buildMissingConcreteDeliverableSummary(taskCard: HallTaskCard, participant: HallParticipant): string { + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${taskCard.latestSummary ?? ""}`); + const currentExecutionItem = getCurrentExecutionItem(taskCard); + const currentTask = currentExecutionItem?.task?.trim() || taskCard.doneWhen?.trim() || ""; + if (language === "zh") { + return `${participant.displayName} 这一棒还没把具体交付物贴出来,继续当前步骤:${currentTask || "把结果直接贴回群里。"}` + .trim(); + } + return `${participant.displayName} has not posted the concrete deliverable for this step yet. Continue the current step: ${currentTask || "post the actual result back into the hall."}`; +} + +async function applyHallExecutionDirective(input: { + hall: CollaborationHall; + taskCard: HallTaskCard; + task?: ProjectTask; + participant: HallParticipant; + directive?: HallRuntimeChainDirective; + toolClient?: ToolClient; +}): Promise<{ taskCard: HallTaskCard; task?: ProjectTask; generatedMessages: HallMessage[] }> { + const nextAction = input.directive?.nextAction; + const latestHall = await requireHall(input.hall.hallId); + const latestTaskCard = await requireTaskCard(input.taskCard.taskCardId); + const currentExecutionItem = getCurrentExecutionItem(latestTaskCard); + const queuedNextParticipantId = currentExecutionItem + ? (currentExecutionItem.handoffToParticipantId?.trim() || "") + : (latestTaskCard.plannedExecutionOrder[0] || ""); + if (!nextAction || nextAction === "continue") { + return { taskCard: latestTaskCard, task: input.task, generatedMessages: [] }; + } + + if (nextAction === "blocked") { + const taskCard = ( + await updateHallTaskCard({ + taskCardId: latestTaskCard.taskCardId, + stage: "blocked", + status: "blocked", + currentExecutionItem: getCurrentExecutionItem(latestTaskCard), + }) + ).taskCard; + const task = input.task + ? (await patchTask({ + taskId: input.task.taskId, + projectId: input.task.projectId, + status: "blocked", + owner: input.participant.displayName, + roomId: input.taskCard.roomId, + })).task + : undefined; + return { + taskCard, + task, + generatedMessages: [ + await appendHallSystemMessage({ + hallId: input.hall.hallId, + projectId: input.taskCard.projectId, + taskId: input.taskCard.taskId, + taskCardId: input.taskCard.taskCardId, + roomId: input.taskCard.roomId, + content: buildBlockedExecutionSummary(taskCard, input.participant), + payload: { + taskStage: "blocked", + taskStatus: "blocked", + status: "execution_blocked", + }, + }), + ], + }; + } + + if (nextAction === "review" || nextAction === "done") { + const explicitNextParticipant = input.directive?.executor + ? findParticipant(latestHall.participants, input.directive.executor) + : undefined; + const nextParticipant = explicitNextParticipant + ?? (queuedNextParticipantId + ? findParticipant(latestHall.participants, queuedNextParticipantId) + : undefined); + if (nextParticipant && nextParticipant.participantId !== input.participant.participantId) { + const handoff = buildAutomaticRuntimeHandoffInput( + latestTaskCard, + input.task, + input.participant, + nextParticipant, + input.directive, + ); + const handedOff = await recordHallTaskHandoff({ + taskCardId: latestTaskCard.taskCardId, + fromParticipantId: input.participant.participantId, + toParticipantId: nextParticipant.participantId, + handoff, + }, { + toolClient: input.toolClient, + }); + return { + taskCard: handedOff.taskCard ?? latestTaskCard, + task: handedOff.task ?? input.task, + generatedMessages: handedOff.generatedMessages, + }; + } + const taskCard = ( + await updateHallTaskCard({ + taskCardId: latestTaskCard.taskCardId, + stage: "review", + status: "in_progress", + currentExecutionItem: getCurrentExecutionItem(latestTaskCard), + }) + ).taskCard; + const task = input.task + ? (await patchTask({ + taskId: input.task.taskId, + projectId: input.task.projectId, + status: "in_progress", + owner: input.participant.displayName, + roomId: input.taskCard.roomId, + })).task + : undefined; + return { + taskCard, + task, + generatedMessages: [ + await appendHallSystemMessage({ + hallId: input.hall.hallId, + projectId: input.taskCard.projectId, + taskId: input.taskCard.taskId, + taskCardId: input.taskCard.taskCardId, + roomId: input.taskCard.roomId, + content: buildReadyForReviewSummary(taskCard, input.participant), + payload: { + artifactRefs: task?.artifacts, + taskStage: "review", + taskStatus: "in_progress", + status: "execution_ready_for_review", + }, + }), + ], + }; + } + + if (nextAction === "handoff") { + const plannedNextParticipantId = currentExecutionItem + ? (currentExecutionItem.handoffToParticipantId?.trim() || "") + : (latestTaskCard.plannedExecutionOrder[0] || ""); + const explicitNextParticipant = input.directive?.executor + ? findParticipant(latestHall.participants, input.directive.executor) + : undefined; + const nextParticipantId = + explicitNextParticipant && plannedNextParticipantId && explicitNextParticipant.participantId !== plannedNextParticipantId + ? plannedNextParticipantId + : ( + explicitNextParticipant?.participantId + || plannedNextParticipantId + ); + const nextParticipant = nextParticipantId + ? findParticipant(latestHall.participants, nextParticipantId) + : undefined; + if (!nextParticipant || nextParticipant.participantId === input.participant.participantId) { + return applyHallExecutionDirective({ + ...input, + hall: latestHall, + taskCard: latestTaskCard, + directive: { + ...input.directive, + nextAction: "review", + }, + }); + } + const handoff = buildAutomaticRuntimeHandoffInput( + latestTaskCard, + input.task, + input.participant, + nextParticipant, + input.directive, + ); + const handedOff = await recordHallTaskHandoff({ + taskCardId: latestTaskCard.taskCardId, + fromParticipantId: input.participant.participantId, + toParticipantId: nextParticipant.participantId, + handoff, + }, { + toolClient: input.toolClient, + }); + return { + taskCard: handedOff.taskCard ?? latestTaskCard, + task: handedOff.task ?? input.task, + generatedMessages: handedOff.generatedMessages, + }; + } + + return { taskCard: latestTaskCard, task: input.task, generatedMessages: [] }; +} + +function buildAutomaticRuntimeHandoffInput( + taskCard: HallTaskCard, + task: ProjectTask | undefined, + fromParticipant: HallParticipant, + toParticipant: HallParticipant, + directive: HallRuntimeChainDirective | undefined, +): CreateStructuredHandoffInput { + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${taskCard.latestSummary ?? ""}`); + const currentExecutionItem = getCurrentExecutionItem(taskCard); + const nextExecutionItem = findExecutionItemForParticipant(taskCard, toParticipant.participantId); + const currentResult = taskCard.latestSummary?.trim() + || directive?.nextStep?.trim() + || (language === "zh" + ? `${fromParticipant.displayName} 已完成当前这一步。` + : `${fromParticipant.displayName} completed the current execution item.`); + const goal = nextExecutionItem?.task?.trim() + || directive?.nextStep?.trim() + || currentExecutionItem?.handoffWhen?.trim() + || taskCard.doneWhen?.trim() + || (language === "zh" ? "继续推进下一步执行。" : "Continue the next execution step."); + const doneWhen = nextExecutionItem?.handoffWhen?.trim() + || taskCard.doneWhen?.trim() + || nextExecutionItem?.task?.trim() + || (language === "zh" ? "这一轮产出已可评审。" : "This pass is reviewable."); + return { + goal, + currentResult, + doneWhen, + blockers: taskCard.blockers, + nextOwner: toParticipant.displayName, + requiresInputFrom: taskCard.requiresInputFrom, + artifactRefs: task?.artifacts, + }; +} + +async function appendGeneratedHallReply( + hall: CollaborationHall, + taskCard: HallTaskCard, + participant: HallParticipant, + task: ProjectTask | undefined, + triggerMessage: HallMessage | undefined, + toolClient: ToolClient | undefined, +): Promise<{ message?: HallMessage; taskCard: HallTaskCard }> { + if (taskCard.stage === "execution") { + const draft = buildGeneratedHallReply(hall, taskCard, participant, task); + const message = await appendStreamedGeneratedHallMessage({ + hallId: hall.hallId, + kind: draft.kind, + participant, + content: draft.content, + targetParticipantIds: [], + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: draft.payload, + }); + return { message, taskCard }; + } + + let runtimeResult: HallRuntimeDispatchResult | undefined; + let failureMessage: HallMessage | undefined; + let runtimeDraftId = ""; + if (canDispatchHallToRuntime(toolClient, participant)) { + runtimeDraftId = beginHallDraftReply({ + hallId: hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + authorParticipantId: participant.participantId, + authorLabel: participant.displayName, + authorSemanticRole: participant.semanticRole, + messageKind: participant.semanticRole === "manager" ? "decision" : "proposal", + content: "", + }); + try { + const recentThreadMessages = await loadRecentHallThreadMessages(taskCard); + runtimeResult = await dispatchHallRuntimeTurn({ + client: toolClient, + hall, + taskCard, + participant, + task, + triggerMessage, + recentThreadMessages, + mode: "discussion", + }); + } catch (error) { + failureMessage = await appendRuntimeFailureHallMessage(hall, taskCard, participant, error); + } + } + if (runtimeResult?.canceled) { + return { taskCard }; + } + + const draft = runtimeResult + ? { + kind: runtimeResult.kind, + content: runtimeResult.content, + payload: runtimeResult.payload, + } + : failureMessage + ? { + kind: failureMessage.kind, + content: failureMessage.content, + payload: failureMessage.payload, + } + : buildGeneratedHallReply(hall, taskCard, participant, task); + + const message = failureMessage + ?? (runtimeResult + ? await appendPersistedHallMessage({ + hallId: hall.hallId, + kind: draft.kind, + participant, + content: draft.content, + targetParticipantIds: [], + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: draft.payload, + }) + : await appendStreamedGeneratedHallMessage({ + hallId: hall.hallId, + kind: draft.kind, + participant, + content: draft.content, + targetParticipantIds: [], + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: draft.payload, + })); + + if (runtimeDraftId) { + if (message) { + completeHallDraftReply({ + hallId: hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + draftId: runtimeDraftId, + messageId: message.messageId, + content: message.content, + }); + } else { + abortHallDraftReply({ + hallId: hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + draftId: runtimeDraftId, + reason: "discussion_reply_not_persisted", + }); + } + } + + if (!message) { + return { taskCard }; + } + + const persistedTaskCard = await requireTaskCard(taskCard.taskCardId); + if (persistedTaskCard.stage !== "discussion") { + return { message, taskCard: persistedTaskCard }; + } + const completedTaskCard = markDiscussionSpeakerComplete(persistedTaskCard, participant.participantId, message.createdAt); + const discussionCycleCompleted = + JSON.stringify(completedTaskCard.discussionCycle ?? null) !== JSON.stringify(persistedTaskCard.discussionCycle ?? null); + let nextTaskCard = completedTaskCard; + if (discussionCycleCompleted) { + nextTaskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + discussionCycle: completedTaskCard.discussionCycle, + }) + ).taskCard; + } + if ( + draft.payload?.proposal + || draft.payload?.decision + || draft.payload?.doneWhen + || draft.payload?.nextOwnerParticipantId + || draft.payload?.executionOrder + || draft.payload?.executionItems + ) { + const preservePersistedExecutionPlan = + persistedTaskCard.updatedAt !== taskCard.updatedAt + && ( + persistedTaskCard.plannedExecutionItems.length > 0 + || persistedTaskCard.plannedExecutionOrder.length > 0 + ); + const nextExecutionOrder = preservePersistedExecutionPlan + ? nextTaskCard.plannedExecutionOrder + : draft.payload.executionOrder ?? nextTaskCard.plannedExecutionOrder; + const nextExecutionItems = preservePersistedExecutionPlan + ? nextTaskCard.plannedExecutionItems + : draft.payload.executionItems + ?? (draft.payload.executionOrder + ? deriveExecutionItemsFromOrder( + hall.participants, + nextExecutionOrder, + nextTaskCard, + { existingItems: nextTaskCard.plannedExecutionItems, primaryDoneWhen: draft.payload.doneWhen ?? nextTaskCard.doneWhen }, + ) + : nextTaskCard.plannedExecutionItems); + nextTaskCard = ( + await updateHallTaskCard({ + taskCardId: taskCard.taskCardId, + proposal: draft.payload.proposal ?? nextTaskCard.proposal, + decision: draft.payload.decision ?? nextTaskCard.decision, + doneWhen: draft.payload.doneWhen ?? nextTaskCard.doneWhen, + plannedExecutionOrder: nextExecutionOrder, + plannedExecutionItems: nextExecutionItems, + currentOwnerParticipantId: nextTaskCard.currentOwnerParticipantId, + currentOwnerLabel: nextTaskCard.currentOwnerLabel, + latestSummary: draft.content, + discussionCycle: nextTaskCard.discussionCycle, + }) + ).taskCard; + } + + if (runtimeResult?.taskCardPatch || runtimeResult?.sessionKey) { + nextTaskCard = await linkHallRuntimeArtifacts({ + taskCard: nextTaskCard, + task, + participant, + message, + runtimeResult, + }); + } + + if (!failureMessage && participant.semanticRole === "manager" && nextTaskCard.stage === "discussion") { + nextTaskCard = closeDiscussionCycle(nextTaskCard, message.createdAt); + nextTaskCard = ( + await updateHallTaskCard({ + taskCardId: nextTaskCard.taskCardId, + discussionCycle: nextTaskCard.discussionCycle, + latestSummary: draft.content, + }) + ).taskCard; + } + + return { message, taskCard: nextTaskCard }; +} + +async function appendStreamedGeneratedHallMessage(input: { + hallId: string; + kind: HallMessage["kind"]; + participant: HallParticipant; + content: string; + targetParticipantIds: string[]; + projectId: string; + taskId: string; + taskCardId: string; + roomId?: string; + payload?: HallMessage["payload"]; +}): Promise { + const draftId = await streamHallDraftReply({ + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + messageKind: input.kind, + content: input.content, + }); + if (isHallDraftCanceled(draftId)) { + return undefined; + } + const message = ( + await appendHallMessage({ + hallId: input.hallId, + kind: input.kind, + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + content: input.content, + targetParticipantIds: input.targetParticipantIds, + projectId: input.projectId, + taskId: input.taskId, + taskCardId: input.taskCardId, + roomId: input.roomId, + payload: input.payload, + }) + ).message; + completeHallDraftReply({ + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + draftId, + messageId: message.messageId, + content: input.content, + }); + return message; +} + +async function appendPersistedHallMessage(input: { + hallId: string; + kind: HallMessage["kind"]; + participant: HallParticipant; + content: string; + targetParticipantIds: string[]; + projectId: string; + taskId: string; + taskCardId: string; + roomId?: string; + payload?: HallMessage["payload"]; +}): Promise { + return ( + await appendHallMessage({ + hallId: input.hallId, + kind: input.kind, + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + content: input.content, + targetParticipantIds: input.targetParticipantIds, + projectId: input.projectId, + taskId: input.taskId, + taskCardId: input.taskCardId, + roomId: input.roomId, + payload: input.payload, + }) + ).message; +} + +async function appendHallSystemMessage(input: { + hallId: string; + content: string; + projectId: string; + taskId: string; + taskCardId: string; + roomId?: string; + payload?: HallMessage["payload"]; +}): Promise { + return ( + await appendHallMessage({ + hallId: input.hallId, + kind: "system", + authorParticipantId: "system", + authorLabel: "System", + content: input.content, + targetParticipantIds: [], + projectId: input.projectId, + taskId: input.taskId, + taskCardId: input.taskCardId, + roomId: input.roomId, + payload: input.payload, + }) + ).message; +} + +async function appendRuntimeFailureHallMessage( + hall: CollaborationHall, + taskCard: HallTaskCard, + participant: HallParticipant, + error: unknown, +): Promise { + const detail = error instanceof Error ? error.message : "unknown runtime error"; + return ( + await appendHallMessage({ + hallId: hall.hallId, + kind: "system", + authorParticipantId: "system", + authorLabel: "System", + content: `Runtime dispatch to ${participant.displayName} failed: ${detail}`, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + payload: { + projectId: taskCard.projectId, + taskId: taskCard.taskId, + taskCardId: taskCard.taskCardId, + roomId: taskCard.roomId, + taskStage: taskCard.stage, + taskStatus: taskCard.status, + status: "runtime_error", + }, + }) + ).message; +} + +async function linkHallRuntimeArtifacts(input: { + taskCard: HallTaskCard; + task?: ProjectTask; + participant: HallParticipant; + message: HallMessage; + runtimeResult: HallRuntimeDispatchResult; +}): Promise { + const nextSessionKeys = input.runtimeResult.sessionKey + ? [...new Set([...input.taskCard.sessionKeys, input.runtimeResult.sessionKey])] + : input.taskCard.sessionKeys; + let taskCard = input.taskCard; + const patch = input.runtimeResult.taskCardPatch ?? {}; + const shouldPatchTaskCard = + nextSessionKeys.join("|") !== input.taskCard.sessionKeys.join("|") + || patch.proposal !== undefined + || patch.decision !== undefined + || patch.doneWhen !== undefined + || patch.currentOwnerParticipantId !== undefined + || patch.currentOwnerLabel !== undefined + || patch.blockers !== undefined + || patch.requiresInputFrom !== undefined + || patch.latestSummary !== undefined; + + if (shouldPatchTaskCard) { + taskCard = ( + await updateHallTaskCard({ + taskCardId: input.taskCard.taskCardId, + proposal: patch.proposal ?? input.taskCard.proposal, + decision: patch.decision ?? input.taskCard.decision, + doneWhen: patch.doneWhen ?? input.taskCard.doneWhen, + currentOwnerParticipantId: patch.currentOwnerParticipantId ?? input.taskCard.currentOwnerParticipantId, + currentOwnerLabel: patch.currentOwnerLabel ?? input.taskCard.currentOwnerLabel, + blockers: patch.blockers ?? input.taskCard.blockers, + requiresInputFrom: patch.requiresInputFrom ?? input.taskCard.requiresInputFrom, + latestSummary: patch.latestSummary ?? input.taskCard.latestSummary, + sessionKeys: nextSessionKeys, + }) + ).taskCard; + } + + if (input.task) { + const taskSessionKeys = input.runtimeResult.sessionKey + ? [...new Set([...(input.task.sessionKeys ?? []), input.runtimeResult.sessionKey])] + : input.task.sessionKeys; + const mergedArtifacts = mergeTaskArtifacts(input.task.artifacts, input.runtimeResult.payload?.artifactRefs); + if ( + taskSessionKeys.join("|") !== (input.task.sessionKeys ?? []).join("|") + || !sameTaskArtifacts(input.task.artifacts, mergedArtifacts) + ) { + await patchTask({ + taskId: input.task.taskId, + projectId: input.task.projectId, + sessionKeys: taskSessionKeys, + artifacts: mergedArtifacts, + }); + } + } + + if (taskCard.roomId) { + await appendChatMessage({ + roomId: taskCard.roomId, + kind: mapHallKindToRoomKind(input.message.kind), + authorRole: toRoomParticipantRole(input.participant), + authorLabel: input.participant.displayName, + content: input.message.content, + sessionKey: input.runtimeResult.sessionKey, + payload: { + proposal: input.message.payload?.proposal, + decision: input.message.payload?.decision, + doneWhen: input.message.payload?.doneWhen, + status: input.message.payload?.status, + taskStatus: input.message.payload?.taskStatus ?? taskCard.status, + reviewOutcome: input.message.payload?.reviewOutcome, + sessionKey: input.runtimeResult.sessionKey, + sourceSessionKey: input.runtimeResult.sessionKey, + }, + }); + } + + return taskCard; +} + +function mergeTaskArtifacts(existing: TaskArtifact[] | undefined, incoming: TaskArtifact[] | undefined): TaskArtifact[] { + const merged = new Map(); + for (const artifact of [...(existing ?? []), ...(incoming ?? [])]) { + if (!artifact || !artifact.location) continue; + const normalized: TaskArtifact = { + artifactId: artifact.artifactId?.trim() || artifact.location.trim(), + type: artifact.type, + label: artifact.label?.trim() || artifact.location.trim(), + location: artifact.location.trim(), + }; + merged.set(normalized.artifactId, normalized); + } + return Array.from(merged.values()); +} + +function sameTaskArtifacts(left: TaskArtifact[] | undefined, right: TaskArtifact[] | undefined): boolean { + return JSON.stringify(mergeTaskArtifacts(left, [])) === JSON.stringify(mergeTaskArtifacts(right, [])); +} + +function mapHallKindToRoomKind(kind: HallMessage["kind"]): MessageKind { + switch (kind) { + case "proposal": + case "decision": + case "handoff": + case "status": + case "result": + return kind; + default: + return "chat"; + } +} + +function buildGeneratedHallReply( + hall: CollaborationHall, + taskCard: HallTaskCard, + participant: HallParticipant, + task: ProjectTask | undefined, +): { kind: HallMessage["kind"]; content: string; payload?: HallMessage["payload"] } { + const language = inferHallResponseLanguage(`${taskCard.title}\n${taskCard.description}\n${task?.title ?? ""}`); + const title = taskCard.title; + const domain = inferHallDiscussionDomain(taskCard, task); + if (taskCard.stage === "execution") { + return { + kind: "status", + content: language === "zh" + ? `${participant.displayName} 继续做这一棒,下一条只贴结果和下一步。` + : `${participant.displayName} is on this step and will post the concrete result next.`, + payload: { + taskStage: "execution", + taskStatus: "in_progress", + nextOwnerParticipantId: participant.participantId, + status: "execution_update", + }, + }; + } + if (taskCard.stage === "review") { + return { + kind: "review", + content: language === "zh" + ? `${participant.displayName} 先只挑必须改的点;没硬伤就直接让下一步继续。` + : `${participant.displayName} is checking only the must-fix issues; if there is no hard blocker, the next step should continue.`, + payload: { + taskStage: "review", + taskStatus: taskCard.status, + status: "review_in_progress", + }, + }; + } + if (participant.semanticRole === "planner") { + const proposal = buildPlannerDiscussionProposal(taskCard, domain, language); + return { + kind: "proposal", + content: proposal, + payload: { + proposal, + taskStage: "discussion", + taskStatus: taskCard.status, + }, + }; + } + if (participant.semanticRole === "coder") { + const proposal = buildImplementerDiscussionProposal(taskCard, domain, language); + return { + kind: "proposal", + content: proposal, + payload: { + proposal, + taskStage: "discussion", + taskStatus: taskCard.status, + }, + }; + } + if (participant.semanticRole === "reviewer") { + const proposal = buildReviewerDiscussionProposal(taskCard, domain, language); + return { + kind: "proposal", + content: proposal, + payload: { + proposal, + taskStage: "discussion", + taskStatus: taskCard.status, + }, + }; + } + if (participant.semanticRole === "manager") { + const executor = pickRecommendedExecutor(hall, taskCard, task); + const suggestedPlan = buildSuggestedExecutionPlan(hall, taskCard, executor.participantId, task); + const executionOrder = suggestedPlan.executionOrder; + const executionItems = suggestedPlan.executionItems; + const decision = buildManagerDiscussionDecision(taskCard, domain, executor.displayName, language); + const preservedProposal = taskCard.proposal?.trim() + || taskCard.latestSummary?.trim() + || (language === "zh" + ? `先把“${title}”这一轮讨论收成一版可执行方案。` + : `Turn this discussion about "${title}" into an executable first plan.`); + const doneWhen = task?.definitionOfDone.length + ? task.definitionOfDone.join("; ") + : buildSuggestedDoneWhen(taskCard, domain, language); + const actionSummary = executionItems.map((item, index) => { + const participantLabel = findParticipant(hall.participants, item.participantId)?.displayName ?? item.participantId; + const nextLabel = item.handoffToParticipantId + ? (findParticipant(hall.participants, item.handoffToParticipantId)?.displayName ?? item.handoffToParticipantId) + : undefined; + return language === "zh" + ? `${index + 1}. ${participantLabel}:${item.task}${nextLabel ? `;然后交给 ${nextLabel}` : ""}` + : `${index + 1}. ${participantLabel}: ${item.task}${nextLabel ? `; then hand off to ${nextLabel}` : ""}`; + }).join(language === "zh" ? ";" : "; "); + return { + kind: "decision", + content: language === "zh" + ? `${decision} 行动项:${actionSummary}。完成标准:${doneWhen}。` + : `${decision} Action items: ${actionSummary}. Done when: ${doneWhen}.`, + payload: { + proposal: preservedProposal, + decision, + doneWhen, + executionOrder, + executionItems, + nextOwnerParticipantId: executor.participantId, + taskStage: "discussion", + taskStatus: taskCard.status, + }, + }; + } + return { + kind: "chat", + content: language === "zh" + ? `${participant.displayName} 已经就位;如果需要补充这个话题的特定视角,可以继续点名我。` + : `${participant.displayName} is available for targeted input on "${title}" if needed.`, + payload: { + taskStage: taskCard.stage, + taskStatus: taskCard.status, + }, + }; +} + +function inferHallDiscussionDomain(taskCard: HallTaskCard, task: ProjectTask | undefined): HallDiscussionDomain { + return inferHallDiscussionDomainFromText(`${taskCard.title}\n${taskCard.description}\n${task?.title ?? ""}`); +} + +function buildPlannerDiscussionProposal(taskCard: HallTaskCard, domain: HallDiscussionDomain, language: HallResponseLanguage): string { + const title = taskCard.title; + if (language === "zh") { + if (domain === "creative") return `关于“${title}”,我想先锁定 brief:目标受众、核心信息、风格边界、输出形式,以及第一版能否成立的样片长什么样。我的建议是把它当成一个创意制作任务:brief -> 分镜 -> 第一版动画样片。`; + if (domain === "analysis") return `关于“${title}”,我想先澄清我们要回答什么问题、面向谁、数据从哪里来,以及最终需要交付什么样的产物。先在大厅里把叙事和输出形态对齐,再决定谁去做第一版。`; + if (domain === "product") return `关于“${title}”,我想先钉住用户问题、成功标准、约束,以及最小但有意义的第一步。我们先在大厅里把范围说清楚,再指派一个 owner 做第一版。`; + if (domain === "research") return `关于“${title}”,我想先定义研究问题、需要的证据、预期深度,以及最后要形成什么结论。先把调查视角和结构对齐,再安排采集、整理和审核顺序。`; + if (domain === "operations") return `关于“${title}”,我想先澄清流程、相关方、交接点和潜在失效点。先在大厅里把执行路径讲清楚,再指派一个 owner 驱动第一段。`; + if (domain === "engineering") return `关于“${title}”,我想先澄清目标、约束、接口和最小可落地切片。先在大厅里把范围收口,再指派一个 owner 做第一版实现。`; + return `关于“${title}”,我想先澄清目标、受众、约束,以及“完成”到底意味着什么。先在大厅里把事情说透,再安排第一位执行者。`; + } + if (domain === "creative") { + return `For "${title}", I want to lock the brief before execution: audience, message, style boundary, output format, and what a first convincing motion sample looks like. I suggest we treat this as a creative-production task: brief -> storyboard -> first animation pass.`; + } + if (domain === "analysis") { + return `For "${title}", I want to clarify the question we are answering, the audience, the data sources, and the artifact we need to deliver. I suggest we align on the narrative and the final output shape before anyone starts building.`; + } + if (domain === "product") { + return `For "${title}", I want to pin down the user problem, success criteria, constraints, and the smallest plan that moves the decision forward. We should align the scope in the hall first, then choose one execution owner for the first pass.`; + } + if (domain === "research") { + return `For "${title}", I want to define the research question, evidence we need, depth expected, and what kind of conclusion will be useful. I suggest we agree on the investigation lens first, then sequence collection, synthesis, and review.`; + } + if (domain === "operations") { + return `For "${title}", I want to clarify the workflow, stakeholders, handoff points, and failure modes before execution starts. We should first agree on the operating path in the hall, then assign one owner to drive the first slice.`; + } + if (domain === "engineering") { + return `For "${title}", I want to clarify the outcome, constraints, interfaces, and the smallest safe slice before execution starts. We should align the scope in the hall first, then assign one owner for the first implementation pass.`; + } + return `For "${title}", I want to clarify the outcome, audience, constraints, and what “done” means before anyone executes. We should use this hall discussion to scope the work, then assign one owner for the first concrete pass.`; +} + +function buildImplementerDiscussionProposal(taskCard: HallTaskCard, domain: HallDiscussionDomain, language: HallResponseLanguage): string { + const title = taskCard.title; + if (language === "zh") { + if (domain === "creative") return `“${title}”比较务实的执行路径是:把 brief 转成分镜,选定视觉语言,做一小段 animatic 或 motion sample,再根据反馈迭代。这样第一版就能形成一个可评审的交付物,而不是一开始就把全片做满。`; + if (domain === "analysis") return `“${title}”比较务实的执行路径是:先确认可信数据,快速搭叙事,再做一个轻量可视化原型,然后根据故事是否成立和是否符合受众来迭代。`; + if (domain === "product") return `“${title}”比较务实的执行路径是:先把讨论沉淀成一个短计划,再做一个具体的草稿或原型,用它验证方向,再决定是否扩 scope。这样第一位 owner 会很清楚自己要交什么。`; + if (domain === "research") return `“${title}”比较务实的执行路径是:先拿到最小可用的证据集,再把模式整理出来,最后转换成清晰建议。第一位 owner 应该留下可分享的综合结论,而不是一堆原始笔记。`; + if (domain === "operations") return `“${title}”比较务实的执行路径是:先画出当前流程,提出下一版流程,再验证最容易出问题的 handoff。这样第一段执行就会足够小,也便于观察。`; + if (domain === "engineering") return `“${title}”比较务实的执行路径是:先选最小可交付切片,端到端做通一遍,把证据和细节放进关联线程。这样一个 owner 就能快速前进,又不会丢可追踪性。`; + return `“${title}”比较务实的执行路径是:先做一个能证明方向的具体产物,再根据讨论反馈继续迭代。第一位 owner 要交付的是“可评审的第一版”,不是一次性做到底。`; + } + if (domain === "creative") { + return `A practical execution path for "${title}" is: turn the brief into a storyboard, pick a visual language, make one short animatic or motion sample, then iterate after feedback. That gives us a concrete first deliverable without overcommitting the whole production.`; + } + if (domain === "analysis") { + return `A practical execution path for "${title}" is: confirm the data we can trust, sketch the narrative, build one lightweight prototype of the visualization, then refine it after we validate the story and audience fit.`; + } + if (domain === "product") { + return `A practical execution path for "${title}" is: turn the discussion into a short plan, create one tangible draft or prototype, and use that to validate the direction before expanding scope. That keeps the first owner focused on one concrete deliverable.`; + } + if (domain === "research") { + return `A practical execution path for "${title}" is: collect the minimum viable evidence set, summarize the patterns, and convert that into a clear recommendation. The first owner should leave behind a shareable synthesis, not just raw notes.`; + } + if (domain === "operations") { + return `A practical execution path for "${title}" is: map the current workflow, propose the next-state flow, and validate the first risky handoff before rolling the whole process out. That keeps the first execution slice small and observable.`; + } + if (domain === "engineering") { + return `A practical execution path for "${title}" is: choose the smallest shippable slice, implement it end to end, and keep details or evidence in the linked thread. That lets one owner move quickly without losing traceability.`; + } + return `A practical execution path for "${title}" is: create one concrete first deliverable that proves the direction, then iterate after discussion feedback. The first owner should focus on something reviewable, not boil the ocean.`; +} + +function buildReviewerDiscussionProposal(taskCard: HallTaskCard, domain: HallDiscussionDomain, language: HallResponseLanguage): string { + const title = taskCard.title; + if (language === "zh") { + if (domain === "creative") return `我这边的审视重点是:受众、视觉调性、时长和审核节点是否已经明确;第一版是不是有过度展开的风险;以及什么样的反馈才算方向验证通过。`; + if (domain === "analysis") return `我这边的审视重点是:数据是否可靠、叙事是否诚实、受众是否能看懂,以及选定的交付物是否真的能支撑我们要做的判断。`; + if (domain === "product") return `我这边的审视重点是:我们是不是在解决正确的问题、第一版范围是否足够小,以及有哪些依赖或假设会让这一步失效。`; + if (domain === "research") return `我这边的审视重点是:问题是否足够尖锐、证据计划是否可信,以及产出是否会形成可用结论,而不是只堆一堆观察。`; + if (domain === "operations") return `我这边的审视重点是:流程会卡在哪、哪些 handoff 最容易掉链子,以及哪些责任或审批必须先说清楚。`; + if (domain === "engineering") return `我这边的审视重点是:scope 控制、回归风险、owner 边界,以及第一版是不是足够小,能不能被干净地评审。`; + return `我这边的审视重点是:范围是否清楚、有哪些隐藏假设、谁需要评审第一版,以及当前提出的第一步是否已经足够具体。`; + } + if (domain === "creative") { + return `Review focus for "${title}": are we clear on audience, visual tone, duration, and approval checkpoints; are we about to over-scope the first pass; and do we know what feedback would validate the direction?`; + } + if (domain === "analysis") { + return `Review focus for "${title}": are the data sources reliable, is the narrative honest, will the audience understand the output, and are we choosing a deliverable that can actually support the decision we care about?`; + } + if (domain === "product") { + return `Review focus for "${title}": are we solving the right user problem, is the scope small enough for one owner, and what dependencies or assumptions could invalidate the proposed first step?`; + } + if (domain === "research") { + return `Review focus for "${title}": are the questions sharp enough, is the evidence plan credible, and is the output likely to produce a usable conclusion instead of a pile of observations?`; + } + if (domain === "operations") { + return `Review focus for "${title}": where can the workflow stall, who can be blocked by a bad handoff, and what needs explicit ownership or approval before we say the first slice is done?`; + } + if (domain === "engineering") { + return `Review focus for "${title}": scope control, regression risk, ownership boundaries, and whether the first pass is small enough to review cleanly before it expands.`; + } + return `Review focus for "${title}": scope clarity, hidden assumptions, who needs to review the first deliverable, and whether the proposed first pass is concrete enough to evaluate.`; +} + +function buildManagerDiscussionDecision(taskCard: HallTaskCard, domain: HallDiscussionDomain, executorLabel: string, language: HallResponseLanguage): string { + if (language === "zh") { + if (domain === "creative") return `我们应该先在大厅里把 brief 锁住,再把第一段创意制作交给 ${executorLabel}。第一位 owner 的目标不是一次做完整,而是拿出可评审的分镜或 motion sample。`; + if (domain === "analysis") return `我们应该先在大厅里把叙事和输出形式对齐,再把第一段分析或原型交给 ${executorLabel}。第一版的目标是形成可评审的可视化方向,而不是最后成片。`; + if (domain === "product") return `我们应该先在大厅里把问题、范围和成功标准收口,再让 ${executorLabel} 去完成第一版可落地方案。第一段执行应该足够小,能快速被评审。`; + if (domain === "research") return `我们应该先在大厅里把研究问题和证据标准定清楚,再让 ${executorLabel} 去推动第一轮采集与整理。第一版要交的是能支撑决策的综合判断。`; + if (domain === "operations") return `我们应该先在大厅里把流程、交接和审批说透,再让 ${executorLabel} 去跑第一段受控流程。这样更容易看见阻塞点和责任边界。`; + if (domain === "engineering") return `我们应该先在大厅里把范围和接口收口,再让 ${executorLabel} 做第一版最小可交付切片。先把端到端一条链跑通,再扩展。`; + return `我们应该先在大厅里把目标、约束和执行方式对齐,再让 ${executorLabel} 做第一版具体产物。先拿到一个可评审的结果,再决定后续扩展。`; + } + if (domain === "creative") { + return `We should keep this in discussion long enough to lock the brief, then move the first creative-production pass to ${executorLabel}. The first owner should produce a reviewable storyboard or motion sample instead of trying to finish the whole piece at once.`; + } + if (domain === "analysis") { + return `We should align the narrative and output shape here in the hall, then move the first analysis or prototype pass to ${executorLabel}. The goal of that first pass is a reviewable visualization direction, not final polish.`; + } + if (domain === "product") { + return `We should settle the goal and first deliverable in the hall, then hand the initial execution slice to ${executorLabel}. That first pass should validate direction quickly before the scope widens.`; + } + if (domain === "research") { + return `We should align on the investigation lens here, then hand the first evidence-collection and synthesis pass to ${executorLabel}. The first owner should return with a reviewable recommendation, not just raw material.`; + } + if (domain === "operations") { + return `We should agree on the workflow and handoff points here, then move the first operational pass to ${executorLabel}. The first owner should validate one controlled slice before we scale the process.`; + } + if (domain === "engineering") { + return `We should keep the decision in the hall, assign the first focused implementation slice to ${executorLabel}, and continue using the linked thread only for detail and evidence.`; + } + return `We should settle the direction in the hall, move the first focused execution slice to ${executorLabel}, and keep the rest of the team in review and handoff roles until the first deliverable lands.`; +} + +function buildSuggestedDoneWhen(taskCard: HallTaskCard, domain: HallDiscussionDomain, language: HallResponseLanguage): string { + if (language === "zh") { + if (domain === "creative") return `针对“${taskCard.title}”,需要有明确受众、核心叙事、风格方向,以及一版可评审的分镜或动画样片。`; + if (domain === "analysis") return `针对“${taskCard.title}”,需要有可信数据范围、清楚叙事、第一版可评审可视化方向,以及下一步迭代建议。`; + if (domain === "product") return `针对“${taskCard.title}”,需要有明确的问题定义、第一版行动方案或原型,以及对下一步验证路径的清楚说明。`; + if (domain === "research") return `针对“${taskCard.title}”,需要有研究问题、关键证据、综合结论,以及下一步建议。`; + if (domain === "operations") return `针对“${taskCard.title}”,需要有清楚的流程设计、关键责任边界、第一段试运行结果,以及后续调整建议。`; + if (domain === "engineering") return `针对“${taskCard.title}”,需要有一条端到端可运行的第一版、必要证据,以及明确的下一步实现计划。`; + return `针对“${taskCard.title}”,需要有一个可评审的第一版结果、明确的完成标准,以及清楚的下一步。`; + } + if (domain === "creative") { + return `the brief is clear, a first storyboard or motion sample exists, and the hall has agreed on the next production step for "${taskCard.title}"`; + } + if (domain === "analysis") { + return `there is a reviewable data narrative, a first visualization direction, and a clear next step for refining "${taskCard.title}"`; + } + if (domain === "product") { + return `the goal, first deliverable, and owner sequence are clear, and the first pass for "${taskCard.title}" is ready for review`; + } + if (domain === "research") { + return `the key question is answered with enough evidence to recommend a next move for "${taskCard.title}"`; + } + if (domain === "operations") { + return `the first workflow slice for "${taskCard.title}" has a clear owner, observable result, and known next handoff`; + } + if (domain === "engineering") { + return `the first implementation slice for "${taskCard.title}" is complete, reviewable, and its evidence is linked back into the hall`; + } + return `the first focused deliverable for "${taskCard.title}" is reviewable, the next owner is clear, and the hall has a concrete next step`; +} + +function inferHallResponseLanguage(source: string | undefined): HallResponseLanguage { + const value = String(source ?? "").trim(); + if (!value) return "en"; + const cjkMatches = value.match(/[\u4e00-\u9fff]/g) ?? []; + const latinMatches = value.match(/[A-Za-z]/g) ?? []; + if (cjkMatches.length > 0) return "zh"; + if (latinMatches.length > 0) return "en"; + return "en"; +} + +function buildSuggestedExecutionOrder( + hall: CollaborationHall, + taskCard: HallTaskCard, + recommendedOwnerParticipantId: string, +): string[] { + const ordered = [ + recommendedOwnerParticipantId, + ...taskCard.requiresInputFrom, + ...taskCard.mentionedParticipantIds, + ]; + return sanitizeExecutionOrder(hall.participants, ordered); +} + +function buildDynamicDiscussionParticipantQueue( + hall: CollaborationHall, + taskCard: HallTaskCard, + task?: ProjectTask, + triggerText?: string, +): string[] { + const domain = inferHallDiscussionDomain(taskCard, task); + const normalizedTrigger = triggerText ?? `${taskCard.title}\n${taskCard.description}`; + const wantsContinuation = requestsDiscussionContinuation(normalizedTrigger); + const wantsDecision = + !wantsContinuation + && classifyHallDiscussionFollowupIntent(normalizedTrigger) === "decision_request"; + const ordered: string[] = []; + const seen = new Set(); + const push = (participantId: string | undefined) => { + if (!participantId || seen.has(participantId)) return; + const participant = findParticipant(hall.participants, participantId); + if (!participant || !participant.active) return; + seen.add(participantId); + ordered.push(participantId); + }; + + if (wantsDecision) { + push(pickPrimaryParticipantByRole(hall.participants, "manager")?.participantId); + } + for (const participantId of [...taskCard.mentionedParticipantIds, ...taskCard.requiresInputFrom]) { + push(participantId); + } + for (const role of discussionRoleOrderForDomain(domain).filter((role) => wantsDecision || role !== "manager")) { + push(pickParticipantForRole(hall.participants, role)?.participantId); + } + if (wantsDecision) { + push(pickPrimaryParticipantByRole(hall.participants, "manager")?.participantId); + } + + if (ordered.length < 2) { + for (const role of ["planner", "coder", "reviewer", "generalist", "manager"] as HallSemanticRole[]) { + if (!wantsDecision && role === "manager") continue; + push(pickParticipantForRole(hall.participants, role)?.participantId); + if (ordered.length >= 2) break; + } + } + + return ordered.slice(0, wantsDecision ? 3 : 2); +} + +function discussionRoleOrderForDomain(domain: HallDiscussionDomain): HallSemanticRole[] { + if (domain === "engineering") return ["planner", "coder", "reviewer", "manager"]; + if (domain === "creative") return ["planner", "coder", "manager"]; + if (domain === "analysis") return ["planner", "coder", "manager"]; + if (domain === "product") return ["planner", "reviewer", "manager"]; + if (domain === "research") return ["planner", "reviewer", "manager"]; + if (domain === "operations") return ["planner", "reviewer", "manager"]; + return ["planner", "manager"]; +} + +function recommendedExecutorRoleOrder(domain: HallDiscussionDomain): HallSemanticRole[] { + if (domain === "engineering") return ["coder", "planner", "manager"]; + if (domain === "creative") return ["planner", "coder", "generalist"]; + if (domain === "analysis") return ["planner", "coder", "reviewer"]; + if (domain === "product") return ["planner", "manager", "coder"]; + if (domain === "research") return ["planner", "reviewer", "manager"]; + if (domain === "operations") return ["manager", "planner", "reviewer"]; + return ["planner", "generalist", "manager", "coder"]; +} + +function pickParticipantForRole( + participants: HallParticipant[], + role: HallSemanticRole, +): HallParticipant | undefined { + if (role === "generalist") { + return participants.find((participant) => participant.active && participant.semanticRole === "generalist"); + } + return pickPrimaryParticipantByRole(participants, role); +} + +async function ensureHallContext(hallId = DEFAULT_COLLABORATION_HALL_ID): Promise<{ hall: CollaborationHall }> { + const roster = await loadBestEffortAgentRoster(); + const participants = resolveHallParticipantsFromRoster(roster.entries); + await ensureDefaultCollaborationHall(participants); + const store = await loadCollaborationHallStore(); + const hall = store.halls.find((item) => item.hallId === hallId) ?? store.halls.find((item) => item.hallId === DEFAULT_COLLABORATION_HALL_ID); + if (!hall) { + throw new CollaborationHallStoreValidationError(`hall '${hallId}' was not found.`, ["hallId"], 404); + } + return { hall }; +} + +async function requireHall(hallId: string): Promise { + return (await ensureHallContext(hallId)).hall; +} + +async function requireTaskCard(taskCardId: string): Promise { + const store = await loadCollaborationTaskCardStore(); + const taskCard = getHallTaskCard(store, taskCardId); + if (!taskCard) { + throw new CollaborationHallStoreValidationError(`task card '${taskCardId}' was not found.`, ["taskCardId"], 404); + } + return taskCard; +} + +async function requireTaskCardByProjectTask(projectId: string, taskId: string): Promise { + const store = await loadCollaborationTaskCardStore(); + const taskCard = getHallTaskCardByTask(store, projectId, taskId); + if (!taskCard) { + throw new CollaborationHallStoreValidationError(`task '${projectId}:${taskId}' does not have a hall task card yet.`, ["taskId"], 404); + } + return taskCard; +} + +async function refreshHallAndTaskSummary( + hallId: string, + taskCard: HallTaskCard, +): Promise<{ hall: CollaborationHall; hallSummary: CollaborationHallSummary; taskCard: HallTaskCard; taskSummary: HallTaskSummary }> { + const hall = await requireHall(hallId); + const [messageStore, taskCardStore] = await Promise.all([ + loadCollaborationHallMessageStore(), + loadCollaborationTaskCardStore(), + ]); + const taskCards = listHallTaskCards(taskCardStore, { hallId }); + const updatedTaskCard = taskCards.find((item) => item.taskCardId === taskCard.taskCardId) ?? taskCard; + const messages = listHallMessages(messageStore, { hallId }); + const hallSummary = (await upsertCollaborationHallSummary(hall, messages, taskCards)).summary; + const taskSummary = (await upsertHallTaskSummary(updatedTaskCard, messages)).summary; + return { + hall, + hallSummary, + taskCard: updatedTaskCard, + taskSummary, + }; +} + +async function ensureHallProject(projectId: string): Promise { + const store = await loadProjectStore(); + if (store.projects.some((project) => project.projectId === projectId)) return; + const now = new Date().toISOString(); + store.projects.push({ + projectId, + title: projectId === DEFAULT_COLLABORATION_HALL_PROJECT_ID ? "Collaboration Hall" : projectId, + status: "active", + owner: "operator", + budget: {}, + updatedAt: now, + }); + store.updatedAt = now; + await saveProjectStore(store); +} + +function findParticipant(participants: HallParticipant[], participantId: string | undefined): HallParticipant | undefined { + if (!participantId) return undefined; + const normalized = String(participantId).trim().toLowerCase(); + return participants.find((participant) => { + if (participant.participantId === participantId) return true; + if (participant.displayName.trim().toLowerCase() === normalized) return true; + return participant.aliases.some((alias) => alias.trim().toLowerCase() === normalized); + }); +} + +function requireHallParticipant( + participants: HallParticipant[], + participantId: string, + field: string, +): HallParticipant { + const participant = findParticipant(participants, participantId); + if (!participant) { + throw new CollaborationHallStoreValidationError(`participant '${participantId}' was not found.`, [field], 404); + } + return participant; +} + +function pickRecommendedExecutor( + hall: CollaborationHall, + taskCard: HallTaskCard, + task?: ProjectTask, +): HallParticipant { + if (taskCard.currentOwnerParticipantId) { + const existing = findParticipant(hall.participants, taskCard.currentOwnerParticipantId); + if (existing) return existing; + } + const domain = inferHallDiscussionDomain(taskCard, task); + for (const role of recommendedExecutorRoleOrder(domain)) { + const participant = pickParticipantForRole(hall.participants, role); + if (participant) return participant; + } + return pickPrimaryParticipantByRole(hall.participants, "planner") + ?? pickPrimaryParticipantByRole(hall.participants, "coder") + ?? hall.participants[0] + ?? { + participantId: "operator", + displayName: "Operator", + semanticRole: "generalist", + active: true, + aliases: ["Operator", "operator"], + isHuman: true, + }; +} + +function deriveTaskTitle(content: string): string { + const cleaned = content.trim().replace(/\s+/g, " "); + if (!cleaned) return "Untitled hall task"; + const firstSentence = cleaned.split(/[\n。!?!?]/u).find((part) => part.trim().length > 0)?.trim() ?? cleaned; + return firstSentence.length > 90 ? `${firstSentence.slice(0, 87)}...` : firstSentence; +} + +function buildTaskId(content: string): string { + const slug = content + .trim() + .toLowerCase() + .replace(/[^a-z0-9._:-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + const base = slug || "hall-task"; + return `${base}-${randomUUID().slice(0, 8)}`; +} + +function normalizeTaskKey(value: string | undefined): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function toRoomParticipantRole(participant: HallParticipant): RoomParticipantRole { + if (participant.semanticRole === "planner") return "planner"; + if (participant.semanticRole === "reviewer") return "reviewer"; + if (participant.semanticRole === "manager") return "manager"; + return "coder"; +} + +function truncateLinkedRoomHandoffNote(note: string): string { + const trimmed = note.trim(); + if (!trimmed) return trimmed; + return trimmed.length > 320 ? `${trimmed.slice(0, 317).trimEnd()}...` : trimmed; +} + +async function requireLinkedRoom(roomId: string) { + const store = await loadChatRoomStore(); + const room = getChatRoom(store, roomId); + if (!room) { + throw new CollaborationHallStoreValidationError(`linked room '${roomId}' was not found.`, ["roomId"], 404); + } + return room; +} diff --git a/src/runtime/collaboration-hall-store.ts b/src/runtime/collaboration-hall-store.ts new file mode 100644 index 0000000..4faae0b --- /dev/null +++ b/src/runtime/collaboration-hall-store.ts @@ -0,0 +1,1300 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { getRuntimeDir, resolveRuntimePath } from "./runtime-path"; +import { publishHallStreamEvent } from "./collaboration-stream"; +import type { + CollaborationHall, + CollaborationHallMessageStoreSnapshot, + CollaborationHallStoreSnapshot, + CollaborationTaskCardStoreSnapshot, + HallMessage, + HallMessageKind, + HallParticipant, + HallTaskCard, + HallTaskStage, + MentionTarget, +} from "../types"; + +const RUNTIME_DIR = getRuntimeDir(); +export const COLLABORATION_HALLS_PATH = resolveRuntimePath("collaboration-halls.json"); +export const COLLABORATION_HALL_MESSAGES_PATH = resolveRuntimePath("collaboration-hall-messages.json"); +export const COLLABORATION_TASK_CARDS_PATH = resolveRuntimePath("collaboration-task-cards.json"); +export const DEFAULT_COLLABORATION_HALL_ID = "main"; + +const EMPTY_HALL_STORE: CollaborationHallStoreSnapshot = { + halls: [], + executionLocks: [], + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +const EMPTY_MESSAGE_STORE: CollaborationHallMessageStoreSnapshot = { + messages: [], + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +const EMPTY_TASK_CARD_STORE: CollaborationTaskCardStoreSnapshot = { + taskCards: [], + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +const HALL_MESSAGE_KINDS: HallMessageKind[] = [ + "chat", + "task", + "proposal", + "decision", + "handoff", + "status", + "review", + "result", + "system", +]; +const HALL_TASK_STAGES: HallTaskStage[] = ["discussion", "execution", "review", "blocked", "completed"]; + +export class CollaborationHallStoreValidationError extends Error { + readonly issues: string[]; + readonly statusCode: number; + + constructor(message: string, issues: string[] = [], statusCode = 400) { + super(message); + this.name = "CollaborationHallStoreValidationError"; + this.issues = issues; + this.statusCode = statusCode; + } +} + +export interface CreateHallTaskCardInput { + hallId?: string; + taskCardId?: string; + projectId: string; + taskId: string; + roomId?: string; + title: string; + description: string; + stage?: HallTaskStage; + status?: HallTaskCard["status"]; + createdByParticipantId: string; + currentOwnerParticipantId?: string; + currentOwnerLabel?: string; + mentionedParticipantIds?: string[]; + plannedExecutionOrder?: string[]; + plannedExecutionItems?: HallTaskCard["plannedExecutionItems"]; + currentExecutionItem?: HallTaskCard["currentExecutionItem"]; + requiresInputFrom?: string[]; + blockers?: string[]; + proposal?: string; + decision?: string; + doneWhen?: string; + sessionKeys?: string[]; +} + +export interface UpdateHallTaskCardInput { + hallId?: string; + taskCardId: string; + roomId?: string | null; + title?: string; + description?: string; + stage?: HallTaskStage; + status?: HallTaskCard["status"]; + currentOwnerParticipantId?: string | null; + currentOwnerLabel?: string | null; + mentionedParticipantIds?: string[]; + plannedExecutionOrder?: string[]; + plannedExecutionItems?: HallTaskCard["plannedExecutionItems"]; + currentExecutionItem?: HallTaskCard["currentExecutionItem"] | null; + requiresInputFrom?: string[]; + blockers?: string[]; + proposal?: string | null; + decision?: string | null; + doneWhen?: string | null; + latestSummary?: string | null; + discussionCycle?: HallTaskCard["discussionCycle"] | null; + executionLock?: HallTaskCard["executionLock"] | null; + sessionKeys?: string[]; + archivedAt?: string | null; + archivedByParticipantId?: string | null; + archivedByLabel?: string | null; +} + +export interface ArchiveHallTaskCardInput { + taskCardId: string; + archivedByParticipantId?: string; + archivedByLabel?: string; +} + +export interface DeleteHallTaskCardInput { + taskCardId: string; +} + +export interface DeleteHallMessagesForTaskCardInput { + hallId?: string; + taskCardId: string; + taskId?: string; + roomId?: string; +} + +export interface AppendHallMessageInput { + hallId?: string; + messageId?: string; + kind?: HallMessageKind; + authorParticipantId: string; + authorLabel: string; + authorSemanticRole?: HallMessage["authorSemanticRole"]; + content: string; + targetParticipantIds?: string[]; + mentionTargets?: MentionTarget[]; + projectId?: string; + taskId?: string; + taskCardId?: string; + roomId?: string; + payload?: HallMessage["payload"]; + createdAt?: string; +} + +export async function loadCollaborationHallStore(): Promise { + try { + const raw = await readFile(COLLABORATION_HALLS_PATH, "utf8"); + return normalizeHallStore(JSON.parse(raw)); + } catch { + return cloneEmptyHallStore(); + } +} + +export async function loadCollaborationHallMessageStore(): Promise { + try { + const raw = await readFile(COLLABORATION_HALL_MESSAGES_PATH, "utf8"); + return normalizeHallMessageStore(JSON.parse(raw)); + } catch { + return cloneEmptyMessageStore(); + } +} + +export async function loadCollaborationTaskCardStore(): Promise { + try { + const raw = await readFile(COLLABORATION_TASK_CARDS_PATH, "utf8"); + return normalizeTaskCardStore(JSON.parse(raw)); + } catch { + return cloneEmptyTaskCardStore(); + } +} + +export async function saveCollaborationHallStore(next: CollaborationHallStoreSnapshot): Promise { + const normalized = normalizeHallStore({ + ...next, + updatedAt: new Date().toISOString(), + }); + await writeRuntimeJsonAtomically(COLLABORATION_HALLS_PATH, normalized); + return COLLABORATION_HALLS_PATH; +} + +export async function saveCollaborationHallMessageStore( + next: CollaborationHallMessageStoreSnapshot, +): Promise { + const normalized = normalizeHallMessageStore({ + ...next, + updatedAt: new Date().toISOString(), + }); + await writeRuntimeJsonAtomically(COLLABORATION_HALL_MESSAGES_PATH, normalized); + return COLLABORATION_HALL_MESSAGES_PATH; +} + +export async function saveCollaborationTaskCardStore(next: CollaborationTaskCardStoreSnapshot): Promise { + const normalized = normalizeTaskCardStore({ + ...next, + updatedAt: new Date().toISOString(), + }); + await writeRuntimeJsonAtomically(COLLABORATION_TASK_CARDS_PATH, normalized); + return COLLABORATION_TASK_CARDS_PATH; +} + +async function writeRuntimeJsonAtomically(targetPath: string, payload: unknown): Promise { + await mkdir(RUNTIME_DIR, { recursive: true }); + const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8"); + await rename(tempPath, targetPath); +} + +export async function ensureDefaultCollaborationHall(participants: HallParticipant[]): Promise { + const store = await loadCollaborationHallStore(); + const existing = store.halls.find((hall) => hall.hallId === DEFAULT_COLLABORATION_HALL_ID); + const now = new Date().toISOString(); + if (existing) { + existing.participants = normalizeParticipants(participants); + existing.updatedAt = now; + store.updatedAt = now; + await saveCollaborationHallStore(store); + return existing; + } + + const hall: CollaborationHall = { + hallId: DEFAULT_COLLABORATION_HALL_ID, + title: "Collaboration Hall", + description: "The shared control-center group chat for task discussion, assignment, execution, and review.", + participants: normalizeParticipants(participants), + taskCardIds: [], + messageIds: [], + lastMessageId: null, + createdAt: now, + updatedAt: now, + }; + store.halls.push(hall); + store.updatedAt = now; + await saveCollaborationHallStore(store); + return hall; +} + +export function getCollaborationHall( + store: CollaborationHallStoreSnapshot, + hallId = DEFAULT_COLLABORATION_HALL_ID, +): CollaborationHall | undefined { + return store.halls.find((hall) => hall.hallId === hallId); +} + +export function listCollaborationHalls(store: CollaborationHallStoreSnapshot): CollaborationHall[] { + return [...store.halls].sort((a, b) => a.hallId.localeCompare(b.hallId)); +} + +export function listHallMessages( + store: CollaborationHallMessageStoreSnapshot, + options?: { hallId?: string; taskCardId?: string; taskId?: string; limit?: number }, +): HallMessage[] { + const filtered = store.messages + .filter((message) => !options?.hallId || message.hallId === options.hallId) + .filter((message) => !options?.taskCardId || message.taskCardId === options.taskCardId) + .filter((message) => !options?.taskId || message.taskId === options.taskId) + .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); + return options?.limit ? filtered.slice(-options.limit) : filtered; +} + +export function listHallTaskCards( + store: CollaborationTaskCardStoreSnapshot, + options?: { hallId?: string; stage?: HallTaskStage; includeArchived?: boolean }, +): HallTaskCard[] { + return store.taskCards + .filter((card) => !options?.hallId || card.hallId === options.hallId) + .filter((card) => !options?.stage || card.stage === options.stage) + .filter((card) => options?.includeArchived === true || !card.archivedAt) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); +} + +export function getHallTaskCard( + store: CollaborationTaskCardStoreSnapshot, + taskCardId: string, +): HallTaskCard | undefined { + return store.taskCards.find((card) => card.taskCardId === taskCardId.trim()); +} + +export function getHallTaskCardByTask( + store: CollaborationTaskCardStoreSnapshot, + projectId: string, + taskId: string, +): HallTaskCard | undefined { + const normalizedProjectId = projectId.trim(); + const normalizedTaskId = taskId.trim(); + return store.taskCards.find((card) => card.projectId === normalizedProjectId && card.taskId === normalizedTaskId); +} + +export async function createHallTaskCard(input: CreateHallTaskCardInput): Promise<{ path: string; hallPath: string; taskCard: HallTaskCard }> { + const payload = validateCreateHallTaskCardInput(input); + const [hallStore, taskCardStore] = await Promise.all([ + loadCollaborationHallStore(), + loadCollaborationTaskCardStore(), + ]); + const hall = getCollaborationHall(hallStore, payload.hallId); + if (!hall) { + throw new CollaborationHallStoreValidationError(`hall '${payload.hallId}' was not found.`, ["hallId"], 404); + } + if (getHallTaskCardByTask(taskCardStore, payload.projectId, payload.taskId)) { + throw new CollaborationHallStoreValidationError( + `task '${payload.projectId}:${payload.taskId}' already has a hall task card.`, + ["taskId"], + 409, + ); + } + + const now = new Date().toISOString(); + const taskCard: HallTaskCard = { + hallId: hall.hallId, + taskCardId: payload.taskCardId ?? `${payload.projectId}:${payload.taskId}`, + projectId: payload.projectId, + taskId: payload.taskId, + roomId: payload.roomId, + title: payload.title, + description: payload.description, + stage: payload.stage ?? "discussion", + status: payload.status ?? "todo", + createdByParticipantId: payload.createdByParticipantId, + currentOwnerParticipantId: payload.currentOwnerParticipantId, + currentOwnerLabel: payload.currentOwnerLabel, + blockers: payload.blockers ?? [], + requiresInputFrom: payload.requiresInputFrom ?? [], + mentionedParticipantIds: payload.mentionedParticipantIds ?? [], + plannedExecutionOrder: payload.plannedExecutionOrder ?? [], + plannedExecutionItems: payload.plannedExecutionItems ?? [], + currentExecutionItem: payload.currentExecutionItem, + sessionKeys: payload.sessionKeys ?? [], + proposal: payload.proposal, + decision: payload.decision, + doneWhen: payload.doneWhen, + archivedAt: undefined, + archivedByParticipantId: undefined, + archivedByLabel: undefined, + createdAt: now, + updatedAt: now, + }; + + taskCardStore.taskCards.push(taskCard); + taskCardStore.updatedAt = now; + hall.taskCardIds = [...new Set([...hall.taskCardIds, taskCard.taskCardId])]; + hall.updatedAt = now; + hallStore.updatedAt = now; + + const [hallPath, path] = await Promise.all([ + saveCollaborationHallStore(hallStore), + saveCollaborationTaskCardStore(taskCardStore), + ]); + publishHallStreamEvent({ + type: "invalidate", + hallId: hall.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + reason: "task_created", + }); + return { hallPath, path, taskCard }; +} + +export async function updateHallTaskCard(input: UpdateHallTaskCardInput): Promise<{ path: string; taskCard: HallTaskCard }> { + const payload = validateUpdateHallTaskCardInput(input); + const taskCardStore = await loadCollaborationTaskCardStore(); + const taskCard = getHallTaskCard(taskCardStore, payload.taskCardId); + if (!taskCard) { + throw new CollaborationHallStoreValidationError(`task card '${payload.taskCardId}' was not found.`, ["taskCardId"], 404); + } + + const now = new Date().toISOString(); + if (payload.roomId !== undefined) taskCard.roomId = payload.roomId ?? undefined; + if (payload.title !== undefined) taskCard.title = payload.title; + if (payload.description !== undefined) taskCard.description = payload.description; + if (payload.stage !== undefined) taskCard.stage = payload.stage; + if (payload.status !== undefined) taskCard.status = payload.status; + if (payload.currentOwnerParticipantId !== undefined) { + taskCard.currentOwnerParticipantId = payload.currentOwnerParticipantId ?? undefined; + } + if (payload.currentOwnerLabel !== undefined) { + taskCard.currentOwnerLabel = payload.currentOwnerLabel ?? undefined; + } + if (payload.mentionedParticipantIds !== undefined) taskCard.mentionedParticipantIds = payload.mentionedParticipantIds; + if (payload.plannedExecutionOrder !== undefined) taskCard.plannedExecutionOrder = payload.plannedExecutionOrder; + if (payload.plannedExecutionItems !== undefined) taskCard.plannedExecutionItems = payload.plannedExecutionItems; + if (payload.currentExecutionItem !== undefined) taskCard.currentExecutionItem = payload.currentExecutionItem ?? undefined; + if (payload.requiresInputFrom !== undefined) taskCard.requiresInputFrom = payload.requiresInputFrom; + if (payload.blockers !== undefined) taskCard.blockers = payload.blockers; + if (payload.proposal !== undefined) taskCard.proposal = payload.proposal ?? undefined; + if (payload.decision !== undefined) taskCard.decision = payload.decision ?? undefined; + if (payload.doneWhen !== undefined) taskCard.doneWhen = payload.doneWhen ?? undefined; + if (payload.latestSummary !== undefined) taskCard.latestSummary = payload.latestSummary ?? undefined; + if (payload.discussionCycle !== undefined) taskCard.discussionCycle = payload.discussionCycle ?? undefined; + if (payload.executionLock !== undefined) taskCard.executionLock = payload.executionLock ?? undefined; + if (payload.sessionKeys !== undefined) taskCard.sessionKeys = payload.sessionKeys; + if (payload.archivedAt !== undefined) taskCard.archivedAt = payload.archivedAt ?? undefined; + if (payload.archivedByParticipantId !== undefined) { + taskCard.archivedByParticipantId = payload.archivedByParticipantId ?? undefined; + } + if (payload.archivedByLabel !== undefined) taskCard.archivedByLabel = payload.archivedByLabel ?? undefined; + taskCard.updatedAt = now; + taskCardStore.updatedAt = now; + + const path = await saveCollaborationTaskCardStore(taskCardStore); + publishHallStreamEvent({ + type: "invalidate", + hallId: taskCard.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + reason: "task_updated", + }); + return { path, taskCard }; +} + +export async function archiveHallTaskCard( + input: ArchiveHallTaskCardInput, +): Promise<{ path: string; taskCard: HallTaskCard }> { + const payload = validateArchiveHallTaskCardInput(input); + return updateHallTaskCard({ + taskCardId: payload.taskCardId, + archivedAt: new Date().toISOString(), + archivedByParticipantId: payload.archivedByParticipantId, + archivedByLabel: payload.archivedByLabel, + }); +} + +export async function deleteHallTaskCard( + input: DeleteHallTaskCardInput, +): Promise<{ path: string; hallPath: string; taskCard: HallTaskCard }> { + const payload = validateDeleteHallTaskCardInput(input); + const [hallStore, taskCardStore] = await Promise.all([ + loadCollaborationHallStore(), + loadCollaborationTaskCardStore(), + ]); + const taskCard = getHallTaskCard(taskCardStore, payload.taskCardId); + if (!taskCard) { + throw new CollaborationHallStoreValidationError(`task card '${payload.taskCardId}' was not found.`, ["taskCardId"], 404); + } + + taskCardStore.taskCards = taskCardStore.taskCards.filter((card) => card.taskCardId !== payload.taskCardId); + const now = new Date().toISOString(); + taskCardStore.updatedAt = now; + const hall = getCollaborationHall(hallStore, taskCard.hallId); + if (hall) { + hall.taskCardIds = hall.taskCardIds.filter((taskCardId) => taskCardId !== payload.taskCardId); + hall.updatedAt = now; + hallStore.updatedAt = now; + } + + const [hallPath, path] = await Promise.all([ + saveCollaborationHallStore(hallStore), + saveCollaborationTaskCardStore(taskCardStore), + ]); + publishHallStreamEvent({ + type: "invalidate", + hallId: taskCard.hallId, + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + roomId: taskCard.roomId, + reason: "task_deleted", + }); + return { hallPath, path, taskCard }; +} + +export async function deleteHallMessagesForTaskCard( + input: DeleteHallMessagesForTaskCardInput, +): Promise<{ path: string; hallPath: string; removedCount: number }> { + const payload = validateDeleteHallMessagesForTaskCardInput(input); + const [hallStore, messageStore] = await Promise.all([ + loadCollaborationHallStore(), + loadCollaborationHallMessageStore(), + ]); + const hall = getCollaborationHall(hallStore, payload.hallId); + if (!hall) { + throw new CollaborationHallStoreValidationError(`hall '${payload.hallId}' was not found.`, ["hallId"], 404); + } + + const nextMessages = messageStore.messages.filter((message) => { + if (message.hallId !== payload.hallId) return true; + if (message.taskCardId === payload.taskCardId) return false; + if (!message.taskCardId && payload.taskId && message.taskId === payload.taskId) { + if (!payload.roomId || message.roomId === payload.roomId) return false; + } + return true; + }); + const removedCount = messageStore.messages.length - nextMessages.length; + if (removedCount === 0) { + return { + path: COLLABORATION_HALL_MESSAGES_PATH, + hallPath: COLLABORATION_HALLS_PATH, + removedCount: 0, + }; + } + + const remainingHallMessages = nextMessages + .filter((message) => message.hallId === payload.hallId) + .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); + const lastMessage = remainingHallMessages.at(-1); + const now = new Date().toISOString(); + messageStore.messages = nextMessages; + messageStore.updatedAt = now; + hall.messageIds = remainingHallMessages.map((message) => message.messageId); + hall.lastMessageId = lastMessage?.messageId ?? null; + hall.latestMessageAt = lastMessage?.createdAt; + hall.updatedAt = now; + hallStore.updatedAt = now; + + const [hallPath, path] = await Promise.all([ + saveCollaborationHallStore(hallStore), + saveCollaborationHallMessageStore(messageStore), + ]); + publishHallStreamEvent({ + type: "invalidate", + hallId: payload.hallId, + taskCardId: payload.taskCardId, + taskId: payload.taskId, + roomId: payload.roomId, + reason: "task_messages_deleted", + }); + return { hallPath, path, removedCount }; +} + +export async function appendHallMessage(input: AppendHallMessageInput): Promise<{ path: string; hallPath: string; message: HallMessage }> { + const payload = validateAppendHallMessageInput(input); + const [hallStore, messageStore] = await Promise.all([ + loadCollaborationHallStore(), + loadCollaborationHallMessageStore(), + ]); + const hall = getCollaborationHall(hallStore, payload.hallId); + if (!hall) { + throw new CollaborationHallStoreValidationError(`hall '${payload.hallId}' was not found.`, ["hallId"], 404); + } + + const message: HallMessage = { + hallId: hall.hallId, + messageId: payload.messageId ?? randomUUID(), + kind: payload.kind ?? "chat", + authorParticipantId: payload.authorParticipantId, + authorLabel: payload.authorLabel, + authorSemanticRole: payload.authorSemanticRole, + content: payload.content, + targetParticipantIds: payload.targetParticipantIds ?? [], + mentionTargets: payload.mentionTargets ?? [], + projectId: payload.projectId, + taskId: payload.taskId, + taskCardId: payload.taskCardId, + roomId: payload.roomId, + payload: payload.payload, + createdAt: payload.createdAt ?? new Date().toISOString(), + }; + + messageStore.messages.push(message); + messageStore.updatedAt = message.createdAt; + hall.messageIds = [...new Set([...hall.messageIds, message.messageId])]; + hall.lastMessageId = message.messageId; + hall.latestMessageAt = message.createdAt; + hall.updatedAt = message.createdAt; + hallStore.updatedAt = message.createdAt; + + const [hallPath, path] = await Promise.all([ + saveCollaborationHallStore(hallStore), + saveCollaborationHallMessageStore(messageStore), + ]); + publishHallStreamEvent({ + type: "invalidate", + hallId: hall.hallId, + taskCardId: message.taskCardId, + projectId: message.projectId, + taskId: message.taskId, + roomId: message.roomId, + reason: "message_created", + messageId: message.messageId, + messageKind: message.kind, + authorParticipantId: message.authorParticipantId, + authorLabel: message.authorLabel, + authorSemanticRole: message.authorSemanticRole, + }); + return { hallPath, path, message }; +} + +function validateCreateHallTaskCardInput(input: CreateHallTaskCardInput): CreateHallTaskCardInput & { hallId: string } { + const issues: string[] = []; + const hallId = optionalString(input.hallId, "hallId", 120, issues) ?? DEFAULT_COLLABORATION_HALL_ID; + const projectId = requiredString(input.projectId, "projectId", 120, issues); + const taskId = requiredString(input.taskId, "taskId", 120, issues); + const roomId = optionalString(input.roomId, "roomId", 180, issues); + const title = requiredString(input.title, "title", 180, issues); + const description = requiredString(input.description, "description", 4000, issues); + const createdByParticipantId = requiredString(input.createdByParticipantId, "createdByParticipantId", 160, issues); + const taskCardId = optionalString(input.taskCardId, "taskCardId", 180, issues); + const currentOwnerParticipantId = optionalString(input.currentOwnerParticipantId, "currentOwnerParticipantId", 160, issues); + const currentOwnerLabel = optionalString(input.currentOwnerLabel, "currentOwnerLabel", 120, issues); + const stage = optionalHallTaskStage(input.stage, "stage", issues); + const status = optionalTaskStatus(input.status, "status", issues); + const blockers = optionalStringArray(input.blockers, "blockers", 240, issues); + const requiresInputFrom = optionalStringArray(input.requiresInputFrom, "requiresInputFrom", 120, issues); + const mentionedParticipantIds = optionalStringArray(input.mentionedParticipantIds, "mentionedParticipantIds", 120, issues); + const proposal = optionalString(input.proposal, "proposal", 1200, issues); + const decision = optionalString(input.decision, "decision", 1200, issues); + const doneWhen = optionalString(input.doneWhen, "doneWhen", 240, issues); + const plannedExecutionOrder = optionalStringArray(input.plannedExecutionOrder, "plannedExecutionOrder", 160, issues); + const plannedExecutionItems = optionalExecutionItems(input.plannedExecutionItems, "plannedExecutionItems", issues); + const currentExecutionItem = optionalExecutionItem(input.currentExecutionItem, "currentExecutionItem", issues); + const sessionKeys = optionalStringArray(input.sessionKeys, "sessionKeys", 240, issues); + + if (issues.length > 0) { + throw new CollaborationHallStoreValidationError("Invalid hall task card payload.", issues); + } + + return { + hallId, + taskCardId, + projectId, + taskId, + roomId, + title, + description, + stage, + status, + createdByParticipantId, + currentOwnerParticipantId, + currentOwnerLabel, + blockers, + requiresInputFrom, + mentionedParticipantIds, + plannedExecutionOrder, + plannedExecutionItems, + currentExecutionItem, + proposal, + decision, + doneWhen, + sessionKeys, + }; +} + +function validateUpdateHallTaskCardInput(input: UpdateHallTaskCardInput): UpdateHallTaskCardInput { + const issues: string[] = []; + const taskCardId = requiredString(input.taskCardId, "taskCardId", 180, issues); + const title = input.title === undefined ? undefined : requiredString(input.title, "title", 180, issues); + const description = + input.description === undefined ? undefined : requiredString(input.description, "description", 4000, issues); + const roomId = input.roomId === undefined + ? undefined + : input.roomId === null + ? null + : optionalString(input.roomId, "roomId", 180, issues); + const currentOwnerParticipantId = + input.currentOwnerParticipantId === undefined + ? undefined + : input.currentOwnerParticipantId === null + ? null + : optionalString(input.currentOwnerParticipantId, "currentOwnerParticipantId", 160, issues); + const currentOwnerLabel = + input.currentOwnerLabel === undefined + ? undefined + : input.currentOwnerLabel === null + ? null + : optionalString(input.currentOwnerLabel, "currentOwnerLabel", 120, issues); + const stage = input.stage === undefined ? undefined : optionalHallTaskStage(input.stage, "stage", issues); + const status = input.status === undefined ? undefined : optionalTaskStatus(input.status, "status", issues); + const blockers = input.blockers === undefined ? undefined : optionalStringArray(input.blockers, "blockers", 240, issues); + const requiresInputFrom = + input.requiresInputFrom === undefined + ? undefined + : optionalStringArray(input.requiresInputFrom, "requiresInputFrom", 120, issues); + const mentionedParticipantIds = + input.mentionedParticipantIds === undefined + ? undefined + : optionalStringArray(input.mentionedParticipantIds, "mentionedParticipantIds", 120, issues); + const plannedExecutionOrder = + input.plannedExecutionOrder === undefined + ? undefined + : optionalStringArray(input.plannedExecutionOrder, "plannedExecutionOrder", 160, issues); + const plannedExecutionItems = + input.plannedExecutionItems === undefined + ? undefined + : optionalExecutionItems(input.plannedExecutionItems, "plannedExecutionItems", issues); + const currentExecutionItem = + input.currentExecutionItem === undefined + ? undefined + : input.currentExecutionItem === null + ? null + : optionalExecutionItem(input.currentExecutionItem, "currentExecutionItem", issues); + const proposal = input.proposal === undefined + ? undefined + : input.proposal === null + ? null + : optionalString(input.proposal, "proposal", 1200, issues); + const decision = input.decision === undefined + ? undefined + : input.decision === null + ? null + : optionalString(input.decision, "decision", 1200, issues); + const doneWhen = input.doneWhen === undefined + ? undefined + : input.doneWhen === null + ? null + : optionalString(input.doneWhen, "doneWhen", 240, issues); + const latestSummary = + input.latestSummary === undefined + ? undefined + : input.latestSummary === null + ? null + : optionalString(input.latestSummary, "latestSummary", 600, issues); + const archivedAt = + input.archivedAt === undefined + ? undefined + : input.archivedAt === null + ? null + : optionalIsoString(input.archivedAt, "archivedAt", issues); + const archivedByParticipantId = + input.archivedByParticipantId === undefined + ? undefined + : input.archivedByParticipantId === null + ? null + : optionalString(input.archivedByParticipantId, "archivedByParticipantId", 160, issues); + const archivedByLabel = + input.archivedByLabel === undefined + ? undefined + : input.archivedByLabel === null + ? null + : optionalString(input.archivedByLabel, "archivedByLabel", 120, issues); + const sessionKeys = + input.sessionKeys === undefined ? undefined : optionalStringArray(input.sessionKeys, "sessionKeys", 240, issues); + + if (issues.length > 0) { + throw new CollaborationHallStoreValidationError("Invalid hall task card patch payload.", issues); + } + + return { + ...input, + taskCardId, + title, + description, + roomId, + currentOwnerParticipantId, + currentOwnerLabel, + stage, + status, + blockers, + requiresInputFrom, + mentionedParticipantIds, + plannedExecutionOrder, + plannedExecutionItems, + currentExecutionItem, + proposal, + decision, + doneWhen, + latestSummary, + archivedAt, + archivedByParticipantId, + archivedByLabel, + sessionKeys, + }; +} + +function validateArchiveHallTaskCardInput(input: ArchiveHallTaskCardInput): ArchiveHallTaskCardInput { + const issues: string[] = []; + const taskCardId = requiredString(input.taskCardId, "taskCardId", 180, issues); + const archivedByParticipantId = optionalString(input.archivedByParticipantId, "archivedByParticipantId", 160, issues); + const archivedByLabel = optionalString(input.archivedByLabel, "archivedByLabel", 120, issues); + if (issues.length > 0) { + throw new CollaborationHallStoreValidationError("Invalid hall task archive payload.", issues); + } + return { + taskCardId, + archivedByParticipantId, + archivedByLabel, + }; +} + +function validateDeleteHallTaskCardInput(input: DeleteHallTaskCardInput): DeleteHallTaskCardInput { + const issues: string[] = []; + const taskCardId = requiredString(input.taskCardId, "taskCardId", 180, issues); + if (issues.length > 0) { + throw new CollaborationHallStoreValidationError("Invalid hall task delete payload.", issues); + } + return { taskCardId }; +} + +function validateDeleteHallMessagesForTaskCardInput( + input: DeleteHallMessagesForTaskCardInput, +): DeleteHallMessagesForTaskCardInput & { hallId: string } { + const issues: string[] = []; + const hallId = optionalString(input.hallId, "hallId", 120, issues) ?? DEFAULT_COLLABORATION_HALL_ID; + const taskCardId = requiredString(input.taskCardId, "taskCardId", 180, issues); + const taskId = optionalString(input.taskId, "taskId", 120, issues); + const roomId = optionalString(input.roomId, "roomId", 180, issues); + if (issues.length > 0) { + throw new CollaborationHallStoreValidationError("Invalid hall task message delete payload.", issues); + } + return { hallId, taskCardId, taskId, roomId }; +} + +function validateAppendHallMessageInput(input: AppendHallMessageInput): AppendHallMessageInput & { hallId: string } { + const issues: string[] = []; + const hallId = optionalString(input.hallId, "hallId", 120, issues) ?? DEFAULT_COLLABORATION_HALL_ID; + const messageId = optionalString(input.messageId, "messageId", 180, issues); + const kind = input.kind === undefined ? undefined : optionalHallMessageKind(input.kind, "kind", issues); + const authorParticipantId = requiredString(input.authorParticipantId, "authorParticipantId", 160, issues); + const authorLabel = requiredString(input.authorLabel, "authorLabel", 120, issues); + const content = requiredString(input.content, "content", 4000, issues); + const targetParticipantIds = + input.targetParticipantIds === undefined + ? undefined + : optionalStringArray(input.targetParticipantIds, "targetParticipantIds", 160, issues); + const mentionTargets = input.mentionTargets; + const projectId = optionalString(input.projectId, "projectId", 120, issues); + const taskId = optionalString(input.taskId, "taskId", 120, issues); + const taskCardId = optionalString(input.taskCardId, "taskCardId", 180, issues); + const roomId = optionalString(input.roomId, "roomId", 180, issues); + + if (issues.length > 0) { + throw new CollaborationHallStoreValidationError("Invalid hall message payload.", issues); + } + + return { + ...input, + hallId, + messageId, + kind, + authorParticipantId, + authorLabel, + content, + targetParticipantIds, + mentionTargets, + projectId, + taskId, + taskCardId, + roomId, + }; +} + +function normalizeHallStore(input: unknown): CollaborationHallStoreSnapshot { + const root = asObject(input); + if (!root) return cloneEmptyHallStore(); + return { + halls: asArray(root.halls) + .map((item) => normalizeHall(item)) + .filter((item): item is CollaborationHall => Boolean(item)) + .sort((a, b) => a.hallId.localeCompare(b.hallId)), + executionLocks: asArray(root.executionLocks) + .map((item) => normalizeExecutionLock(item)) + .filter((item): item is CollaborationHallStoreSnapshot["executionLocks"][number] => Boolean(item)), + updatedAt: normalizeIsoString(root.updatedAt) ?? EMPTY_HALL_STORE.updatedAt, + }; +} + +function normalizeHallMessageStore(input: unknown): CollaborationHallMessageStoreSnapshot { + const root = asObject(input); + if (!root) return cloneEmptyMessageStore(); + return { + messages: asArray(root.messages) + .map((item) => normalizeHallMessage(item)) + .filter((item): item is HallMessage => Boolean(item)) + .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)), + updatedAt: normalizeIsoString(root.updatedAt) ?? EMPTY_MESSAGE_STORE.updatedAt, + }; +} + +function normalizeTaskCardStore(input: unknown): CollaborationTaskCardStoreSnapshot { + const root = asObject(input); + if (!root) return cloneEmptyTaskCardStore(); + return { + taskCards: asArray(root.taskCards) + .map((item) => normalizeTaskCard(item)) + .filter((item): item is HallTaskCard => Boolean(item)) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)), + updatedAt: normalizeIsoString(root.updatedAt) ?? EMPTY_TASK_CARD_STORE.updatedAt, + }; +} + +function normalizeHall(input: unknown): CollaborationHall | undefined { + const root = asObject(input); + if (!root) return undefined; + const hallId = asNonEmptyString(root.hallId); + const title = asNonEmptyString(root.title); + const createdAt = normalizeIsoString(root.createdAt); + const updatedAt = normalizeIsoString(root.updatedAt); + if (!hallId || !title || !createdAt || !updatedAt) return undefined; + return { + hallId, + title, + description: asNonEmptyString(root.description), + participants: normalizeParticipants(asArray(root.participants).map((item) => normalizeParticipant(item)).filter(Boolean) as HallParticipant[]), + taskCardIds: toStringArray(root.taskCardIds, 180), + messageIds: toStringArray(root.messageIds, 180), + lastMessageId: asNonEmptyString(root.lastMessageId) ?? undefined, + latestMessageAt: normalizeIsoString(root.latestMessageAt), + createdAt, + updatedAt, + }; +} + +function normalizeTaskCard(input: unknown): HallTaskCard | undefined { + const root = asObject(input); + if (!root) return undefined; + const hallId = asNonEmptyString(root.hallId); + const taskCardId = asNonEmptyString(root.taskCardId); + const projectId = asNonEmptyString(root.projectId); + const taskId = asNonEmptyString(root.taskId); + const title = asNonEmptyString(root.title); + const description = asNonEmptyString(root.description); + const stage = asHallTaskStage(root.stage); + const status = asTaskStatus(root.status); + const createdByParticipantId = asNonEmptyString(root.createdByParticipantId); + const createdAt = normalizeIsoString(root.createdAt); + const updatedAt = normalizeIsoString(root.updatedAt); + if (!hallId || !taskCardId || !projectId || !taskId || !title || !description || !stage || !status || !createdByParticipantId || !createdAt || !updatedAt) { + return undefined; + } + return { + hallId, + taskCardId, + projectId, + taskId, + roomId: asNonEmptyString(root.roomId), + title, + description, + stage, + status, + createdByParticipantId, + currentOwnerParticipantId: asNonEmptyString(root.currentOwnerParticipantId), + currentOwnerLabel: asNonEmptyString(root.currentOwnerLabel), + proposal: asNonEmptyString(root.proposal), + decision: asNonEmptyString(root.decision), + doneWhen: asNonEmptyString(root.doneWhen), + latestSummary: asNonEmptyString(root.latestSummary), + blockers: toStringArray(root.blockers, 240), + requiresInputFrom: toStringArray(root.requiresInputFrom, 160), + mentionedParticipantIds: toStringArray(root.mentionedParticipantIds, 160), + plannedExecutionOrder: toStringArray(root.plannedExecutionOrder, 160), + plannedExecutionItems: normalizeExecutionItems(root.plannedExecutionItems), + currentExecutionItem: normalizeExecutionItem(root.currentExecutionItem), + sessionKeys: toStringArray(root.sessionKeys, 240), + discussionCycle: normalizeDiscussionCycle(root.discussionCycle), + executionLock: normalizeExecutionLock(root.executionLock), + archivedAt: normalizeIsoString(root.archivedAt), + archivedByParticipantId: asNonEmptyString(root.archivedByParticipantId), + archivedByLabel: asNonEmptyString(root.archivedByLabel), + createdAt, + updatedAt, + }; +} + +function normalizeHallMessage(input: unknown): HallMessage | undefined { + const root = asObject(input); + if (!root) return undefined; + const hallId = asNonEmptyString(root.hallId); + const messageId = asNonEmptyString(root.messageId); + const kind = asHallMessageKind(root.kind); + const authorParticipantId = asNonEmptyString(root.authorParticipantId); + const authorLabel = asNonEmptyString(root.authorLabel); + const content = asNonEmptyString(root.content); + const createdAt = normalizeIsoString(root.createdAt); + if (!hallId || !messageId || !kind || !authorParticipantId || !authorLabel || !content || !createdAt) return undefined; + return { + hallId, + messageId, + kind, + authorParticipantId, + authorLabel, + authorSemanticRole: asHallSemanticRole(root.authorSemanticRole), + content, + targetParticipantIds: toStringArray(root.targetParticipantIds, 160), + mentionTargets: normalizeMentionTargets(root.mentionTargets), + projectId: asNonEmptyString(root.projectId), + taskId: asNonEmptyString(root.taskId), + taskCardId: asNonEmptyString(root.taskCardId), + roomId: asNonEmptyString(root.roomId), + payload: asObject(root.payload) as HallMessage["payload"] | undefined, + createdAt, + }; +} + +function normalizeDiscussionCycle(input: unknown): HallTaskCard["discussionCycle"] | undefined { + const root = asObject(input); + if (!root) return undefined; + const cycleId = asNonEmptyString(root.cycleId); + const openedAt = normalizeIsoString(root.openedAt); + const openedByParticipantId = asNonEmptyString(root.openedByParticipantId); + if (!cycleId || !openedAt || !openedByParticipantId) return undefined; + return { + cycleId, + openedAt, + openedByParticipantId, + expectedParticipantIds: toStringArray(root.expectedParticipantIds, 160), + completedParticipantIds: toStringArray(root.completedParticipantIds, 160), + closedAt: normalizeIsoString(root.closedAt), + }; +} + +function normalizeExecutionItems(input: unknown): HallTaskCard["plannedExecutionItems"] { + if (!Array.isArray(input)) return []; + const items: HallTaskCard["plannedExecutionItems"] = []; + for (const item of input) { + const normalized = normalizeExecutionItem(item); + if (normalized) items.push(normalized); + } + return items; +} + +function normalizeExecutionItem(input: unknown): HallTaskCard["currentExecutionItem"] | undefined { + const root = asObject(input); + if (!root) return undefined; + const itemId = asNonEmptyString(root.itemId); + const participantId = asNonEmptyString(root.participantId); + const task = asNonEmptyString(root.task); + if (!itemId || !participantId || !task) return undefined; + return { + itemId, + participantId, + task, + handoffToParticipantId: asNonEmptyString(root.handoffToParticipantId), + handoffWhen: asNonEmptyString(root.handoffWhen), + }; +} + +function optionalExecutionItems( + input: unknown, + label: string, + issues: string[], +): HallTaskCard["plannedExecutionItems"] | undefined { + if (input === undefined) return undefined; + if (!Array.isArray(input)) { + issues.push(`${label} must be an array.`); + return undefined; + } + return input.map((item, index) => { + const root = asObject(item); + if (!root) { + issues.push(`${label}[${index}] must be an object.`); + return { + itemId: randomUUID(), + participantId: "", + task: "", + }; + } + return { + itemId: optionalString(typeof root.itemId === "string" ? root.itemId : undefined, `${label}[${index}].itemId`, 180, issues) ?? randomUUID(), + participantId: requiredString(typeof root.participantId === "string" ? root.participantId : undefined, `${label}[${index}].participantId`, 160, issues), + task: requiredString(typeof root.task === "string" ? root.task : undefined, `${label}[${index}].task`, 400, issues), + handoffToParticipantId: optionalString(typeof root.handoffToParticipantId === "string" ? root.handoffToParticipantId : undefined, `${label}[${index}].handoffToParticipantId`, 160, issues), + handoffWhen: optionalString(typeof root.handoffWhen === "string" ? root.handoffWhen : undefined, `${label}[${index}].handoffWhen`, 240, issues), + }; + }).filter((item) => item.participantId && item.task); +} + +function optionalExecutionItem( + input: unknown, + label: string, + issues: string[], +): HallTaskCard["currentExecutionItem"] | undefined { + if (input === undefined) return undefined; + const root = asObject(input); + if (!root) { + issues.push(`${label} must be an object.`); + return undefined; + } + const participantId = requiredString(typeof root.participantId === "string" ? root.participantId : undefined, `${label}.participantId`, 160, issues); + const task = requiredString(typeof root.task === "string" ? root.task : undefined, `${label}.task`, 400, issues); + if (!participantId || !task) return undefined; + return { + itemId: optionalString(typeof root.itemId === "string" ? root.itemId : undefined, `${label}.itemId`, 180, issues) ?? randomUUID(), + participantId, + task, + handoffToParticipantId: optionalString(typeof root.handoffToParticipantId === "string" ? root.handoffToParticipantId : undefined, `${label}.handoffToParticipantId`, 160, issues), + handoffWhen: optionalString(typeof root.handoffWhen === "string" ? root.handoffWhen : undefined, `${label}.handoffWhen`, 240, issues), + }; +} + +function normalizeExecutionLock(input: unknown): CollaborationHallStoreSnapshot["executionLocks"][number] | undefined { + const root = asObject(input); + if (!root) return undefined; + const taskId = asNonEmptyString(root.taskId); + const projectId = asNonEmptyString(root.projectId); + const ownerParticipantId = asNonEmptyString(root.ownerParticipantId); + const ownerLabel = asNonEmptyString(root.ownerLabel); + const acquiredAt = normalizeIsoString(root.acquiredAt); + if (!taskId || !projectId || !ownerParticipantId || !ownerLabel || !acquiredAt) return undefined; + return { + taskId, + projectId, + ownerParticipantId, + ownerLabel, + acquiredAt, + releasedAt: normalizeIsoString(root.releasedAt), + releasedReason: asNonEmptyString(root.releasedReason), + }; +} + +function normalizeParticipant(input: unknown): HallParticipant | undefined { + const root = asObject(input); + if (!root) return undefined; + const participantId = asNonEmptyString(root.participantId); + const displayName = asNonEmptyString(root.displayName); + const semanticRole = asHallSemanticRole(root.semanticRole); + if (!participantId || !displayName || !semanticRole) return undefined; + return { + participantId, + agentId: asNonEmptyString(root.agentId), + displayName, + semanticRole, + active: root.active !== false, + aliases: toStringArray(root.aliases, 160), + isHuman: root.isHuman === true, + }; +} + +function normalizeMentionTargets(input: unknown): MentionTarget[] { + if (!Array.isArray(input)) return []; + return input + .map((item) => { + const root = asObject(item); + if (!root) return undefined; + const participantId = asNonEmptyString(root.participantId); + const displayName = asNonEmptyString(root.displayName); + const semanticRole = asHallSemanticRole(root.semanticRole); + const raw = asNonEmptyString(root.raw); + if (!participantId || !displayName || !semanticRole || !raw) return undefined; + return { raw, participantId, displayName, semanticRole }; + }) + .filter((item): item is MentionTarget => Boolean(item)); +} + +function cloneEmptyHallStore(): CollaborationHallStoreSnapshot { + return { + halls: [], + executionLocks: [], + updatedAt: EMPTY_HALL_STORE.updatedAt, + }; +} + +function cloneEmptyMessageStore(): CollaborationHallMessageStoreSnapshot { + return { + messages: [], + updatedAt: EMPTY_MESSAGE_STORE.updatedAt, + }; +} + +function cloneEmptyTaskCardStore(): CollaborationTaskCardStoreSnapshot { + return { + taskCards: [], + updatedAt: EMPTY_TASK_CARD_STORE.updatedAt, + }; +} + +function normalizeParticipants(participants: HallParticipant[]): HallParticipant[] { + const seen = new Set(); + const normalized: HallParticipant[] = []; + for (const participant of participants) { + if (!participant.participantId.trim()) continue; + if (seen.has(participant.participantId)) continue; + seen.add(participant.participantId); + normalized.push({ + ...participant, + displayName: participant.displayName.trim() || participant.participantId, + aliases: [...new Set( + participant.aliases + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .concat([participant.displayName.trim() || participant.participantId, participant.participantId]), + )], + }); + } + return normalized.sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +function requiredString(value: string | undefined, field: string, maxLength: number, issues: string[]): string { + const normalized = typeof value === "string" ? value.trim() : ""; + if (!normalized) { + issues.push(field); + return ""; + } + return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; +} + +function optionalString(value: string | undefined, field: string, maxLength: number, issues: string[]): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") { + issues.push(field); + return undefined; + } + const normalized = value.trim(); + if (!normalized) return undefined; + return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; +} + +function optionalIsoString(value: string | undefined, field: string, issues: string[]): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") { + issues.push(field); + return undefined; + } + const normalized = normalizeIsoString(value); + if (!normalized) { + issues.push(field); + return undefined; + } + return normalized; +} + +function optionalStringArray(value: string[] | undefined, field: string, maxLength: number, issues: string[]): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + issues.push(field); + return undefined; + } + return [...new Set( + value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .map((item) => (item.length > maxLength ? `${item.slice(0, maxLength - 3)}...` : item)), + )]; +} + +function optionalTaskStatus(value: HallTaskCard["status"] | undefined, field: string, issues: string[]): HallTaskCard["status"] | undefined { + if (value === undefined) return undefined; + if (value === "todo" || value === "in_progress" || value === "blocked" || value === "done") return value; + issues.push(field); + return undefined; +} + +function optionalHallTaskStage(value: HallTaskStage | undefined, field: string, issues: string[]): HallTaskStage | undefined { + if (value === undefined) return undefined; + if (HALL_TASK_STAGES.includes(value)) return value; + issues.push(field); + return undefined; +} + +function optionalHallMessageKind(value: HallMessageKind | undefined, field: string, issues: string[]): HallMessageKind | undefined { + if (value === undefined) return undefined; + if (HALL_MESSAGE_KINDS.includes(value)) return value; + issues.push(field); + return undefined; +} + +function asObject(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asHallMessageKind(value: unknown): HallMessageKind | undefined { + return typeof value === "string" && HALL_MESSAGE_KINDS.includes(value as HallMessageKind) + ? (value as HallMessageKind) + : undefined; +} + +function asHallTaskStage(value: unknown): HallTaskStage | undefined { + return typeof value === "string" && HALL_TASK_STAGES.includes(value as HallTaskStage) + ? (value as HallTaskStage) + : undefined; +} + +function asHallSemanticRole(value: unknown): HallParticipant["semanticRole"] | undefined { + return value === "planner" || value === "coder" || value === "reviewer" || value === "manager" || value === "generalist" + ? value + : undefined; +} + +function asTaskStatus(value: unknown): HallTaskCard["status"] | undefined { + return value === "todo" || value === "in_progress" || value === "blocked" || value === "done" + ? value + : undefined; +} + +function toStringArray(value: unknown, maxLength: number): string[] { + if (!Array.isArray(value)) return []; + return [...new Set( + value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .map((item) => (item.length > maxLength ? `${item.slice(0, maxLength - 3)}...` : item)), + )]; +} + +function normalizeIsoString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return undefined; + return new Date(parsed).toISOString(); +} diff --git a/src/runtime/collaboration-hall-summary-store.ts b/src/runtime/collaboration-hall-summary-store.ts new file mode 100644 index 0000000..ac7f479 --- /dev/null +++ b/src/runtime/collaboration-hall-summary-store.ts @@ -0,0 +1,249 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { getRuntimeDir, resolveRuntimePath } from "./runtime-path"; +import type { + CollaborationHall, + CollaborationHallSummary, + CollaborationHallSummaryStoreSnapshot, + HallMessage, + HallTaskCard, + HallTaskSummary, +} from "../types"; + +const RUNTIME_DIR = getRuntimeDir(); +export const COLLABORATION_HALL_SUMMARIES_PATH = resolveRuntimePath("collaboration-hall-summaries.json"); + +const EMPTY_SUMMARY_STORE: CollaborationHallSummaryStoreSnapshot = { + hallSummaries: [], + taskSummaries: [], + updatedAt: "1970-01-01T00:00:00.000Z", +}; + +export async function loadCollaborationHallSummaryStore(): Promise { + try { + const raw = await readFile(COLLABORATION_HALL_SUMMARIES_PATH, "utf8"); + return normalizeSummaryStore(JSON.parse(raw)); + } catch { + return cloneEmptySummaryStore(); + } +} + +export async function saveCollaborationHallSummaryStore( + next: CollaborationHallSummaryStoreSnapshot, +): Promise { + const normalized = normalizeSummaryStore({ + ...next, + updatedAt: new Date().toISOString(), + }); + await mkdir(RUNTIME_DIR, { recursive: true }); + const tempPath = `${COLLABORATION_HALL_SUMMARIES_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await writeFile(tempPath, JSON.stringify(normalized, null, 2), "utf8"); + await rename(tempPath, COLLABORATION_HALL_SUMMARIES_PATH); + return COLLABORATION_HALL_SUMMARIES_PATH; +} + +export function getCollaborationHallSummary( + store: CollaborationHallSummaryStoreSnapshot, + hallId: string, +): CollaborationHallSummary | undefined { + return store.hallSummaries.find((summary) => summary.hallId === hallId.trim()); +} + +export function getHallTaskSummary( + store: CollaborationHallSummaryStoreSnapshot, + taskCardId: string, +): HallTaskSummary | undefined { + return store.taskSummaries.find((summary) => summary.taskCardId === taskCardId.trim()); +} + +export async function upsertCollaborationHallSummary( + hall: CollaborationHall, + messages: HallMessage[], + taskCards: HallTaskCard[], +): Promise<{ path: string; summary: CollaborationHallSummary }> { + const store = await loadCollaborationHallSummaryStore(); + const summary = buildCollaborationHallSummary(hall, messages, taskCards); + const index = store.hallSummaries.findIndex((item) => item.hallId === summary.hallId); + if (index >= 0) store.hallSummaries[index] = summary; + else store.hallSummaries.push(summary); + store.updatedAt = summary.updatedAt; + const path = await saveCollaborationHallSummaryStore(store); + return { path, summary }; +} + +export async function upsertHallTaskSummary( + taskCard: HallTaskCard, + messages: HallMessage[], +): Promise<{ path: string; summary: HallTaskSummary }> { + const store = await loadCollaborationHallSummaryStore(); + const summary = buildHallTaskSummary(taskCard, messages); + const index = store.taskSummaries.findIndex((item) => item.taskCardId === summary.taskCardId); + if (index >= 0) store.taskSummaries[index] = summary; + else store.taskSummaries.push(summary); + store.updatedAt = summary.updatedAt; + const path = await saveCollaborationHallSummaryStore(store); + return { path, summary }; +} + +export function buildCollaborationHallSummary( + hall: CollaborationHall, + messages: HallMessage[], + taskCards: HallTaskCard[], +): CollaborationHallSummary { + const orderedMessages = [...messages].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); + const lastMessage = orderedMessages.at(-1); + const activeTaskCount = taskCards.filter((card) => card.stage !== "completed" && card.stage !== "blocked").length; + const waitingReviewCount = taskCards.filter((card) => card.stage === "review").length; + const blockedTaskCount = taskCards.filter((card) => card.stage === "blocked").length; + const headline = + lastMessage?.content ?? + (activeTaskCount > 0 + ? `Hall is tracking ${activeTaskCount} active task${activeTaskCount === 1 ? "" : "s"}.` + : "The hall is ready for the next request."); + + return { + hallId: hall.hallId, + headline: headline.length > 220 ? `${headline.slice(0, 217)}...` : headline, + activeTaskCount, + waitingReviewCount, + blockedTaskCount, + currentSpeakerLabel: lastMessage?.authorLabel, + updatedAt: hall.updatedAt, + }; +} + +export function buildHallTaskSummary(taskCard: HallTaskCard, messages: HallMessage[]): HallTaskSummary { + const scopedMessages = messages + .filter((message) => message.taskCardId === taskCard.taskCardId || message.taskId === taskCard.taskId) + .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); + const lastSignal = scopedMessages.at(-1); + const headline = + taskCard.decision ?? + taskCard.proposal ?? + taskCard.latestSummary ?? + lastSignal?.content ?? + taskCard.title; + + const nextAction = + taskCard.stage === "discussion" + ? taskCard.plannedExecutionOrder.length > 0 + ? `Finish discussion, then confirm whether the execution order should start with ${taskCard.plannedExecutionOrder[0]}.` + : "Finish discussion and let the manager close with a decision." + : taskCard.stage === "execution" + ? taskCard.plannedExecutionOrder.length > 0 + ? `${taskCard.currentOwnerLabel ?? "Assigned owner"} should keep posting execution updates, then hand off to ${taskCard.plannedExecutionOrder[0]}.` + : `${taskCard.currentOwnerLabel ?? "Assigned owner"} should keep posting execution updates.` + : taskCard.stage === "review" + ? "Reviewer should approve or reject the current result." + : taskCard.stage === "blocked" + ? "Resolve the blockers or hand the task to a new owner." + : "Completed. Review the final evidence if needed."; + + return { + taskCardId: taskCard.taskCardId, + projectId: taskCard.projectId, + taskId: taskCard.taskId, + headline: headline.length > 220 ? `${headline.slice(0, 217)}...` : headline, + currentOwnerLabel: taskCard.currentOwnerLabel, + nextAction, + stage: taskCard.stage, + blockerCount: taskCard.blockers.length, + updatedAt: taskCard.updatedAt, + }; +} + +function normalizeSummaryStore(input: unknown): CollaborationHallSummaryStoreSnapshot { + const root = asObject(input); + if (!root) return cloneEmptySummaryStore(); + return { + hallSummaries: asArray(root.hallSummaries) + .map((item) => normalizeHallSummary(item)) + .filter((item): item is CollaborationHallSummary => Boolean(item)), + taskSummaries: asArray(root.taskSummaries) + .map((item) => normalizeTaskSummary(item)) + .filter((item): item is HallTaskSummary => Boolean(item)), + updatedAt: normalizeIsoString(root.updatedAt) ?? EMPTY_SUMMARY_STORE.updatedAt, + }; +} + +function normalizeHallSummary(input: unknown): CollaborationHallSummary | undefined { + const root = asObject(input); + if (!root) return undefined; + const hallId = asNonEmptyString(root.hallId); + const headline = asNonEmptyString(root.headline); + const updatedAt = normalizeIsoString(root.updatedAt); + if (!hallId || !headline || !updatedAt) return undefined; + return { + hallId, + headline, + activeTaskCount: asFiniteNumber(root.activeTaskCount) ?? 0, + waitingReviewCount: asFiniteNumber(root.waitingReviewCount) ?? 0, + blockedTaskCount: asFiniteNumber(root.blockedTaskCount) ?? 0, + currentSpeakerLabel: asNonEmptyString(root.currentSpeakerLabel), + updatedAt, + }; +} + +function normalizeTaskSummary(input: unknown): HallTaskSummary | undefined { + const root = asObject(input); + if (!root) return undefined; + const taskCardId = asNonEmptyString(root.taskCardId); + const projectId = asNonEmptyString(root.projectId); + const taskId = asNonEmptyString(root.taskId); + const headline = asNonEmptyString(root.headline); + const stage = root.stage; + const updatedAt = normalizeIsoString(root.updatedAt); + if ( + !taskCardId || + !projectId || + !taskId || + !headline || + (stage !== "discussion" && stage !== "execution" && stage !== "review" && stage !== "blocked" && stage !== "completed") || + !updatedAt + ) { + return undefined; + } + return { + taskCardId, + projectId, + taskId, + headline, + currentOwnerLabel: asNonEmptyString(root.currentOwnerLabel), + nextAction: asNonEmptyString(root.nextAction) ?? "Open the task card and inspect the latest state.", + stage, + blockerCount: asFiniteNumber(root.blockerCount) ?? 0, + updatedAt, + }; +} + +function cloneEmptySummaryStore(): CollaborationHallSummaryStoreSnapshot { + return { + hallSummaries: [], + taskSummaries: [], + updatedAt: EMPTY_SUMMARY_STORE.updatedAt, + }; +} + +function asObject(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizeIsoString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return undefined; + return new Date(parsed).toISOString(); +} diff --git a/src/runtime/collaboration-stream.ts b/src/runtime/collaboration-stream.ts new file mode 100644 index 0000000..afe7600 --- /dev/null +++ b/src/runtime/collaboration-stream.ts @@ -0,0 +1,464 @@ +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { HallMessageKind, RoomParticipantRole } from "../types"; + +export type CollaborationStreamScope = "hall" | "room"; +export type CollaborationStreamEventType = + | "connected" + | "invalidate" + | "draft_start" + | "draft_delta" + | "draft_complete" + | "draft_abort"; + +export interface CollaborationStreamEvent { + eventId: string; + scope: CollaborationStreamScope; + type: CollaborationStreamEventType; + createdAt: string; + hallId?: string; + roomId?: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + reason?: string; + draftId?: string; + messageId?: string; + authorParticipantId?: string; + authorLabel?: string; + authorSemanticRole?: string; + authorRole?: RoomParticipantRole; + messageKind?: HallMessageKind | "chat" | "proposal" | "decision" | "handoff" | "status" | "result"; + delta?: string; + content?: string; +} + +interface StreamSubscriber { + subscriberId: string; + res: ServerResponse; + heartbeat: NodeJS.Timeout; +} + +interface HallDraftStreamInput { + hallId: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + roomId?: string; + authorParticipantId: string; + authorLabel: string; + authorSemanticRole?: string; + messageKind: CollaborationStreamEvent["messageKind"]; + content: string; +} + +interface RoomDraftStreamInput { + roomId: string; + projectId?: string; + taskId?: string; + authorRole: RoomParticipantRole; + authorLabel: string; + messageKind: CollaborationStreamEvent["messageKind"]; + content: string; +} + +interface ActiveHallDraft { + hallId: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + roomId?: string; +} + +const HEARTBEAT_MS = 15_000; +const hallSubscribers = new Map>(); +const roomSubscribers = new Map>(); +const activeHallDrafts = new Map(); +const canceledHallDrafts = new Set(); + +export function openHallEventStream( + req: IncomingMessage, + res: ServerResponse, + hallId: string, +): void { + openEventStream(req, res, hallSubscribers, hallId, { + eventId: randomUUID(), + scope: "hall", + type: "connected", + createdAt: new Date().toISOString(), + hallId, + }); +} + +export function openRoomEventStream( + req: IncomingMessage, + res: ServerResponse, + roomId: string, +): void { + openEventStream(req, res, roomSubscribers, roomId, { + eventId: randomUUID(), + scope: "room", + type: "connected", + createdAt: new Date().toISOString(), + roomId, + }); +} + +export function publishHallStreamEvent( + event: Omit & { hallId: string }, +): void { + publishScopedEvent(hallSubscribers, event.hallId, { + eventId: randomUUID(), + scope: "hall", + createdAt: new Date().toISOString(), + ...event, + }); +} + +export function publishRoomStreamEvent( + event: Omit & { roomId: string }, +): void { + publishScopedEvent(roomSubscribers, event.roomId, { + eventId: randomUUID(), + scope: "room", + createdAt: new Date().toISOString(), + ...event, + }); +} + +export async function streamHallDraftReply(input: HallDraftStreamInput): Promise { + const draftId = beginHallDraftReply(input); + for (const delta of chunkDraftContent(input.content)) { + if (isHallDraftCanceled(draftId)) break; + pushHallDraftDelta({ + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + draftId, + authorParticipantId: input.authorParticipantId, + authorLabel: input.authorLabel, + authorSemanticRole: input.authorSemanticRole, + messageKind: input.messageKind, + delta, + }); + await yieldStreamTurn(); + } + return draftId; +} + +export function completeHallDraftReply(input: { + hallId: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + roomId?: string; + draftId: string; + messageId?: string; + content: string; +}): void { + if (isHallDraftCanceled(input.draftId)) { + activeHallDrafts.delete(input.draftId); + canceledHallDrafts.delete(input.draftId); + return; + } + activeHallDrafts.delete(input.draftId); + publishHallStreamEvent({ + type: "draft_complete", + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + draftId: input.draftId, + messageId: input.messageId, + content: input.content, + }); +} + +export function abortHallDraftReply(input: { + hallId: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + roomId?: string; + draftId: string; + reason?: string; +}): void { + activeHallDrafts.delete(input.draftId); + canceledHallDrafts.add(input.draftId); + publishHallStreamEvent({ + type: "draft_abort", + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + draftId: input.draftId, + reason: input.reason ?? "aborted", + }); +} + +export async function streamRoomDraftReply(input: RoomDraftStreamInput): Promise { + const draftId = beginRoomDraftReply(input); + for (const delta of chunkDraftContent(input.content)) { + pushRoomDraftDelta({ + roomId: input.roomId, + projectId: input.projectId, + taskId: input.taskId, + draftId, + authorRole: input.authorRole, + authorLabel: input.authorLabel, + messageKind: input.messageKind, + delta, + }); + await yieldStreamTurn(); + } + return draftId; +} + +export function completeRoomDraftReply(input: { + roomId: string; + projectId?: string; + taskId?: string; + draftId: string; + messageId?: string; + content: string; +}): void { + publishRoomStreamEvent({ + type: "draft_complete", + roomId: input.roomId, + projectId: input.projectId, + taskId: input.taskId, + draftId: input.draftId, + messageId: input.messageId, + content: input.content, + }); +} + +export function beginHallDraftReply(input: HallDraftStreamInput): string { + const draftId = randomUUID(); + canceledHallDrafts.delete(draftId); + activeHallDrafts.set(draftId, { + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + }); + publishHallStreamEvent({ + type: "draft_start", + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + draftId, + authorParticipantId: input.authorParticipantId, + authorLabel: input.authorLabel, + authorSemanticRole: input.authorSemanticRole, + messageKind: input.messageKind, + content: "", + }); + return draftId; +} + +export function isHallDraftCanceled(draftId: string): boolean { + return canceledHallDrafts.has(draftId); +} + +export function abortHallDraftRepliesForTask(input: { + hallId: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + roomId?: string; + reason?: string; +}): string[] { + const abortedDraftIds: string[] = []; + for (const [draftId, draft] of activeHallDrafts.entries()) { + if (draft.hallId !== input.hallId) continue; + const matchesTaskCard = input.taskCardId && draft.taskCardId === input.taskCardId; + const matchesProjectTask = input.projectId && input.taskId && draft.projectId === input.projectId && draft.taskId === input.taskId; + const matchesRoom = input.roomId && draft.roomId === input.roomId; + if (!matchesTaskCard && !matchesProjectTask && !matchesRoom) continue; + abortHallDraftReply({ + hallId: draft.hallId, + taskCardId: draft.taskCardId, + projectId: draft.projectId, + taskId: draft.taskId, + roomId: draft.roomId, + draftId, + reason: input.reason ?? "aborted_by_operator", + }); + abortedDraftIds.push(draftId); + } + return abortedDraftIds; +} + +export function pushHallDraftDelta(input: { + hallId: string; + taskCardId?: string; + projectId?: string; + taskId?: string; + roomId?: string; + draftId: string; + authorParticipantId: string; + authorLabel: string; + authorSemanticRole?: string; + messageKind: CollaborationStreamEvent["messageKind"]; + delta: string; +}): void { + if (!input.delta) return; + publishHallStreamEvent({ + type: "draft_delta", + hallId: input.hallId, + taskCardId: input.taskCardId, + projectId: input.projectId, + taskId: input.taskId, + roomId: input.roomId, + draftId: input.draftId, + authorParticipantId: input.authorParticipantId, + authorLabel: input.authorLabel, + authorSemanticRole: input.authorSemanticRole, + messageKind: input.messageKind, + delta: input.delta, + }); +} + +export function beginRoomDraftReply(input: RoomDraftStreamInput): string { + const draftId = randomUUID(); + publishRoomStreamEvent({ + type: "draft_start", + roomId: input.roomId, + projectId: input.projectId, + taskId: input.taskId, + draftId, + authorRole: input.authorRole, + authorLabel: input.authorLabel, + messageKind: input.messageKind, + content: "", + }); + return draftId; +} + +export function pushRoomDraftDelta(input: { + roomId: string; + projectId?: string; + taskId?: string; + draftId: string; + authorRole: RoomParticipantRole; + authorLabel: string; + messageKind: CollaborationStreamEvent["messageKind"]; + delta: string; +}): void { + if (!input.delta) return; + publishRoomStreamEvent({ + type: "draft_delta", + roomId: input.roomId, + projectId: input.projectId, + taskId: input.taskId, + draftId: input.draftId, + authorRole: input.authorRole, + authorLabel: input.authorLabel, + messageKind: input.messageKind, + delta: input.delta, + }); +} + +function openEventStream( + req: IncomingMessage, + res: ServerResponse, + registry: Map>, + targetId: string, + connectedEvent: CollaborationStreamEvent, +): void { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store, no-cache, must-revalidate, max-age=0", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + res.flushHeaders?.(); + res.write(`retry: 1000\n\n`); + + const subscriberId = randomUUID(); + const scoped = registry.get(targetId) ?? new Map(); + const heartbeat = setInterval(() => { + try { + res.write(`: keep-alive ${Date.now()}\n\n`); + } catch { + cleanup(); + } + }, HEARTBEAT_MS); + const subscriber: StreamSubscriber = { subscriberId, res, heartbeat }; + scoped.set(subscriberId, subscriber); + registry.set(targetId, scoped); + writeStreamEvent(res, connectedEvent); + + const cleanup = () => { + clearInterval(heartbeat); + const current = registry.get(targetId); + if (!current) return; + current.delete(subscriberId); + if (current.size === 0) registry.delete(targetId); + }; + + req.on("close", cleanup); + res.on("close", cleanup); + res.on("error", cleanup); +} + +function publishScopedEvent( + registry: Map>, + targetId: string, + event: CollaborationStreamEvent, +): void { + const scoped = registry.get(targetId); + if (!scoped || scoped.size === 0) return; + for (const subscriber of scoped.values()) { + try { + writeStreamEvent(subscriber.res, event); + } catch { + clearInterval(subscriber.heartbeat); + scoped.delete(subscriber.subscriberId); + } + } + if (scoped.size === 0) registry.delete(targetId); +} + +function writeStreamEvent(res: ServerResponse, event: CollaborationStreamEvent): void { + res.write(`event: collaboration\nid: ${event.eventId}\ndata: ${JSON.stringify(event)}\n\n`); +} + +function chunkDraftContent(content: string): string[] { + const trimmed = content.trim(); + if (!trimmed) return [""]; + const chunks: string[] = []; + let current = ""; + for (const token of trimmed.split(/(\s+)/).filter(Boolean)) { + if (token.length > 48) { + if (current) { + chunks.push(current); + current = ""; + } + for (let index = 0; index < token.length; index += 32) { + chunks.push(token.slice(index, index + 32)); + } + continue; + } + if ((current + token).length > 48 && current) { + chunks.push(current); + current = token; + continue; + } + current += token; + } + if (current) chunks.push(current); + return chunks.length > 0 ? chunks : [trimmed]; +} + +function yieldStreamTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/src/runtime/hall-discussion-domain.ts b/src/runtime/hall-discussion-domain.ts new file mode 100644 index 0000000..ac557f1 --- /dev/null +++ b/src/runtime/hall-discussion-domain.ts @@ -0,0 +1,65 @@ +export type HallDiscussionDomain = + | "engineering" + | "creative" + | "analysis" + | "product" + | "research" + | "operations" + | "general"; + +interface DomainSignal { + pattern: RegExp; + weight: number; +} + +const DOMAIN_SIGNAL_MAP: Record, DomainSignal[]> = { + engineering: [ + { pattern: /(代码|编程|开发|工程|接口|api|bug|debug|fix|implement|build|frontend|backend|repo|system)/i, weight: 4 }, + { pattern: /(测试|test|lint|deploy|发布脚本|服务端|前端页面|数据库|schema|migration)/i, weight: 3 }, + ], + creative: [ + { pattern: /(动画|动效|animation|motion|storyboard|分镜|脚本|style frame|视觉语言|品牌|创意)/i, weight: 4 }, + { pattern: /(海报|设计稿|视觉稿|art direction|创意方向|叙事体验)/i, weight: 3 }, + { pattern: /(visual|可视化)/i, weight: 1 }, + ], + analysis: [ + { pattern: /(数据|图表|dashboard|指标|分析|insight|metric|analytics)/i, weight: 3 }, + { pattern: /(可视化|visualization|narrative|storytelling with data)/i, weight: 2 }, + ], + product: [ + { pattern: /(产品|发布|roadmap|增长|launch|go[- ]to[- ]market|workflow|feature|scope|success criteria)/i, weight: 3 }, + { pattern: /(用户|体验|user problem|需求范围|优先级|产品方向)/i, weight: 1 }, + ], + research: [ + { pattern: /(调研|research|benchmark|study|compare|调查|访谈|洞察|评估|研究问题)/i, weight: 4 }, + { pattern: /(证据|evidence|结论结构|假设|synthesis|调查视角|研究框架)/i, weight: 3 }, + ], + operations: [ + { pattern: /(运营|流程|排期|runbook|support|审批|交接|上线|通知|治理)/i, weight: 3 }, + { pattern: /(handoff|handover|流程设计|责任边界|试运行|值班|incident)/i, weight: 2 }, + ], +}; + +const DOMAIN_TIE_BREAK_ORDER: HallDiscussionDomain[] = [ + "engineering", + "research", + "creative", + "analysis", + "product", + "operations", + "general", +]; + +export function inferHallDiscussionDomainFromText(source: string): HallDiscussionDomain { + const normalized = source.toLowerCase(); + const scored = Object.entries(DOMAIN_SIGNAL_MAP).map(([domain, signals]) => ({ + domain: domain as HallDiscussionDomain, + score: signals.reduce((total, signal) => total + (signal.pattern.test(normalized) ? signal.weight : 0), 0), + })); + const bestScore = scored.reduce((max, item) => Math.max(max, item.score), 0); + if (bestScore <= 0) return "general"; + const candidates = scored + .filter((item) => item.score === bestScore) + .map((item) => item.domain); + return DOMAIN_TIE_BREAK_ORDER.find((domain) => candidates.includes(domain)) ?? "general"; +} diff --git a/src/runtime/hall-execution-lock.ts b/src/runtime/hall-execution-lock.ts new file mode 100644 index 0000000..ac8226e --- /dev/null +++ b/src/runtime/hall-execution-lock.ts @@ -0,0 +1,69 @@ +import type { ExecutionLock, HallTaskCard } from "../types"; + +export class HallExecutionLockError extends Error { + readonly statusCode: number; + + constructor(message: string, statusCode = 409) { + super(message); + this.name = "HallExecutionLockError"; + this.statusCode = statusCode; + } +} + +export function acquireHallExecutionLock( + taskCard: HallTaskCard, + input: { ownerParticipantId: string; ownerLabel: string; at?: string }, +): HallTaskCard { + const at = input.at ?? new Date().toISOString(); + const activeLock = taskCard.executionLock && !taskCard.executionLock.releasedAt ? taskCard.executionLock : undefined; + if (activeLock && activeLock.ownerParticipantId !== input.ownerParticipantId) { + throw new HallExecutionLockError( + `${activeLock.ownerLabel} already holds execution for ${taskCard.projectId}:${taskCard.taskId}.`, + ); + } + const executionLock: ExecutionLock = { + taskId: taskCard.taskId, + projectId: taskCard.projectId, + ownerParticipantId: input.ownerParticipantId, + ownerLabel: input.ownerLabel, + acquiredAt: activeLock?.acquiredAt ?? at, + }; + return { + ...taskCard, + stage: "execution", + currentOwnerParticipantId: input.ownerParticipantId, + currentOwnerLabel: input.ownerLabel, + executionLock, + updatedAt: at, + }; +} + +export function releaseHallExecutionLock( + taskCard: HallTaskCard, + reason: string, + at = new Date().toISOString(), +): HallTaskCard { + const activeLock = taskCard.executionLock && !taskCard.executionLock.releasedAt ? taskCard.executionLock : undefined; + if (!activeLock) return taskCard; + return { + ...taskCard, + executionLock: { + ...activeLock, + releasedAt: at, + releasedReason: reason, + }, + updatedAt: at, + }; +} + +export function assertHallExecutionAllowed(taskCard: HallTaskCard, participantId: string): void { + const activeLock = taskCard.executionLock && !taskCard.executionLock.releasedAt ? taskCard.executionLock : undefined; + if (taskCard.stage !== "execution") return; + if (!activeLock) return; + if (activeLock.ownerParticipantId !== participantId) { + throw new HallExecutionLockError( + `${activeLock.ownerLabel} currently owns execution for ${taskCard.projectId}:${taskCard.taskId}.`, + 403, + ); + } +} diff --git a/src/runtime/hall-handoff.ts b/src/runtime/hall-handoff.ts new file mode 100644 index 0000000..3870e57 --- /dev/null +++ b/src/runtime/hall-handoff.ts @@ -0,0 +1,136 @@ +import type { StructuredHandoffPacket, TaskArtifact } from "../types"; + +export type HallHandoffSummaryLanguage = "en" | "zh"; + +export class HallHandoffValidationError extends Error { + readonly issues: string[]; + readonly statusCode: number; + + constructor(message: string, issues: string[] = [], statusCode = 400) { + super(message); + this.name = "HallHandoffValidationError"; + this.issues = issues; + this.statusCode = statusCode; + } +} + +export interface CreateStructuredHandoffInput { + goal: string; + currentResult: string; + doneWhen: string; + blockers?: string[]; + nextOwner: string; + requiresInputFrom?: string[]; + artifactRefs?: TaskArtifact[]; +} + +export function buildStructuredHandoffPacket(input: CreateStructuredHandoffInput): StructuredHandoffPacket { + const issues: string[] = []; + const goal = requireText(input.goal, "goal", 240, issues); + const currentResult = requireText(input.currentResult, "currentResult", 500, issues); + const doneWhen = requireText(input.doneWhen, "doneWhen", 240, issues); + const nextOwner = requireText(input.nextOwner, "nextOwner", 120, issues); + const blockers = normalizeStringArray(input.blockers, "blockers", 240, issues); + const requiresInputFrom = normalizeStringArray(input.requiresInputFrom, "requiresInputFrom", 120, issues); + const artifactRefs = normalizeArtifactRefs(input.artifactRefs, "artifactRefs", issues); + + if (issues.length > 0) { + throw new HallHandoffValidationError("Invalid structured handoff payload.", issues); + } + + return { + goal, + currentResult, + doneWhen, + blockers, + nextOwner, + requiresInputFrom, + artifactRefs, + }; +} + +export function summarizeStructuredHandoff( + packet: StructuredHandoffPacket, + options: { language?: HallHandoffSummaryLanguage; includeMention?: boolean } = {}, +): string { + const language = options.language ?? "en"; + const nextOwner = options.includeMention === false ? packet.nextOwner : `@${packet.nextOwner}`; + const shortResult = shortenHandoffCopy(packet.currentResult, language === "zh" ? 54 : 120); + + if (language === "zh") { + const blockers = packet.blockers.length > 0 ? ` 卡点:${packet.blockers.join(";")}。` : ""; + const requires = packet.requiresInputFrom.length > 0 + ? ` 还需要 ${packet.requiresInputFrom.join("、")} 配合。` + : ""; + const artifacts = packet.artifactRefs && packet.artifactRefs.length > 0 + ? ` 先看产物:${packet.artifactRefs.map((artifact) => artifact.label).join("、")}。` + : ""; + return `${nextOwner} 接棒:先做“${packet.goal}”。现在手里有:${shortResult}。做到“${packet.doneWhen}”后继续往下交。${artifacts}${blockers}${requires}`; + } + + const blockers = packet.blockers.length > 0 ? ` Blockers: ${packet.blockers.join("; ")}.` : ""; + const requires = packet.requiresInputFrom.length > 0 + ? ` Needs input from: ${packet.requiresInputFrom.join(", ")}.` + : ""; + const artifacts = packet.artifactRefs && packet.artifactRefs.length > 0 + ? ` Start from these artifacts: ${packet.artifactRefs.map((artifact) => artifact.label).join(", ")}.` + : ""; + return `${nextOwner} takes this next: ${packet.goal}. Current result: ${shortResult}. Done when ${packet.doneWhen}.${artifacts}${blockers}${requires}`; +} + +function shortenHandoffCopy(value: string, maxLength: number): string { + const trimmed = value.replace(/\s+/g, " ").trim(); + if (!trimmed) return ""; + return trimmed.length > maxLength ? `${trimmed.slice(0, Math.max(0, maxLength - 1)).trim()}…` : trimmed; +} + +function requireText(value: string, field: string, maxLength: number, issues: string[]): string { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + issues.push(field); + return ""; + } + return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 3)}...` : trimmed; +} + +function normalizeStringArray(value: string[] | undefined, field: string, maxLength: number, issues: string[]): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + issues.push(field); + return []; + } + return [...new Set( + value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .map((item) => (item.length > maxLength ? `${item.slice(0, maxLength - 3)}...` : item)), + )]; +} + +function normalizeArtifactRefs(value: TaskArtifact[] | undefined, field: string, issues: string[]): TaskArtifact[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + issues.push(field); + return undefined; + } + const refs: TaskArtifact[] = []; + for (const item of value) { + if (!item || typeof item !== "object") { + issues.push(field); + continue; + } + const artifactId = requireText(item.artifactId, `${field}.artifactId`, 120, issues); + const label = requireText(item.label, `${field}.label`, 180, issues); + const location = requireText(item.location, `${field}.location`, 400, issues); + const type = item.type === "code" || item.type === "doc" || item.type === "link" || item.type === "other" + ? item.type + : undefined; + if (!type) { + issues.push(`${field}.type`); + continue; + } + refs.push({ artifactId, type, label, location }); + } + return refs; +} diff --git a/src/runtime/hall-mention-router.ts b/src/runtime/hall-mention-router.ts new file mode 100644 index 0000000..cfef011 --- /dev/null +++ b/src/runtime/hall-mention-router.ts @@ -0,0 +1,48 @@ +import type { HallParticipant, MentionTarget } from "../types"; + +export interface HallMentionRoutingResult { + broadcastAll: boolean; + targets: MentionTarget[]; +} + +export function resolveHallMentionTargets( + content: string, + participants: HallParticipant[], +): HallMentionRoutingResult { + const trimmed = content.trim(); + if (!trimmed) { + return { broadcastAll: false, targets: [] }; + } + + const broadcastAll = /(^|[\s(])@all(?=$|[\s),.!?;:])/i.test(trimmed); + const matched = new Map(); + + for (const participant of participants) { + for (const alias of participant.aliases) { + if (!alias) continue; + if (!containsExplicitMention(trimmed, alias)) continue; + matched.set(participant.participantId, { + raw: `@${alias}`, + participantId: participant.participantId, + displayName: participant.displayName, + semanticRole: participant.semanticRole, + }); + break; + } + } + + return { + broadcastAll, + targets: [...matched.values()], + }; +} + +function containsExplicitMention(content: string, alias: string): boolean { + const escaped = escapeRegex(alias); + const pattern = new RegExp(`(^|[\\s(])@${escaped}(?=$|[\\s),.!?;:])`, "i"); + return pattern.test(content); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/runtime/hall-role-resolver.ts b/src/runtime/hall-role-resolver.ts new file mode 100644 index 0000000..01b6ece --- /dev/null +++ b/src/runtime/hall-role-resolver.ts @@ -0,0 +1,111 @@ +import type { AgentRosterEntry } from "./agent-roster"; +import type { HallParticipant, HallSemanticRole } from "../types"; + +const ROLE_PATTERNS: Record, RegExp[]> = { + manager: [/manager/i, /\bmain\b/i, /lead/i, /chief/i, /owner/i, /orchestr/i], + planner: [/planner/i, /plan/i, /research/i, /architect/i, /product/i, /design/i], + coder: [/coder/i, /code/i, /dev/i, /engineer/i, /implement/i, /build/i, /builder/i, /maker/i], + reviewer: [/review/i, /qa/i, /audit/i, /critic/i, /test/i, /verify/i], +}; + +export function resolveHallParticipantsFromRoster(roster: AgentRosterEntry[]): HallParticipant[] { + const ordered = [...roster] + .map((entry) => ({ + agentId: entry.agentId.trim(), + displayName: entry.displayName.trim() || entry.agentId.trim(), + })) + .filter((entry) => entry.agentId.length > 0) + .sort((a, b) => a.agentId.localeCompare(b.agentId)); + + if (ordered.length === 0) { + return [ + toParticipant("main", "Main", "manager"), + toParticipant("planner", "Planner", "planner"), + toParticipant("coder", "Coder", "coder"), + toParticipant("reviewer", "Reviewer", "reviewer"), + ]; + } + + const assigned = new Set(); + const participants: HallParticipant[] = []; + + const pushRole = (role: Exclude) => { + const candidate = pickBestRoleCandidate(ordered, role, assigned); + if (!candidate) return; + assigned.add(candidate.agentId); + participants.push(toParticipant(candidate.agentId, candidate.displayName, role)); + }; + + pushRole("manager"); + pushRole("planner"); + pushRole("coder"); + pushRole("reviewer"); + + for (const entry of ordered) { + if (assigned.has(entry.agentId)) continue; + participants.push(toParticipant(entry.agentId, entry.displayName, "generalist")); + } + + return participants; +} + +export function pickPrimaryParticipantByRole( + participants: HallParticipant[], + role: Exclude, +): HallParticipant | undefined { + const direct = participants.find((participant) => participant.active && participant.semanticRole === role); + if (direct) return direct; + if (role === "manager") return participants.find((participant) => participant.active); + if (role === "planner") { + return participants.find((participant) => participant.active && participant.semanticRole !== "manager"); + } + return participants.find((participant) => participant.active && participant.semanticRole === "generalist"); +} + +export function resolveSemanticRoleLabel(role: HallSemanticRole, language: "en" | "zh" = "en"): string { + if (language === "zh") { + if (role === "planner") return "策划"; + if (role === "coder") return "执行"; + if (role === "reviewer") return "审核"; + if (role === "manager") return "经理"; + return "通用"; + } + if (role === "planner") return "Planner"; + if (role === "coder") return "Coder"; + if (role === "reviewer") return "Reviewer"; + if (role === "manager") return "Manager"; + return "Generalist"; +} + +function pickBestRoleCandidate( + entries: Array<{ agentId: string; displayName: string }>, + role: Exclude, + assigned: Set, +): { agentId: string; displayName: string } | undefined { + const patterns = ROLE_PATTERNS[role]; + const fromPattern = entries.find((entry) => !assigned.has(entry.agentId) && matchesRole(entry, patterns)); + if (fromPattern) return fromPattern; + return entries.find((entry) => !assigned.has(entry.agentId)); +} + +function matchesRole( + entry: { agentId: string; displayName: string }, + patterns: RegExp[], +): boolean { + const haystack = `${entry.agentId} ${entry.displayName}`; + return patterns.some((pattern) => pattern.test(haystack)); +} + +function toParticipant(agentId: string, displayName: string, semanticRole: HallSemanticRole): HallParticipant { + const aliases = [...new Set([displayName, agentId, displayName.replace(/\s+/g, ""), agentId.replace(/\s+/g, "")])] + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return { + participantId: agentId, + agentId, + displayName, + semanticRole, + active: true, + aliases, + }; +} diff --git a/src/runtime/hall-runtime-dispatch.ts b/src/runtime/hall-runtime-dispatch.ts new file mode 100644 index 0000000..b3078d2 --- /dev/null +++ b/src/runtime/hall-runtime-dispatch.ts @@ -0,0 +1,2003 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ToolClient } from "../clients/tool-client"; +import { + HALL_RUNTIME_DIRECT_STREAM_ENABLED, + HALL_RUNTIME_DISPATCH_ENABLED, + HALL_RUNTIME_HISTORY_LIMIT, + HALL_RUNTIME_POLL_INTERVAL_MS, + HALL_RUNTIME_THINKING_LEVEL, + HALL_RUNTIME_TIMEOUT_SECONDS, +} from "../config"; +import type { AgentRunRequest, AgentRunTransportContext } from "../contracts/openclaw-tools"; +import type { + CollaborationHall, + HallMessage, + HallParticipant, + HallSemanticRole, + HallTaskCard, + ProjectTask, + StructuredHandoffPacket, + TaskArtifact, +} from "../types"; +import { + isHallDraftCanceled, + beginHallDraftReply, + completeHallDraftReply, + pushHallDraftDelta, +} from "./collaboration-stream"; +import { inferHallDiscussionDomainFromText } from "./hall-discussion-domain"; +import { readSessionConversationHistory, type SessionHistoryMessage } from "./session-conversations"; + +interface ToolClientWithAgentRun extends ToolClient { + agentRun?(request: AgentRunRequest): Promise<{ + ok: boolean; + text: string; + rawText: string; + sessionKey?: string; + sessionId?: string; + }>; + agentRunStream?( + request: AgentRunRequest, + handlers?: { + onStdoutChunk?: (chunk: string) => void; + onStderrChunk?: (chunk: string) => void; + }, + ): Promise<{ + ok: boolean; + text: string; + rawText: string; + sessionKey?: string; + sessionId?: string; + }>; +} + +export type HallRuntimeNextAction = "continue" | "review" | "blocked" | "handoff" | "done"; + +export interface HallRuntimeChainDirective { + nextAction?: HallRuntimeNextAction; + nextStep?: string; + executor?: string; +} + +export interface HallRuntimeDispatchInput { + client: ToolClient; + hall: CollaborationHall; + taskCard: HallTaskCard; + participant: HallParticipant; + task?: ProjectTask; + triggerMessage?: HallMessage; + recentThreadMessages?: HallMessage[]; + mode: "discussion" | "execution" | "handoff"; + handoff?: StructuredHandoffPacket; + note?: string; +} + +export interface HallRuntimeDispatchResult { + kind: HallMessage["kind"]; + content: string; + canceled?: boolean; + suppressVisibleMessage?: boolean; + payload?: HallMessage["payload"]; + sessionKey?: string; + sessionId?: string; + chainDirective?: HallRuntimeChainDirective; + taskCardPatch?: { + proposal?: string; + decision?: string; + doneWhen?: string; + currentOwnerParticipantId?: string; + currentOwnerLabel?: string; + blockers?: string[]; + requiresInputFrom?: string[]; + latestSummary?: string; + }; +} + +interface ConcreteDeliverableEnforcement { + content: string; + nextAction?: HallRuntimeNextAction; + suppressVisibleMessage?: boolean; + nextStep?: string; +} + +type HallOperatorIntentType = + | "discussion_request" + | "direct_deliverable_request" + | "repo_scan_request" + | "review_request" + | "planning_request" + | "control_request"; + +interface HallOperatorIntent { + type: HallOperatorIntentType; + text: string; + explicitTarget: boolean; +} + +type ConcreteDeliverableKind = + | "repo_scan" + | "review" + | "thumbnail_urls" + | "thumbnail_ideas" + | "spoken_openings" + | "hooks" + | "script" + | "generic"; + +interface ParsedStructuredBlock { + proposal?: string; + decision?: string; + executor?: string; + doneWhen?: string; + blockers?: string[]; + requiresInputFrom?: string[]; + latestSummary?: string; + nextAction?: HallRuntimeNextAction; + nextStep?: string; + artifactRefs?: TaskArtifact[]; +} + +interface HallRuntimeRepoContext { + lines: string[]; + entryFiles: string[]; +} + +type HallResponseLanguage = "zh" | "en"; + +const CONTROL_CENTER_REPO_ROOT = process.cwd(); +const AGENT_WORKSPACES_ROOT = join(CONTROL_CENTER_REPO_ROOT, "..", ".."); +const CONTROL_CENTER_REPO_ENTRY_FILES = [ + "src/ui/collaboration-hall.ts", + "src/ui/collaboration-hall-theme.ts", + "src/ui/server.ts", + "src/runtime/collaboration-hall-orchestrator.ts", + "src/runtime/hall-runtime-dispatch.ts", + "src/types.ts", +]; +const HALL_REPO_CONTEXT_MAX_FILE_CHARS = 1_600; +const HALL_REPO_CONTEXT_MAX_TOTAL_CHARS = 7_200; +const HALL_WORKSPACE_PERSONA_FILES = ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "README.md"]; +const HALL_WORKSPACE_PERSONA_CACHE = new Map(); + +export function canDispatchHallToRuntime(client: ToolClient | undefined, participant: HallParticipant): client is ToolClientWithAgentRun { + return Boolean( + HALL_RUNTIME_DISPATCH_ENABLED && + client && + participant.active && + (participant.agentId ?? participant.participantId).trim() && + (typeof client.agentRun === "function" || typeof client.agentRunStream === "function"), + ); +} + +export async function dispatchHallRuntimeTurn(input: HallRuntimeDispatchInput): Promise { + const participantAgentId = (input.participant.agentId ?? input.participant.participantId).trim(); + if (!canDispatchHallToRuntime(input.client, input.participant)) { + throw new Error(`Runtime dispatch is unavailable for participant '${input.participant.displayName}'.`); + } + + const expectedSessionKey = pickExpectedSessionKey(input.taskCard, participantAgentId); + const baselineHistory = expectedSessionKey + ? await safeReadHistory(input.client, expectedSessionKey) + : []; + const baselineFingerprint = baselineHistory.at(-1) ? fingerprintHistoryMessage(baselineHistory.at(-1)!) : undefined; + const repoContext = buildHallRuntimeRepoContext(input); + const prompt = buildHallRuntimePrompt(input, repoContext); + const draftId = beginHallDraftReply({ + hallId: input.hall.hallId, + taskCardId: input.taskCard.taskCardId, + projectId: input.taskCard.projectId, + taskId: input.taskCard.taskId, + roomId: input.taskCard.roomId, + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + messageKind: resolveHallRuntimeMessageKind(input), + content: "", + }); + + let currentSessionKey = expectedSessionKey; + let lastStreamedText = ""; + let lastHistoryDraftText = ""; + let sessionHistoryObserved = false; + let directStdoutBuffer = ""; + let finished = false; + + const applyDraftText = (nextDraftText: string, source: "session" | "stdout"): void => { + const normalized = nextDraftText.trim(); + if (!normalized) return; + if (normalized.length <= lastStreamedText.length) return; + if (lastStreamedText && !normalized.startsWith(lastStreamedText)) { + if (source === "stdout" && !sessionHistoryObserved) { + lastStreamedText = ""; + } else { + return; + } + } + const delta = normalized.slice(lastStreamedText.length); + if (!delta) return; + lastStreamedText = normalized; + pushHallDraftDelta({ + hallId: input.hall.hallId, + taskCardId: input.taskCard.taskCardId, + projectId: input.taskCard.projectId, + taskId: input.taskCard.taskId, + roomId: input.taskCard.roomId, + draftId, + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + messageKind: resolveHallRuntimeMessageKind(input), + delta, + }); + }; + + const flushHistory = async (sessionKey: string | undefined): Promise => { + if (!sessionKey) return; + const history = await safeReadHistory(input.client, sessionKey); + const nextDraftText = renderRuntimeDraftText(history, baselineFingerprint); + if (!nextDraftText) return; + lastHistoryDraftText = nextDraftText; + sessionHistoryObserved = true; + applyDraftText(nextDraftText, "session"); + }; + + const poller = (async () => { + while (!finished) { + try { + await flushHistory(currentSessionKey); + } catch { + // Best-effort only; final completion still comes from agentRun(). + } + await delay(HALL_RUNTIME_POLL_INTERVAL_MS); + } + })(); + + try { + const request: AgentRunRequest = { + agentId: participantAgentId, + sessionKey: expectedSessionKey, + message: prompt, + thinking: HALL_RUNTIME_THINKING_LEVEL, + timeoutSeconds: HALL_RUNTIME_TIMEOUT_SECONDS, + context: buildHallRuntimeTransportContext(input, repoContext.entryFiles), + }; + const response = input.client.agentRunStream && HALL_RUNTIME_DIRECT_STREAM_ENABLED + ? await input.client.agentRunStream(request, { + onStdoutChunk: (chunk) => { + directStdoutBuffer += chunk; + if (sessionHistoryObserved) return; + const directText = sanitizeDirectRuntimeOutput(directStdoutBuffer); + if (!directText) return; + applyDraftText(directText, "stdout"); + }, + }) + : input.client.agentRun + ? await input.client.agentRun(request) + : await Promise.reject(new Error("No runtime dispatch method is available.")); + currentSessionKey = response.sessionKey?.trim() || expectedSessionKey; + await flushHistory(currentSessionKey); + const parsed = extractStructuredBlock(response.text); + const visibleContent = sanitizeHallVisibleRuntimeText(lastHistoryDraftText) + || sanitizeHallVisibleRuntimeText(parsed.visibleText) + || buildFallbackVisibleContent(input, response.text); + const structured = parsed.structured; + const result = buildHallRuntimeResult({ + input, + content: visibleContent, + structured, + sessionKey: currentSessionKey, + sessionId: response.sessionId?.trim() || undefined, + }); + if (isHallDraftCanceled(draftId)) { + return { + ...result, + canceled: true, + }; + } + completeHallDraftReply({ + hallId: input.hall.hallId, + taskCardId: input.taskCard.taskCardId, + projectId: input.taskCard.projectId, + taskId: input.taskCard.taskId, + roomId: input.taskCard.roomId, + draftId, + content: result.content, + }); + return result; + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown runtime error"; + const failureText = `Runtime dispatch failed: ${detail}`; + if (isHallDraftCanceled(draftId)) { + throw error; + } + if (failureText.length > lastStreamedText.length) { + pushHallDraftDelta({ + hallId: input.hall.hallId, + taskCardId: input.taskCard.taskCardId, + projectId: input.taskCard.projectId, + taskId: input.taskCard.taskId, + roomId: input.taskCard.roomId, + draftId, + authorParticipantId: input.participant.participantId, + authorLabel: input.participant.displayName, + authorSemanticRole: input.participant.semanticRole, + messageKind: resolveHallRuntimeMessageKind(input), + delta: failureText.slice(lastStreamedText.length), + }); + } + completeHallDraftReply({ + hallId: input.hall.hallId, + taskCardId: input.taskCard.taskCardId, + projectId: input.taskCard.projectId, + taskId: input.taskCard.taskId, + roomId: input.taskCard.roomId, + draftId, + content: failureText, + }); + throw error; + } finally { + finished = true; + await poller.catch(() => undefined); + } +} + +function buildHallRuntimePrompt(input: HallRuntimeDispatchInput, repoContext: HallRuntimeRepoContext): string { + const responseLanguage = inferHallResponseLanguage( + input.triggerMessage?.content + ?? `${input.taskCard.title}\n${input.taskCard.description}\n${input.task?.title ?? ""}`, + ); + const responseLanguageInstruction = responseLanguage === "zh" + ? "Reply in Simplified Chinese unless the latest human message explicitly asks for another language." + : "Reply in English unless the latest human message explicitly asks for another language."; + const recentMessages = dedupeHallPromptMessages( + [...(input.recentThreadMessages ?? []), ...(input.triggerMessage ? [input.triggerMessage] : [])], + ).slice(-10); + const transcriptBlock = recentMessages.length > 0 + ? [ + "Recent shared thread transcript (oldest -> newest):", + ...recentMessages.map((message) => `- ${message.authorLabel}${message.authorSemanticRole ? ` [${message.authorSemanticRole}]` : ""}: ${message.content}`), + ].join("\n") + : ""; + const role = input.participant.semanticRole; + const roleFocus = describeDiscussionRoleFocus(role, `${input.taskCard.title}\n${input.taskCard.description}`); + const currentExecutionItem = resolveCurrentExecutionItem(input.taskCard, input.participant.participantId); + const nextParticipantId = currentExecutionItem?.handoffToParticipantId?.trim() + || input.taskCard.plannedExecutionOrder[0] + || ""; + const nextParticipant = nextParticipantId + ? input.hall.participants.find((participant) => participant.participantId === nextParticipantId) + : undefined; + const nextExecutionItem = nextParticipantId + ? resolvePlannedExecutionItem(input.taskCard, nextParticipantId) + : undefined; + const repoContextLines = repoContext.lines; + const roundRosterBlock = buildHallRuntimeRosterBlock(input); + const selfWorkspacePersona = describeHallParticipantWorkspacePersona(input.participant); + const taskArtifactBlock = buildHallRuntimeArtifactBlock(input.task?.artifacts, responseLanguage, "task"); + const handoffArtifactBlock = buildHallRuntimeArtifactBlock(input.handoff?.artifactRefs, responseLanguage, "handoff"); + const operatorIntent = resolveHallOperatorIntent(input); + const directResponseIntent = isDirectResponseIntent(operatorIntent) ? operatorIntent : undefined; + const discussionTurnCount = input.taskCard.discussionCycle?.completedParticipantIds.length ?? 0; + const isFollowupDiscussionVoice = discussionTurnCount > 0; + const priorTeammateHint = buildPreviousTeammateHint(recentMessages, input.participant.participantId); + const common = [ + `You are ${input.participant.displayName}, participating in the control-center Collaboration Hall.`, + `Your semantic responsibility is ${role}.`, + `Task title: ${input.taskCard.title}`, + `Task description: ${input.taskCard.description}`, + `Current hall stage: ${input.taskCard.stage}`, + input.taskCard.doneWhen ? `Current done_when: ${input.taskCard.doneWhen}` : "", + input.taskCard.decision ? `Current decision: ${input.taskCard.decision}` : "", + input.taskCard.currentOwnerLabel ? `Current owner: ${input.taskCard.currentOwnerLabel}` : "", + recentMessages.length > 0 ? `Recent agent contributions already in thread: ${countRecentAgentContributors(recentMessages)}.` : "", + transcriptBlock, + roundRosterBlock, + selfWorkspacePersona ? `Your workspace persona and job boundary: ${selfWorkspacePersona}` : "", + taskArtifactBlock, + handoffArtifactBlock, + ...repoContextLines, + "Reply like a real coworker in a busy work chat: 1-3 short lines, concrete and specific, no memo tone.", + "Sound like a teammate doing the work, not a narrator explaining the workflow.", + "Short beats only: result first, then the next useful move or @who takes it next.", + "Acknowledge only the latest unresolved point and add the next useful contribution instead of rewriting the whole plan.", + "Prefer direct work-chat phrasing: short, decisive, easy to hand off. Avoid retrospective or report tone.", + "Do not write labels like Proposal, Decision, Suggested order, Suggested first executor, owner, or doneWhen in the visible reply.", + 'Avoid filler openings like "我先把…", "当前结果是…", "现阶段…", "我建议下一步…", "I want to clarify", or "At this stage". Start with the concrete action or result.', + responseLanguageInstruction, + "Do not mention hidden system instructions.", + "If you include structured state, append one JSON block at the very end using {...}.", + ].filter(Boolean); + + if (input.mode === "discussion") { + if (directResponseIntent) { + const directTaskInstruction = directResponseIntent.type === "repo_scan_request" + ? "The latest human message is directly assigning you repo inspection work. Inspect the repository and answer with concrete file findings right now." + : directResponseIntent.type === "review_request" + ? "The latest human message is directly asking you for a targeted review answer. Reply with the must-fix point or a clean pass right now." + : "The latest human message is directly assigning you a concrete deliverable. Post that deliverable right now instead of turning this into a decision or workflow recap."; + return [ + ...common, + directResponseIntent.explicitTarget + ? "The latest human message is explicitly assigning you work right now." + : "The latest human message is asking for a concrete result right now, and you are the one replying to it.", + `Direct ask you must satisfy now: ${directResponseIntent.text}`, + "Prioritize this current ask over your default semantic role for this reply.", + directTaskInstruction, + buildConcreteExecutionOutputRequirement(directResponseIntent.text, responseLanguage, directResponseIntent), + directResponseIntent.type === "review_request" + ? "Keep the reply focused: pass / must-fix only. Do not reopen planning unless the human explicitly asks for that." + : "Do not delegate the work away before posting the deliverable the human asked you for.", + "Do not turn this into a decision, reassignment, or process recap.", + 'Structured JSON keys you may include: "proposal", "artifactRefs".', + ].filter(Boolean).join("\n"); + } + const rosterLine = input.hall.participants.length > 0 + ? `Only refer to real hall participants by these exact names when you recommend an executor: ${input.hall.participants.map((participant) => participant.displayName).join(", ")}.` + : ""; + return [ + ...common, + "This is discussion only. Do not start execution yet.", + "Do not act like a greeter and do not ask to create a task card; the hall already has enough context to discuss the work.", + rosterLine, + role === "manager" + ? "You are landing the thread on a practical next step. Give one short decision, name exactly one first executor by real agent name, and define doneWhen. Do not recap the whole thread." + : roleFocus, + explicitHallMentionTargetLine(input), + isFollowupDiscussionVoice + ? "Another teammate already spoke in this round. Start by building directly on that point, then add only one new angle, correction, or missing constraint." + : "You are the first responder in this round. Open with one concrete angle and leave space for the next teammate to add a different one.", + priorTeammateHint, + role !== "manager" + ? "Add one distinct missing perspective instead of repeating the previous speaker or rewriting the whole plan. One useful angle is enough." + : "Write like a teammate in chat who is wrapping things up. One short decision, one explicit first owner, one done-when is enough.", + role !== "manager" + ? "Do not nominate, @mention, or route the next executor yet. Leave explicit owner-picking and handoff language to the manager unless the human explicitly asked about a named teammate." + : "", + role !== "manager" + ? "Assume at least one teammate will speak after you unless the human explicitly @mentions only one person. Do not try to answer everything yourself." + : "", + role !== "manager" + ? "If you are not the manager, do not assign work with an @mention unless the human explicitly asked for that teammate." + : "", + isFollowupDiscussionVoice + ? "Do not restart the thread from scratch. No long recap. Make the previous speaker's point stronger or sharper in one move, and do not reuse their opening sentence or summary." + : "Do not dump a full plan. Your job is to open one good angle, not to say everything at once.", + isFollowupDiscussionVoice + ? "React to the teammate's point directly, then add one missing delta. Avoid canned openers or speech-style framing." + : "Start with the concrete point itself. Avoid canned openers, scene-setting, or speech-style framing.", + "Do not explain the whole product or workflow unless that exact explanation is the missing angle.", + "One useful push is enough: a sharper criterion, a must-fix risk, or a clearer next move.", + 'Structured JSON keys you may include: "proposal", "decision", "executor", "doneWhen", "artifactRefs".', + ].join("\n"); + } + + if (input.mode === "handoff" && input.handoff) { + return [ + ...common, + "You are receiving a structured handoff and should continue the real work now.", + `Handoff goal: ${input.handoff.goal}`, + `Current result: ${input.handoff.currentResult}`, + `Done when: ${input.handoff.doneWhen}`, + input.handoff.blockers.length > 0 ? `Blockers: ${input.handoff.blockers.join("; ")}` : "", + input.handoff.requiresInputFrom.length > 0 + ? `Requires input from: ${input.handoff.requiresInputFrom.join(", ")}` + : "", + nextParticipant + ? `The next queued owner after you is ${nextParticipant.displayName}. If you finish your step without a real blocker, hand the work to ${nextParticipant.displayName} instead of stopping at a vague review note.` + : "There is no further queued owner after you. If you finish your step without a real blocker, move the work to review.", + nextExecutionItem?.task + ? `Your exact planned execution item is: ${nextExecutionItem.task}` + : "", + "Use your real tools if needed. Then reply like a coworker in chat, not like a memo.", + "Keep it brief unless the deliverable itself is a list, URLs, or concrete repo/file findings. In those cases, post the full deliverable instead of summarizing it away.", + "For repo/code-scan work, inspect the repo before replying and cite real file paths plus what each file proves.", + buildConcreteExecutionOutputRequirement(nextExecutionItem?.task ?? input.handoff.goal, responseLanguage), + "Prefer this shape: what changed, and @who acts next.", + "If it is ready for review, say it like a teammate: '现在请老板评审。' / 'Ready for review.' Do not say '推进到 review' or other system phrasing.", + "No numbered list unless the deliverable itself is literally a list.", + 'Good example: "这版先收住了,owner 还不够显眼。@下一位 你把最后一拍改成可执行句。" ', + "If there is a next owner and no blocker, explicitly @ that owner in the visible reply.", + nextExecutionItem?.task + ? `If you hand off, keep your visible @handoff aligned with the planned next task for ${nextParticipant?.displayName ?? "the next owner"}: ${nextExecutionItem.task}` + : "", + "Do not say the work is ready, done, or ready for review until the visible reply already contains the concrete deliverable for your step.", + "Do not restate the whole thread. Do not write a retrospective. Do not reopen earlier owners unless there is a real blocker.", + 'Structured JSON keys you may include: "latestSummary", "blockers", "requiresInputFrom", "doneWhen", "nextAction", "nextStep", "artifactRefs".', + ].filter(Boolean).join("\n"); + } + + return [ + ...common, + "You are the current execution owner. Do the real work now if needed, then post a concise worker update for the hall.", + currentExecutionItem?.task ? `Your current execution item: ${currentExecutionItem.task}` : "", + currentExecutionItem?.handoffWhen ? `Your step is done when: ${currentExecutionItem.handoffWhen}` : "", + nextParticipant + ? `The next queued owner after you is ${nextParticipant.displayName}. When your current execution item is complete, hand the work off to ${nextParticipant.displayName} instead of doing their step yourself.` + : "If your current execution item is complete and there is no next owner, move the work to review instead of inventing extra steps.", + nextExecutionItem?.task ? `The next owner's step after you is: ${nextExecutionItem.task}` : "", + input.note ? `Assignment note: ${input.note}` : "", + "Stay inside the current execution item only. Do not complete deliverables that belong to later owners in the execution order.", + "Write like a coworker in a work chat.", + "Keep it brief unless the deliverable itself is a list, URLs, or concrete repo/file findings. In those cases, post the full deliverable instead of compressing it into two lines.", + "Say only what concrete result now exists, any real blocker, and @who acts next.", + "If your current execution item asks for a concrete deliverable, the visible reply must contain that deliverable itself: the actual hooks, thumbnail ideas, URLs, script lines, file findings, or repo evidence. Do not just comment on what should be done.", + "For code-scan / repo-summary work, inspect the repo before replying. The visible reply must cite real file paths and what you found in them.", + buildConcreteExecutionOutputRequirement(currentExecutionItem?.task, responseLanguage), + "If your step is done and there is a next owner, make the visible reply read like a handoff between coworkers, not a status report.", + "No numbered list unless the deliverable itself is literally a list.", + 'Good example: "第一版脚本先锁住了,核心句是‘不是在聊天,是在推进任务’。@下一位 你只挑必须改的一点。" ', + "If there is a next queued owner and you are not blocked, explicitly @ that owner in the visible reply.", + nextExecutionItem?.task + ? `If you hand off, do not invent a different next task. Keep your visible @handoff aligned with the planned next task for ${nextParticipant?.displayName ?? "the next owner"}: ${nextExecutionItem.task}` + : "", + "Do not say the work is done, hand off, or ask for review until the visible reply already contains the concrete deliverable for your step.", + "Do not write a project recap, status memo, or generic brainstorming. If there is a next queued owner and you are not blocked, hand off instead of lingering on your own step.", + 'Structured JSON keys you may include: "latestSummary", "blockers", "requiresInputFrom", "doneWhen", "nextAction", "nextStep", "artifactRefs".', + 'Allowed nextAction values: "continue" when you need one more pass on your current execution item, "handoff" when your current execution item is complete and the next queued owner should take over, "review" when the work is ready for review and there is no further handoff, and "blocked" when you need help before continuing.', + ].filter(Boolean).join("\n"); +} + +function buildHallRuntimeArtifactBlock( + artifacts: TaskArtifact[] | undefined, + language: HallResponseLanguage, + source: "task" | "handoff", +): string { + if (!artifacts || artifacts.length === 0) return ""; + const preview = artifacts + .slice(0, 6) + .map((artifact) => `${artifact.label} (${artifact.location})`) + .join(language === "zh" ? ";" : "; "); + if (language === "zh") { + return source === "handoff" + ? `上一棒已经留下这些产物,继续做时优先沿用它们:${preview}` + : `当前线程已经关联这些产物,能复用就直接复用:${preview}`; + } + return source === "handoff" + ? `The previous owner already left these artifacts behind. Start from them if they help: ${preview}` + : `This thread already has these artifacts attached. Reuse them when they are relevant: ${preview}`; +} + +function resolveHallOperatorIntent( + input: HallRuntimeDispatchInput, +): HallOperatorIntent | undefined { + const triggerMessage = input.triggerMessage; + if (!triggerMessage || triggerMessage.authorParticipantId !== "operator") return undefined; + const rawContent = String(triggerMessage.content || "").trim(); + if (!rawContent) return undefined; + const normalized = sanitizeHallVisibleRuntimeText(rawContent) + .replace(//gi, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return undefined; + + const explicitTarget = isExplicitHallTaskTarget(triggerMessage, input.participant); + let type = classifyHallOperatorIntent(normalized); + if ( + input.mode === "discussion" + && !explicitTarget + && ( + type === "direct_deliverable_request" + || type === "repo_scan_request" + || type === "review_request" + ) + ) { + type = "discussion_request"; + } + + return { + type, + text: normalized, + explicitTarget, + }; +} + +function classifyHallOperatorIntent(text: string): HallOperatorIntentType { + if (/(开始执行|继续讨论|安排后续顺序|调整执行顺序|保存顺序|stop|停止|暂停|恢复|resume|start execution)/i.test(text)) { + return "control_request"; + } + if (/(收一下|收个口|给个结论|拍板|定一下|做决定|作决定|第一执行者|建议第一位执行者|先给.*第一步|给.*第一步|谁先做|谁来做第一步|谁来先做|执行顺序|下一步由谁|谁负责)/i.test(text)) { + return "planning_request"; + } + if (/(repo|repository|codebase|source code|scan code|scan the repo|implementation|file path|workspace|代码|源码|仓库|扫描代码|扫代码|实现|文件)/i.test(text)) { + return "repo_scan_request"; + } + if (/(must-fix|review|审核|评审|检查|挑一下|挑出|只挑|硬问题|硬缺口)/i.test(text)) { + return "review_request"; + } + if (/(给我|给一下|直接给|你给|你来|你去|请你|帮我|直接出|出一下|写一下|写一版|给一版|直接贴|贴一下|去扫|扫一下|看一下|查一下|产出|生成|整理|总结|扫描|scan|inspect|check|review|write|draft|produce|generate|show me|give me|please give|please write|please scan|can you|could you|完整的?.*(开头|口播|脚本|文案|版本)|三个?.*(开头|视频开头|口播开头)|3 个.*(开头|视频开头|口播开头|hook|thumbnail))/i.test(text)) { + return "direct_deliverable_request"; + } + return "discussion_request"; +} + +function isDirectResponseIntent(intent: HallOperatorIntent | undefined): intent is HallOperatorIntent { + return Boolean(intent && ( + intent.type === "direct_deliverable_request" + || intent.type === "repo_scan_request" + || intent.type === "review_request" + )); +} + +function isExplicitHallTaskTarget(message: HallMessage, participant: HallParticipant): boolean { + const mentionTargets = message.mentionTargets?.map((target) => String(target.participantId || "").trim()) ?? []; + if (mentionTargets.includes(participant.participantId)) return true; + + const content = String(message.content || ""); + const candidates = [ + participant.participantId, + participant.displayName, + ...(Array.isArray(participant.aliases) ? participant.aliases : []), + ] + .map((value) => String(value || "").trim()) + .filter(Boolean); + return candidates.some((candidate) => { + const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s([{(,。!?、,.!?;::])@${escaped}(?=$|[\\s)\\]}),.!?;::,。!?、])`, "i").test(content); + }); +} + +function buildHallRuntimeRepoContext(input: HallRuntimeDispatchInput): HallRuntimeRepoContext { + const operatorIntent = resolveHallOperatorIntent(input); + if (input.mode === "discussion" && operatorIntent && operatorIntent.type !== "repo_scan_request") { + return { lines: [], entryFiles: [] }; + } + const shouldInclude = input.mode !== "discussion" || looksLikeRepoAwareTask(input); + if (!shouldInclude) return { lines: [], entryFiles: [] }; + const existingEntryFiles = CONTROL_CENTER_REPO_ENTRY_FILES + .filter((relativePath) => existsSync(join(CONTROL_CENTER_REPO_ROOT, relativePath))); + const fileEvidence = buildHallRuntimeRepoEvidence(existingEntryFiles); + return { + entryFiles: existingEntryFiles, + lines: [ + `Repository root for this running control-center instance: ${CONTROL_CENTER_REPO_ROOT}`, + existingEntryFiles.length > 0 + ? `If you need to inspect implementation details, start with these existing files: ${existingEntryFiles.join(", ")}` + : "", + "Treat this as a normal workspace agent turn. If your agent runtime normally has file-reading tools, they are still available here; do not claim the hall surface blocks repo inspection by default.", + "If the current execution item asks for a code scan, implementation summary, or repo-based conclusion, inspect this repository before claiming the repo context is missing.", + fileEvidence.length > 0 + ? ["Literal repository excerpts for grounding:", ...fileEvidence].join("\n") + : "", + ].filter(Boolean), + }; +} + +function buildHallRuntimeTransportContext( + input: HallRuntimeDispatchInput, + entryFiles: string[], +): AgentRunTransportContext { + return { + surface: "control-center/hall", + workspaceRoot: CONTROL_CENTER_REPO_ROOT, + workdir: CONTROL_CENTER_REPO_ROOT, + entryFiles, + artifactRefs: input.task?.artifacts.map((artifact) => ({ + artifactId: artifact.artifactId, + type: artifact.type, + label: artifact.label, + location: artifact.location, + })) ?? [], + }; +} + +function buildHallRuntimeRepoEvidence(existingEntryFiles: string[]): string[] { + const excerpts: string[] = []; + let remainingChars = HALL_REPO_CONTEXT_MAX_TOTAL_CHARS; + for (const relativePath of existingEntryFiles) { + if (remainingChars <= 0) break; + const snippet = readHallRuntimeRepoSnippet(relativePath, Math.min(HALL_REPO_CONTEXT_MAX_FILE_CHARS, remainingChars)); + if (!snippet) continue; + excerpts.push(`--- ${relativePath} ---\n${snippet}`); + remainingChars -= snippet.length; + } + return excerpts; +} + +function readHallRuntimeRepoSnippet(relativePath: string, maxChars: number): string { + try { + const absolutePath = join(CONTROL_CENTER_REPO_ROOT, relativePath); + const raw = readFileSync(absolutePath, "utf8").trim(); + if (!raw) return ""; + const normalized = raw.replace(/\r\n/g, "\n"); + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, Math.max(0, maxChars - 16)).trimEnd()}\n/* truncated */`; + } catch { + return ""; + } +} + +function looksLikeRepoAwareTask(input: HallRuntimeDispatchInput): boolean { + const source = [ + input.taskCard.title, + input.taskCard.description, + input.task?.title ?? "", + input.note ?? "", + input.handoff?.goal ?? "", + input.handoff?.currentResult ?? "", + input.triggerMessage?.content ?? "", + resolveCurrentExecutionItem(input.taskCard, input.participant.participantId)?.task ?? "", + ].join("\n"); + return /(repo|repository|codebase|source code|scan code|scan the repo|implementation|file path|workspace|代码|源码|仓库|扫描代码|扫代码|实现|文件)/i.test(source); +} + +function resolveCurrentExecutionItem( + taskCard: HallTaskCard, + participantId: string, +): HallTaskCard["currentExecutionItem"] | undefined { + const current = taskCard.currentExecutionItem; + if (current?.participantId === participantId) return current; + return taskCard.plannedExecutionItems.find((item) => item.participantId === participantId); +} + +function resolvePlannedExecutionItem( + taskCard: HallTaskCard, + participantId: string, +): HallTaskCard["plannedExecutionItems"][number] | HallTaskCard["currentExecutionItem"] | undefined { + const current = taskCard.currentExecutionItem; + if (current?.participantId === participantId) return current; + return taskCard.plannedExecutionItems.find((item) => item.participantId === participantId); +} + +function describeDiscussionRoleFocus(role: HallParticipant["semanticRole"], source: string): string { + const normalized = source.toLowerCase(); + const creative = /(动画|动效|可视化|visual|animation|storyboard|脚本|海报|品牌|创意|设计稿|style frame)/i.test(normalized); + const analysis = /(数据|图表|dashboard|指标|分析|insight|metric|analytics|可视化)/i.test(normalized); + const research = /(调研|research|benchmark|study|compare|调查|访谈|洞察|评估)/i.test(normalized); + const product = /(产品|发布|roadmap|增长|用户|功能|体验|launch|go[- ]to[- ]market|workflow|feature)/i.test(normalized); + const operations = /(运营|流程|排期|runbook|support|审批|交接|上线|通知|治理)/i.test(normalized); + + if (role === "planner") { + if (creative) return "Focus on clarifying the brief: audience, message, style boundary, output format, and what the first reviewable creative artifact should be."; + if (analysis) return "Focus on clarifying the question, audience, data scope, output artifact, and what decision the result should support."; + if (research) return "Focus on clarifying the research question, evidence standard, and what kind of conclusion will be useful."; + if (product) return "Focus on clarifying the user problem, success criteria, constraints, and the smallest meaningful first slice."; + if (operations) return "Focus on clarifying the workflow, stakeholders, approvals, and handoff boundaries before execution starts."; + return "Focus on clarifying the outcome, audience, constraints, and what done should mean before execution starts."; + } + if (role === "coder") { + if (creative) return "Act as the practical implementer. Propose a concrete execution path such as storyboard -> sample -> iteration, even though this is not a coding-only task."; + if (analysis) return "Act as the practical implementer. Propose a concrete path to turn the idea into a reviewable prototype or artifact."; + if (research) return "Act as the practical implementer. Propose a concrete path to collect evidence, synthesize it, and return a usable recommendation."; + if (product) return "Act as the practical implementer. Propose the first tangible draft, prototype, or plan that can validate the direction."; + if (operations) return "Act as the practical implementer. Propose the first controlled workflow slice that can be executed and observed."; + return "Act as the practical implementer. Propose the first concrete deliverable that proves the direction without over-scoping."; + } + if (role === "reviewer") { + if (creative) return "Stress-test the brief, scope, approval checkpoints, and the risk of making something polished before the direction is validated."; + if (analysis) return "Stress-test the data quality, narrative honesty, audience fit, and whether the proposed artifact can actually answer the question."; + if (research) return "Stress-test the evidence plan, scope, and whether the output will lead to a usable conclusion."; + if (product) return "Stress-test the assumptions, dependencies, and whether the first slice is small enough to evaluate cleanly."; + if (operations) return "Stress-test the workflow, blocker points, and where ownership or approval might break down."; + return "Stress-test the assumptions, scope, reviewability, and hidden risks in the proposed first pass."; + } + return "Be available for targeted input if another participant explicitly needs your perspective."; +} + +function buildHallRuntimeRosterBlock(input: HallRuntimeDispatchInput): string { + const participantLines = input.hall.participants + .filter((participant) => participant.active !== false) + .map((participant) => { + const selfTag = participant.participantId === input.participant.participantId ? " (you)" : ""; + const roleLabel = describeHallSemanticRole(participant.semanticRole); + const persona = describeHallParticipantWorkspacePersona(participant) + || describeHallParticipantLightPersona(participant.semanticRole); + const roundTask = describeHallParticipantRoundTask(input.taskCard, participant.participantId); + return roundTask + ? `- ${participant.displayName}${selfTag} — role: ${roleLabel}; persona: ${persona}; this round: ${roundTask}` + : `- ${participant.displayName}${selfTag} — role: ${roleLabel}; persona: ${persona}`; + }); + + if (participantLines.length === 0) return ""; + + return [ + "Hall roster for this round:", + ...participantLines, + "Treat these as real teammates with different duties. Know what the others are responsible for before you reply, and do not steal a step that belongs to someone else.", + ].join("\n"); +} + +function describeHallSemanticRole(role: HallParticipant["semanticRole"]): string { + if (role === "planner") return "planner"; + if (role === "coder") return "builder"; + if (role === "reviewer") return "reviewer"; + if (role === "manager") return "manager"; + return "generalist"; +} + +function describeHallParticipantLightPersona(role: HallParticipant["semanticRole"]): string { + if (role === "planner") return "frames the task, pins the brief, keeps scope from drifting"; + if (role === "coder") return "turns direction into a concrete draft or working slice fast"; + if (role === "reviewer") return "only flags must-fix issues, trims scope, and hands it on when it is good enough"; + if (role === "manager") return "closes loops, names the next owner, and pushes the room to a real next step"; + return "fills a gap with one useful angle without taking over the whole thread"; +} + +function describeHallParticipantWorkspacePersona(participant: HallParticipant): string { + const workspaceId = normalizeLookup((participant.agentId ?? participant.participantId).trim()); + if (!workspaceId) return ""; + const cached = HALL_WORKSPACE_PERSONA_CACHE.get(workspaceId); + if (cached !== undefined) return cached; + + const workspaceRoot = join(AGENT_WORKSPACES_ROOT, workspaceId); + const summary = summarizeWorkspacePersonaFromFiles(workspaceRoot); + HALL_WORKSPACE_PERSONA_CACHE.set(workspaceId, summary); + return summary; +} + +export function summarizeWorkspacePersonaFromFiles(workspaceRoot: string): string { + const candidates: Array<{ text: string; score: number }> = []; + for (const fileName of HALL_WORKSPACE_PERSONA_FILES) { + const absolutePath = join(workspaceRoot, fileName); + if (!existsSync(absolutePath)) continue; + let raw = ""; + try { + raw = readFileSync(absolutePath, "utf8"); + } catch { + continue; + } + for (const candidate of extractWorkspacePersonaCandidates(raw, fileName)) { + candidates.push(candidate); + } + } + + const seen = new Set(); + const lines = candidates + .sort((left, right) => right.score - left.score || left.text.length - right.text.length) + .map((candidate) => candidate.text) + .filter((text) => { + const normalized = normalizeLookup(text); + if (!normalized || seen.has(normalized)) return false; + seen.add(normalized); + return true; + }) + .slice(0, 3); + + return lines.join("; "); +} + +function extractWorkspacePersonaCandidates(raw: string, fileName: string): Array<{ text: string; score: number }> { + const normalized = raw.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n").map((line) => line.trim()).filter(Boolean); + const candidates: Array<{ text: string; score: number }> = []; + + const pushCandidate = (text: string, score: number): void => { + const compact = text + .replace(/^[-*]\s*/, "") + .replace(/^#+\s*/, "") + .replace(/\s+/g, " ") + .trim(); + if (!compact) return; + if (/(this folder is home|every session|memory is limited|react like a human|safe to do freely|ask first|continuity|notes:|save this file|fill this in)/i.test(compact)) return; + if (/_\(.*\)_/.test(compact) || /pick something/i.test(compact)) return; + candidates.push({ text: compact, score }); + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (/^