Initial public-ready standalone repository

This commit is contained in:
OpenClaw Local committed 2026-03-11 12:23:57 +01:00
commit d002386bc2
66 files changed
+28683

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Mission Control MVP flags
# Keep readonly true until explicit live-mode validation is complete.
GATEWAY_URL=ws://127.0.0.1:18789
# Optional path overrides when your OpenClaw/Codex data is not in the default home locations.
# OPENCLAW_HOME=/path/to/.openclaw
# CODEX_HOME=/path/to/.codex
# OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH=/path/to/subscription.json
READONLY_MODE=true
APPROVAL_ACTIONS_ENABLED=false
APPROVAL_ACTIONS_DRY_RUN=true
IMPORT_MUTATION_ENABLED=false
IMPORT_MUTATION_DRY_RUN=false
LOCAL_TOKEN_AUTH_REQUIRED=true
# LOCAL_API_TOKEN=<set-explicitly-for-protected-routes>
MONITOR_CONTINUOUS=false
UI_MODE=false
UI_PORT=4310
+28
View File
@@ -0,0 +1,28 @@
node_modules/
dist/
runtime/
coverage/
*.log
*.tsbuildinfo
__pycache__/
*.pyc
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
.DS_Store
.env
.env.local
.env.*.local
plans/
workflows/
mission.config.json
mission.spec.json
mission.worklist.json
docs/mission.runbook.md
docs/mission.sop.md
init.sh
1440m
180m
720m
alexfinn-gap-checklist.md
i18n-missing-list.md
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 OpenClaw contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+564
View File
@@ -0,0 +1,564 @@
# OpenClaw Mission Control MVP
Safety-first local Mission Control for OpenClaw.
Language / 语言: [English](#english-installation-and-onboarding) | [中文](#中文安装与上手)
## Why this exists
- One local place to see whether OpenClaw is healthy, busy, blocked, or drifting.
- Built for non-technical operators who need observability and certainty, not raw backend payloads.
- Safe first-run defaults:
- read-only by default
- local token auth by default
- mutation routes disabled by default
## What you get
- `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
- `Tasks`: current work, approvals, execution chains, and runtime evidence
- `Documents` and `Memory`: source-backed workbenches scoped to active OpenClaw agents
## Who it is for
- OpenClaw users who want one local control center for observability, usage, tasks, approvals, replay, documents, and memory
- teams running OpenClaw on one machine or a reachable local environment
- maintainers who want a public-ready, safety-first OpenClaw dashboard instead of a generic agent platform
## Screenshots
Example UI from a local OpenClaw environment:
<img src="docs/assets/overview-en.png" alt="OpenClaw Mission Control overview screenshot" width="900" />
<img src="docs/assets/usage-en.png" alt="OpenClaw Mission Control usage screenshot" width="900" />
## 5-minute start
```bash
npm install
cp .env.example .env
npm run build
npm test
npm run smoke:ui
UI_MODE=true npm run dev
```
Then open:
- `http://127.0.0.1:4310/?section=overview&lang=en`
- `http://127.0.0.1:4310/?section=overview&lang=zh`
For / 适用对象:
- Existing OpenClaw users who want a local control center for observability, usage, staff activity, tasks, approvals, replay, and documents.
- Teams running OpenClaw on the same machine or a reachable local environment.
- Not a generic dashboard for non-OpenClaw agent stacks.
## Core constraints
- Only touches files in `control-center/`.
- `READONLY_MODE=true` by default.
- `LOCAL_TOKEN_AUTH_REQUIRED=true` by default.
- `IMPORT_MUTATION_ENABLED=false` by default.
- `IMPORT_MUTATION_DRY_RUN=false` by default.
- Import/export and all state-changing endpoints require a local token when auth is enabled.
- Approval actions are hard-gated (`APPROVAL_ACTIONS_ENABLED=false` default).
- Approval actions are dry-run by default (`APPROVAL_ACTIONS_DRY_RUN=true`).
- No mutation of `~/.openclaw/openclaw.json`.
## Quick start
1. `npm install`
2. `cp .env.example .env`
3. Keep safe defaults for the first run; only change `GATEWAY_URL` or path overrides if your OpenClaw setup is non-standard.
4. `npm run build`
5. `npm test`
6. `npm run smoke:ui`
7. `UI_MODE=true npm run dev`
## English installation and onboarding
### 1. Before you start
You should already have:
- a working OpenClaw installation
- a reachable OpenClaw Gateway
- shell access with `node` and `npm`
- read access to your OpenClaw home directory
For the richest dashboard data, it also helps if this machine has:
- `~/.openclaw`
- `~/.codex`
- a readable OpenClaw subscription snapshot, if your setup stores one outside the default locations
### 2. Install the project
```bash
git clone <your-repo-url>
cd control-center
npm install
cp .env.example .env
```
### 3. Configure `.env`
For a safe first run, keep the mutation guards in place.
Use this baseline:
```dotenv
GATEWAY_URL=ws://127.0.0.1:18789
READONLY_MODE=true
APPROVAL_ACTIONS_ENABLED=false
APPROVAL_ACTIONS_DRY_RUN=true
IMPORT_MUTATION_ENABLED=false
IMPORT_MUTATION_DRY_RUN=false
LOCAL_TOKEN_AUTH_REQUIRED=true
UI_MODE=false
UI_PORT=4310
# Optional only when your paths differ from the defaults:
# OPENCLAW_HOME=/path/to/.openclaw
# CODEX_HOME=/path/to/.codex
# OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH=/path/to/subscription.json
```
Change only these values if your environment needs it:
- `GATEWAY_URL`: when your OpenClaw Gateway is not on the default local socket
- `OPENCLAW_HOME`: when OpenClaw is not stored in `~/.openclaw`
- `CODEX_HOME`: when Codex data is not stored in `~/.codex`
- `OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH`: when your billing/subscription snapshot lives somewhere custom
- `UI_PORT`: when `4310` is already in use
### 4. Optional: ask your own OpenClaw to prepare the setup
If you want OpenClaw to help configure the environment, give it this prompt:
```text
You are helping me connect OpenClaw Mission Control to this machine's OpenClaw installation.
Work only inside the control-center repository.
Do not modify application source code unless I explicitly ask.
Goal: prepare a safe first-run setup.
Please do the following:
1. Check whether the OpenClaw Gateway is reachable and tell me the correct gateway URL.
2. Confirm the correct OpenClaw home path and Codex home path on this machine.
3. Compare those paths with .env.example and create or update .env.
4. Keep these values safe for first run:
- READONLY_MODE=true
- LOCAL_TOKEN_AUTH_REQUIRED=true
- APPROVAL_ACTIONS_ENABLED=false
- IMPORT_MUTATION_ENABLED=false
5. Do not enable live import or approval mutations.
6. Do not change OpenClaw's own config.
7. At the end, print:
- the final env values you changed
- the exact commands I should run next
- the first dashboard pages I should open to confirm everything works
8. If any required path, process, or file is missing, stop and tell me exactly what is missing instead of guessing.
```
### 5. Verify the install
Run:
```bash
npm run build
npm test
npm run smoke:ui
```
Expected result:
- build passes
- tests pass
- UI smoke reports a local URL such as `http://127.0.0.1:<port>`
### 6. Start the UI
```bash
UI_MODE=true npm run dev
```
Then open:
- English UI: `http://127.0.0.1:4310/?section=overview&lang=en`
- Chinese UI: `http://127.0.0.1:4310/?section=overview&lang=zh`
If you changed `UI_PORT`, replace `4310` with your chosen port.
### 7. First-use checklist
On your first launch, check these pages in order:
1. `Overview`: the app opens and shows current system state.
2. `Usage`: usage and subscription panels either show real numbers or a clear missing-connector state.
3. `Staff`: live work status matches real active sessions.
4. `Tasks`: current work, approvals, and execution-chain cards load without raw payload noise.
5. `Documents` and `Memory`: the visible agent tabs match your active agents from `openclaw.json`.
### 8. If something looks wrong
- Empty live activity usually means `GATEWAY_URL` is wrong or the OpenClaw Gateway is not running.
- Missing `Documents / Memory` agents usually means `OPENCLAW_HOME` points to the wrong OpenClaw root or `openclaw.json` is missing.
- Missing usage/subscription data usually means `CODEX_HOME` or `OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH` needs to be set.
- If you only want a safe read-only dashboard, do not change the mutation defaults.
## 中文安装与上手
### 1. 开始前准备
你最好已经有:
- 一个正常可用的 OpenClaw 安装
- 一个可连通的 OpenClaw Gateway
- 当前机器上的 `node` 和 `npm`
- 对 OpenClaw 主目录的读取权限
如果你希望 `用量 / 订阅` 这类信息更完整,当前机器最好还能读到:
- `~/.openclaw`
- `~/.codex`
- 你的 OpenClaw 订阅快照文件,尤其是它不在默认位置时
### 2. 安装项目
```bash
git clone <你的仓库地址>
cd control-center
npm install
cp .env.example .env
```
### 3. 配置 `.env`
第一次接入建议保持安全默认值,不要急着开写操作。
可以先用这份基线配置:
```dotenv
GATEWAY_URL=ws://127.0.0.1:18789
READONLY_MODE=true
APPROVAL_ACTIONS_ENABLED=false
APPROVAL_ACTIONS_DRY_RUN=true
IMPORT_MUTATION_ENABLED=false
IMPORT_MUTATION_DRY_RUN=false
LOCAL_TOKEN_AUTH_REQUIRED=true
UI_MODE=false
UI_PORT=4310
# 只有在你的路径不是默认值时才需要设置:
# OPENCLAW_HOME=/path/to/.openclaw
# CODEX_HOME=/path/to/.codex
# OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH=/path/to/subscription.json
```
只有这些情况才需要改:
- `GATEWAY_URL`:你的 OpenClaw Gateway 不在默认本地地址
- `OPENCLAW_HOME`:你的 OpenClaw 目录不在 `~/.openclaw`
- `CODEX_HOME`:你的 Codex 数据不在 `~/.codex`
- `OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH`:你的订阅或账单快照文件放在自定义位置
- `UI_PORT`:`4310` 端口已被占用
### 4. 可选:让你自己的 OpenClaw 帮你配置
如果你想让自己的 OpenClaw 帮你把环境接好,可以直接给它这段提示词:
```text
你现在要帮我把 OpenClaw Mission Control 接到这台机器自己的 OpenClaw 环境上。
只允许在 control-center 仓库里工作。
除非我明确要求,否则不要修改应用源码。
目标是完成一次安全的首次接入配置。
请按下面步骤执行:
1. 检查 OpenClaw Gateway 是否可达,并告诉我正确的 gateway URL。
2. 确认这台机器上正确的 OpenClaw 主目录和 Codex 主目录。
3. 对照 .env.example,创建或更新 .env。
4. 第一次接入时必须保持这些值:
- READONLY_MODE=true
- LOCAL_TOKEN_AUTH_REQUIRED=true
- APPROVAL_ACTIONS_ENABLED=false
- IMPORT_MUTATION_ENABLED=false
5. 不要开启 live import,也不要开启 approval mutation。
6. 不要改 OpenClaw 自己的配置文件。
7. 完成后输出:
- 你实际修改了哪些 env 值
- 我下一步应该执行的准确命令
- 我应该先打开哪些页面来确认接入成功
8. 如果缺少必要的路径、进程或文件,不要猜,直接明确告诉我缺什么。
```
### 5. 验证安装
执行:
```bash
npm run build
npm test
npm run smoke:ui
```
预期结果:
- build 通过
- test 通过
- UI smoke 输出一个本地地址,比如 `http://127.0.0.1:<port>`
### 6. 启动界面
```bash
UI_MODE=true npm run dev
```
然后打开:
- 中文界面:`http://127.0.0.1:4310/?section=overview&lang=zh`
- 英文界面:`http://127.0.0.1:4310/?section=overview&lang=en`
如果你改了 `UI_PORT`,把上面的 `4310` 换成你自己的端口。
### 7. 首次上手检查顺序
第一次打开后,建议按这个顺序检查:
1. `总览`:页面能正常打开,并且能看到当前系统状态。
2. `用量`:能看到真实数字,或者至少能看到明确的“数据源未连接”状态。
3. `员工`:实时工作状态和真实 active session 大致一致。
4. `任务`:当前工作、待审批、执行链卡片都能正常加载,而且不会吐原始 payload。
5. `文档` 和 `记忆`:看到的 agent 标签应该和你的 `openclaw.json` 活跃 agent 一致。
### 8. 如果看起来不对
- 实时活动全空,通常是 `GATEWAY_URL` 错了,或者 OpenClaw Gateway 没启动。
- `文档 / 记忆` 的 agent 范围不对,通常是 `OPENCLAW_HOME` 指错了,或者 `openclaw.json` 不可读。
- `用量 / 订阅` 没数据,通常是 `CODEX_HOME` 或 `OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH` 没配对。
- 如果你只是想先安全观察,不要改动默认的只读和 mutation 开关。
## Local commands
- `npm run build`
- `npm run dev`
- `npm run dev:continuous`
- `npm run dev:ui`
- `npm run smoke:ui`
- `npm run command:backup-export`
- `npm run command:import-validate -- runtime/exports/<file>.json`
- `npm run command:acks-prune`
- `npm test`
- `npm run validate`
For protected command modes (`command:backup-export`, `command:import-validate`, `command:acks-prune`), set `LOCAL_API_TOKEN=<token>` unless `LOCAL_TOKEN_AUTH_REQUIRED=false`.
## Repository hygiene (Phase 113, Open-source release readiness)
- Repo now ships with `.gitignore`, `LICENSE`, and publishable package metadata.
- Gateway connectivity is configurable via `GATEWAY_URL` instead of being hard-wired to one local socket.
- PM2, mission harness, workflow, and verifier examples now use repo-relative paths and environment-injected secrets only.
- Public docs now refer to generic `~/.openclaw/...` locations instead of machine-specific home directories.
- Run `npm run release:audit` before every public push.
- See [`docs/PUBLISHING.md`](docs/PUBLISHING.md) for the recommended standalone-repo release flow.
## Local HTTP endpoints
- `GET /snapshot`: raw snapshot JSON
- `GET /projects`: list projects with optional query filters `status`, `owner`
- `GET /api/projects`: same as `/projects` for compatibility
- `POST /api/projects`: create project (`projectId`, `title`, optional `status`, `owner`)
- `PATCH /api/projects/:projectId`: update project title/status/owner
- `GET /tasks`: flattened task list with optional query filters `status`, `owner`, `project`
- `GET /api/tasks`: same as `/tasks` for compatibility
- `POST /api/tasks`: create task with schema validation
- `PATCH /api/tasks/:taskId/status`: update task status with schema validation
- `GET /sessions`: paginated session visibility list with optional filters `state`, `agentId`, `q`, and pagination params `page`, `pageSize`, `historyLimit`
- `GET /sessions/:id`: per-session JSON detail with latest history entries (`historyLimit` query supported)
- `GET /api/sessions/:id`: explicit API alias for per-session JSON detail
- `GET /session/:id`: localized session drill-down UI page (`lang=en|zh`) with latest messages, execution-chain evidence, and safe truncation
- `GET /api/sessions`: compatibility endpoint for `/sessions`
- `GET /api/commander/exceptions`: exceptions-only summary (blocked/errors/pending approvals/over-budget/tasks-due)
- `GET /exceptions`: routed exceptions feed with levels (`info`, `warn`, `action-required`), sorted by severity then newest event
- `GET /done-checklist`: final integration checklist + readiness scoring (`observability/governance/collaboration/security`)
- `GET /api/done-checklist`: API alias for done checklist
- `GET /api/action-queue`: notification center queue derived from exceptions feed + ack state + relevant session/task/project links
- `GET /api/action-queue/acks/prune-preview`: token-gated dry-run preview of stale ack prune counts (`before/removed/after`, no state mutation)
- `POST /api/action-queue/:itemId/ack`: acknowledge an action-required queue item (persisted), optional `ttlMinutes` or `snoozeUntil` to auto-expire ack state
- `GET /graph`: project-task-session linkage graph JSON (for future Gameboy view)
- `GET /view/pixel-state.json`: pixel-ready adapter state (`rooms`, `entities`, `links`) for future Gameboy canvas
- `GET /usage-cost`: product route alias that redirects to `/?section=usage-cost`
- `GET /api/usage-cost`: usage/billing observability snapshot (period totals, context windows, breakdowns, burn-rate, subscription consumed/remaining/cycle, connector TODOs)
- `GET /export/state.json`: bundled export with sessions/tasks/projects/budgets/exceptions + persisted debug snapshot + backup bundle in `runtime/exports/` (requires local token auth)
- `POST /api/import/dry-run`: dry-run validator for export bundles (`fileName` or inline `bundle`) with zero state mutation (requires local token auth)
- `POST /api/import/live`: optional live import mutation endpoint (high-risk, local-only); requires local token + `IMPORT_MUTATION_ENABLED=true`, blocked in readonly unless `dryRun=true`, and now returns validation errors instead of `500` for bad `fileName` paths
- `GET /notifications/preview`: notification policy preview with quiet-hours + severity routing
- `GET /cron`: cron overview with next run and health summary
- `GET /healthz`: system health payload (build info + snapshot freshness + monitor lag)
- `GET /digest/latest`: rendered HTML page from latest markdown digest file
- `GET /api/ui/preferences`: persisted dashboard UI preferences (`runtime/ui-preferences.json`)
- `PATCH /api/ui/preferences`: update dashboard UI preferences (`compactStatusStrip`, `quickFilter`, `taskFilters`)
- `GET /api/search/tasks`: safe substring search over tasks (`q`, `limit`), with `count` = total matches and `returned` = current response size
- `GET /api/search/projects`: safe substring search over projects (`q`, `limit`), with `count` = total matches and `returned` = current response size
- `GET /api/search/sessions`: safe substring search over sessions (`q`, `limit`), with `count` = total matches, `returned` = current response size, and live-session merge parity with `/sessions`
- `GET /api/search/exceptions`: safe substring search over exception feed (`q`, `limit`), with `count` = total matches and `returned` = current response size
- `GET /api/replay/index`: replay/debug index from timeline + digests + export snapshots + export bundles, optional `from`/`to` ISO time window filters, plus per-source `stats` (`total`, `returned`, `filteredOut`, window-vs-limit breakdown, `latencyMs`, `latencyBucketsMs` with `p50/p95`, `totalSizeBytes`, `returnedSizeBytes`)
- `GET /api/docs`: route + schema summary endpoint
- `GET /docs`: localized docs index page (read-only) with direct return path to the `Documents` section
- `GET /docs/readme|runbook|architecture|progress`: local markdown docs views (read-only, `lang` accepted for index/back-link flow)
- `POST /api/approvals/:approvalId/approve`: approval action service (gate + dry-run + audit)
- `POST /api/approvals/:approvalId/reject`: rejection action service (gate + dry-run + audit)
- `GET /audit`: local audit timeline page (newest-first, severity filter)
- `GET /api/audit`: audit timeline JSON (`severity=all|info|warn|action-required|error`)
## Dashboard highlights (Phase 14)
- Home page includes inline scoped search UI wired to `/api/search/*`.
- Home page replay/export visibility card now shows returned/filtered counts and latency/size indicators from `/api/replay/index`.
- Guard table shows explicit disabled/enabled badges and linked local docs references.
## Dashboard highlights (Phase 107, Approvals/replay/tool-activity correctness sweep)
- Approval counts now use the full live approval set:
- sidebar and task-hub decision counts no longer under-report when approval previews are truncated
- approval preview lists stay short but now make it explicit when only the latest subset is shown
- Replay visibility chips now show total available history:
- timeline events
- daily digests
- export snapshots
- backup bundles
- Overview tool activity detail now loads actual session evidence:
- it no longer claims there are no tool-call sessions while the same page shows active tool-call counts
- User-facing parity routes no longer advertise deprecated dashboard sections:
- approvals route points to the task hub decision lane
- replay route points to `/audit`
## Dashboard highlights (Phase 110, Docs/memory active-agent scope alignment)
- `Documents` and `Memory` now follow active OpenClaw agent config instead of stale workspace folders:
- facets are resolved from `~/.openclaw/openclaw.json` first
- removed agents no longer appear just because an old folder still exists under `workspace/agents/`
- root OpenClaw files are now shown as `Main` instead of `共享`
- Editable file content remains source-of-truth current:
- file lists still read live filesystem metadata (`updatedAt`, size, path)
- opening a file reads the current source file
- saving a file writes directly back to that same source file
## Dashboard highlights (Phase 111, Execution-chain readability cleanup)
- `Execution chain` cards no longer surface raw JSON payloads as the visible headline or summary.
- Unmapped isolated-run cards now use stable labels such as `Main · Cron 隔离执行`.
- JSON-like payloads are summarized into short readable lines:
- `成功 · 查询 30 · 成功 30`
- `失败 · 错误 locked`
- `成功 · 扫描 120 · 入选 2 · 发送 2`
- Long titles and session keys now wrap/clamp inside the card instead of pushing badges out of place.
## Dashboard highlights (Phase 112, Staff status freshness semantics)
- Staff `Working / 工作中` now means live execution, not just “still owns unfinished tasks”.
- Agents with backlog but no live session now stay in standby semantics instead of looking falsely active.
- Staff work labels now separate:
- live work: `Working on / 正在处理什么`
- queued next task: `Next up / 下一项`
- Staff/recent-activity cache remains short-lived at about `3s`; live-session polling baseline remains `5s`.
## Dashboard highlights (Phase 114, Execution-chain card hardening)
- Execution-chain cards no longer allow raw JSON-like payloads to become visible titles.
- Structured payload-shaped titles are converted into short human summaries or stable fallback labels instead.
- Long titles, meta rows, and badge rails are now overflow-safe inside the card grid.
## Dashboard highlights (Phase 115, Apple-native card elevation tune)
- Page canvas is slightly cooler and quieter so foreground cards stand out more cleanly.
- Core cards now use brighter layered fills, crisper borders, and deeper but still restrained elevation.
- The hierarchy is unchanged; this is a visual polish pass, not a layout rewrite.
## Dashboard highlights (Phase 116, Config-truthful document and memory scopes)
- `Documents / Memory` no longer revive deleted agents just because stale folders still remain on disk.
- Valid active-agent config remains the only truth source for those facet buttons.
- If config becomes unreadable, the workbench now falls back conservatively to `Main` only instead of showing stale agent folders.
## Dashboard highlights (Phase 106, Cold-path cache coalescing)
- Correctness stays unified across Overview / Settings / Usage:
- no return to split summary/full quota logic
- repeat navigations keep the same usage/quota truth when the underlying execution state is unchanged
- Heavy evidence scans are now reused instead of recomputed per page:
- runtime usage logs
- Codex subscription/quota telemetry
- digest history
- OpenClaw cron name catalog
- UI startup now primes dashboard caches, and concurrent page opens share the same heavy build work.
- In local smoke:
- back-to-back `Overview` renders dropped to about `0.41s`
- back-to-back `Usage` renders dropped to about `0.41s`
- after one priming hit, concurrent `Overview / Settings / Usage` completed in about `1.13s`
## Dashboard highlights (Phase 105, Observability correctness alignment)
- Overview / Tasks / Settings / Usage now share one usage/quota truth source:
- the same today-usage number
- the same Codex quota windows
- the same subscription-status judgement
- Active-session counts are aligned across:
- overview KPI
- certainty card
- sidebar / summary strips
- Task certainty no longer depends on only the first recent-session page:
- linked session evidence is loaded for the visible task set
- task detail pages no longer stop at the first 6 linked sessions
- Detail links now keep the current UI language when opening task / cron drill-down pages.
## Dashboard highlights (Phase 15, UX v2)
- Home dashboard is now organized into six operator tabs with persistent left navigation:
- `Overview`
- `Office Space`
- `Projects/Tasks`
- `Alerts`
- `Replay/Audit`
- `Settings`
- UI moved to a colorful pixel-arcade visual style with dual sidebars (navigation + context rail).
- New `Office Space` view shows who is busy on what, grouped by office zones.
- Agent cards now include automatic animal identities derived from agent name semantics, with deterministic fallback mapping.
- Empty/zero-heavy blocks are softened:
- non-actionable zero states are minimized
- user-facing empty states now say `Not activated yet`.
- Home copy was rewritten from debug-heavy wording to operator-focused language while preserving all existing routes and backend behavior.
## Dashboard highlights (Phase 22, Usage/Cost parity surfaces)
- Added `Usage & Cost` section in the primary sidebar IA.
- Added Overview card-level usage/cost pulse:
- period totals (`today`, `7d`, `30d`)
- request-count source state
- burn-rate headline
- Added dedicated Usage & Cost dashboard section:
- context window visibility per active session/agent (absolute tokens + % when context catalog is available)
- pace/trend labels + warning thresholds
- usage/cost breakdown by agent, project, model, provider
- budget burn-rate status and alert messaging
- Added graceful unavailable-state behavior:
- explicit `Data source not connected` labels for disconnected metrics (instead of fake zeros)
- connector TODO list surfaced in Settings
- Added usage adapter endpoint:
- `GET /api/usage-cost`
## Dashboard highlights (Phase 25, Mission Control v3)
- UI visual reset to polished pixel-office style:
- design-token based palette/spacing/radius/shadow system
- layered office background grid + glow depth
- responsive desktop/mobile hierarchy with subtle card/status motion
- Navigation and copy reset for operator clarity:
- `Command Deck`, `Usage & Billing`, `Pixel Office`, `Work Board`, `Decisions`, `Timeline`, `Control Room`
- reduced technical wording on primary surfaces
- advanced links preserved under explicit disclosure
- Mac parity surfaces panel added with status + route entry for:
- conversations, approvals/decision queue, cron, projects/tasks, usage/cost, replay/audit, health/digest, export/import dry-run safety, pixel adapter
- Full roster office model:
- best-effort OpenClaw roster read from `~/.openclaw/openclaw.json` via `src/runtime/agent-roster.ts`
- office floor now renders desk/zone occupancy and includes known agents beyond active sessions
- Subscription usage/remaining best-effort integration:
- adapter support in `src/runtime/usage-cost.ts` for connected/partial/not_connected states
- UI now shows consumed/remaining/limit/cycle/source and explicit connection targets when unavailable
## Dashboard highlights (Phase 68, Plain-language certainty)
- Added `Information certainty` card to Overview and Settings:
- tells non-technical operators which parts of the picture are trustworthy now
- calls out remaining blind spots in plain language
- Added `Execution certainty` board to Tasks:
- scores whether each in-flight task is backed by real execution evidence
- separates `evidence is strong` from `needs follow-up` and `evidence is weak`
- Task detail pages now act as evidence pages:
- certainty judgement
- linked session evidence and recent activity summaries
## API validation/error envelope
- API mutating routes require `Content-Type: application/json`.
- Import/export and mutating routes require local token auth by default:
- header: `x-local-token: <LOCAL_API_TOKEN>`
- or `Authorization: Bearer <LOCAL_API_TOKEN>`
- Strict API query validation rejects unknown query keys.
- JSON errors use a consistent envelope:
- `{"ok":false,"requestId":"...","error":{"code":"...","status":<http>,"message":"...","issues":[],"requestId":"..."}}`
- JSON responses include `requestId` and all responses include `x-request-id` header for correlation.
## Live import warning
- `POST /api/import/live` is intentionally disabled by default.
- Do not enable it unless you are doing a controlled local restore test.
- Live mode mutates local runtime stores (`runtime/projects.json`, `runtime/tasks.json`, `runtime/budgets.json`).
- Keep `READONLY_MODE=true` and `IMPORT_MUTATION_ENABLED=false` in normal operation.
## Runtime files
- `runtime/last-snapshot.json`
- `runtime/timeline.log`
- `runtime/projects.json`
- `runtime/tasks.json`
- `runtime/budgets.json`
- `runtime/notification-policy.json`
- `runtime/model-context-catalog.json`
- `runtime/ui-preferences.json`
- `runtime/acks.json`
- `runtime/approval-actions.log`
- `runtime/operation-audit.log`
- `runtime/digests/YYYY-MM-DD.json`
- `runtime/digests/YYYY-MM-DD.md`
- `runtime/export-snapshots/*.json`
- `runtime/exports/*.json`
## Docs
- `docs/ARCHITECTURE.md`
- `docs/RUNBOOK.md`
- `docs/PROGRESS.md`
+287
View File
@@ -0,0 +1,287 @@
# Architecture (Official-First)
## Core Principle
Use official OpenClaw interfaces first, then add thin adapters only when required.
## Official Capability Mapping
1) Gateway (control plane)
- Endpoint: `GATEWAY_URL` (defaults to `ws://127.0.0.1:18789`)
- Role: real-time event/control backbone
2) Sessions APIs
- `sessions_list`: discover sessions/agents
- `sessions_history`: inspect message history
- `sessions_send`: route instructions
- Local session-visibility module merges `sessions_list` with per-session latest history snippets for operator read APIs.
- Session history normalization distinguishes regular messages and tool events for readonly drill-down rendering.
3) Runtime status
- `session_status`: usage/time/cost/status snapshots
4) Scheduling / Ops
- `cron`: list/add/run/runs for scheduled tasks
5) Governance
- approvals + tool policy boundaries for sensitive actions
- approvals action service is runtime-gated and dry-run by default
- every approval action attempt writes audit entries to `runtime/approval-actions.log`
6) Visual layer
- Canvas/A2UI for future Gameboy-style rendering
7) Project/task state
- Local typed project store at `runtime/projects.json`
- Local typed task store at `runtime/tasks.json`
- Tasks are linked to projects via `projectId` and validated on create/update flows
- Domain entities include project status/owner/title + task owner/DoD/artifacts/rollback plan/per-scope thresholds
- Local HTTP APIs support project list/create/update and task list/create/update-status with schema validation
- Snapshot includes `projectSummaries` for board views and operational counts
8) Budget governance
- Scopes: `agent`, `project`, `task`
- Inputs: session usage snapshots (`tokensIn`, `tokensOut`, optional `cost`)
- Runtime policy file: `runtime/budgets.json` with validated defaults and optional per-scope overrides
- Output status: `ok`, `warn`, `over` via threshold + warn ratio computation
- Commander exceptions endpoint reports `over-budget` entries only
9) Alert routing
- Levels: `info`, `warn`, `action-required`
- Routing targets:
- `info` -> `timeline`
- `warn` -> `operator-watch`
- `action-required` -> `action-queue`
- Exceptions feed endpoint: `GET /exceptions`
- Notification center queue endpoint: `GET /api/action-queue`
- Queue items include links to related `/sessions/:id`, `/tasks`, `/projects` targets when context is available.
- Queue acknowledgements persisted at `runtime/acks.json`
- Ack API endpoint: `POST /api/action-queue/:itemId/ack`
- optional acknowledgement expiry via `ttlMinutes` or `snoozeUntil`
10) Audit timeline
- Aggregates runtime sources into a unified event list:
- `runtime/timeline.log` (monitor diffs)
- `runtime/approval-actions.log` (action audit trail)
- `runtime/operation-audit.log` (import dry-run + backup export actions)
- snapshot health event from `runtime/last-snapshot.json`
- Local page: `GET /audit`
- JSON API: `GET /api/audit`
- Timeline is newest-first with severity filter (`all|info|warn|action-required|error`).
11) Linkage graph + state export
- `GET /graph` returns normalized graph data (`project/task/session/agent` nodes and linkage edges).
- `GET /export/state.json` returns bundled project/task/budget/exception state for downstream consumers.
12) Commander digest
- On each monitor run, a daily digest is written to:
- `runtime/digests/YYYY-MM-DD.json`
- `runtime/digests/YYYY-MM-DD.md`
- Digest captures operational counts + alerts + exception summary for commander review.
13) Pixel data adapter (Phase 7)
- Endpoint: `GET /view/pixel-state.json`
- Purpose: minimal adapter for future Gameboy view.
- Output model: `rooms`, `entities`, `links` generated from local project/task/session/agent state.
14) Notification policy engine (Phase 7)
- Runtime policy file: `runtime/notification-policy.json`
- Supports:
- quiet hours suppression
- severity-to-route mapping (`info/warn/action-required`)
- Preview endpoint: `GET /notifications/preview` (optional `?at=<ISO>` simulation).
15) Cron overview (Phase 7)
- Endpoint: `GET /cron`
- Summarizes next run timing, per-job health (`scheduled/due/late/unknown/disabled`), and monitor lag state.
16) System health panel endpoint (Phase 7)
- Endpoint: `GET /healthz`
- Combines:
- build/runtime info (`package.json`, dist build timestamp, safety gates)
- snapshot freshness (`runtime/last-snapshot.json` age)
- monitor lag (`runtime/timeline.log` latest tick)
17) Markdown digest renderer page (Phase 7)
- Endpoint: `GET /digest/latest`
- Reads latest `runtime/digests/*.md` and renders safe HTML for operator consumption.
18) Operator dashboard preferences + quick filters (Phase 8)
- Home page resolves effective filters from URL query + persisted defaults in `runtime/ui-preferences.json`.
- Compact status strip mode is persisted and toggleable from dashboard UI.
- Quick filters (`all`, `attention`, task states) apply readonly task subsets without mutating task state.
19) Search APIs (Phase 8)
- Endpoints:
- `GET /api/search/tasks`
- `GET /api/search/projects`
- `GET /api/search/sessions`
- `GET /api/search/exceptions`
- Semantics:
- case-insensitive substring matching only (safe includes, no regex eval)
- bounded `limit` query to cap payload size and prevent oversized scans
20) Replay index + API docs (Phase 8)
- `GET /api/replay/index` indexes:
- `runtime/timeline.log`
- `runtime/digests/*.json`
- `runtime/export-snapshots/*.json`
- `runtime/exports/*.json`
- optional query window filters:
- `from=<ISO>`
- `to=<ISO>`
- replay response includes per-source filter observability stats:
- `total`, `returned`, `filteredOut`, `filteredOutByWindow`, `filteredOutByLimit`
- `GET /api/docs` returns route + schema summary payload for operator/debug usage.
- `GET /export/state.json` now also persists timestamped export snapshots under `runtime/export-snapshots/`.
21) Request telemetry correlation (Phase 8)
- Request ID resolution:
- incoming `x-request-id` sanitized and reused when valid
- otherwise generated UUID
- Correlation propagation:
- all responses include `x-request-id`
- JSON responses include top-level `requestId`
- error envelope includes `error.requestId`
- warn/error logs include the same requestId
22) Integration readiness + backup/import surfaces (Phase 9)
- Final integration checklist endpoint:
- `GET /done-checklist` (alias `GET /api/done-checklist`)
- Builds docs-aligned checklist with runtime capability checks.
- Computes lightweight readiness scores: `observability`, `governance`, `collaboration`, `security`.
- Export backup command:
- app command mode `backup-export` writes timestamped bundles to `runtime/exports/*.json`.
- Import dry-run validator:
- API: `POST /api/import/dry-run`
- app command mode: `import-validate <file>`
- validates bundle shape only; no mutation.
- Optional live import mutation endpoint:
- API: `POST /api/import/live`
- requires explicit env gate `IMPORT_MUTATION_ENABLED=true`
- still requires local token auth
- blocked while `READONLY_MODE=true` unless request sets `dryRun=true`
- Commander feed order is normalized for operator priority:
- severity first (`action-required` > `warn` > `info`)
- newest event timestamp within each severity bucket.
23) Local mutation/import auth gate + deterministic ordering hardening (Phase 10)
- New local auth gate defaults:
- `LOCAL_TOKEN_AUTH_REQUIRED=true`
- `LOCAL_API_TOKEN` must be configured for protected operations
- token accepted via `x-local-token` or `Authorization: Bearer`
- Protected routes:
- all state-changing endpoints (`POST`/`PATCH`)
- import/export routes with side effects (`/api/import/dry-run`, `/api/import/live`, `/export/state.json`, `/api/export/state.json`)
- Protected command paths:
- `backup-export`
- `import-validate`
- `acks-prune`
- Operation audit trail:
- import dry-run, import apply, backup export, and ack prune attempts append JSONL entries to `runtime/operation-audit.log`
- audit timeline now includes source `operation`
- Commander feed ordering tie-breakers are now fully deterministic after severity/time (code/source/sourceId/route/message).
24) Replay windowing + acknowledgement TTL semantics (Phase 13)
- Replay index supports optional global time window filtering (`from`, `to`) over:
- timeline entries
- digest entries
- export snapshots
- export bundles
- Action queue acknowledgements support optional expiry:
- `ttlMinutes` (relative duration)
- `snoozeUntil` (absolute future timestamp)
- Expired acknowledgements are treated as inactive, so queue items reappear without manual reset.
25) Replay filter stats + stale ack prune command (Phase 14)
- Replay index exposes per-source and aggregate filtered-vs-returned stats for operator debugging on large windows.
- Replay stats now include per-source latency and size indicators (`latencyMs`, `latencyBucketsMs.p50`, `latencyBucketsMs.p95`, `totalSizeBytes`, `returnedSizeBytes`) for large-instance troubleshooting.
- Home dashboard replay panel surfaces returned/filtered counts to reduce blind spots when window/limit filters hide artifacts.
- Added command-mode stale ack prune:
- `APP_COMMAND=acks-prune`
- optional dry-run via `COMMAND_ARG=--dry-run` or `ACK_PRUNE_DRY_RUN=true`
- local token-gated by default when `LOCAL_TOKEN_AUTH_REQUIRED=true`
- prunes expired `runtime/acks.json` entries and writes `ack_prune` operation audit records.
26) Mission Control v3 parity + office model (Phase 25)
- UI shell upgraded to polished pixel-office visual system (tokenized colors/spacing/radii/shadows, layered background, responsive rails, subtle motion).
- Primary IA renamed for operator intent:
- Command Deck, Usage & Billing, Pixel Office, Work Board, Decisions, Timeline, Control Room.
- Added Mac parity surface matrix panel in Command Deck:
- each core capability exposes status + direct route entry.
- Added agent roster adapter:
- module: `src/runtime/agent-roster.ts`
- source: best-effort read from `~/.openclaw/openclaw.json` (read-only)
- fallback merge with runtime session/task/project/budget signals to avoid missing inactive agents.
- Pixel Office renders desk/zone occupancy instead of session-only grouping.
27) Subscription usage/remaining contract (Phase 25)
- Usage adapter now includes best-effort subscription source probes:
- `runtime/subscription-snapshot.json`
- `~/.openclaw/subscription.json`
- `~/.openclaw/billing/subscription.json`
- `~/.openclaw/billing/usage.json`
- `~/.openclaw/usage/subscription.json`
- Data contract in `UsageCostSnapshot.subscription`:
- `connected`: consumed/remaining/limit/cycle/source available
- `partial`: source found but fields incomplete/parse issues
- `not_connected`: explicit connection hint
- Connector state now includes `subscriptionUsage` and exposes actionable TODOs when disconnected.
## MVP Scope (Phase 1)
- Local scaffold only
- No network calls in default readonly mode
- Define contracts + polling config
## Safety Constraints (Current)
- `READONLY_MODE=true` is the default and remains the primary runtime mode.
- `APPROVAL_ACTIONS_ENABLED=false` by default; no real approve/reject runs unless this is explicitly `true`.
- `APPROVAL_ACTIONS_DRY_RUN=true` by default; action endpoints simulate and audit but do not execute.
- `IMPORT_MUTATION_ENABLED=false` by default; live import apply is disabled unless explicitly gated on.
- `IMPORT_MUTATION_DRY_RUN=false` by default; `/api/import/live` uses request `dryRun=true` for non-mutating execution when needed.
- `LOCAL_TOKEN_AUTH_REQUIRED=true` by default; protected operations remain blocked unless explicit local token is configured and presented.
- Writes are limited to local `control-center/runtime/*` files (`last-snapshot.json`, `timeline.log`, `projects.json`, `tasks.json`, `budgets.json`, `acks.json`, `approval-actions.log`, `operation-audit.log`, `ui-preferences.json`, `export-snapshots/*.json`, `exports/*.json`).
- Runtime digests are local-only artifacts under `control-center/runtime/digests/*`.
- No mutation of OpenClaw runtime config from this project.
## Local API Surface
- `GET /projects`
- `GET /api/projects`
- `POST /api/projects`
- `PATCH /api/projects/:projectId`
- `GET /api/tasks`
- `GET /tasks`
- `GET /sessions`
- `GET /sessions/:id`
- `GET /session/:id`
- `GET /api/sessions`
- `GET /api/sessions/:id`
- `POST /api/tasks`
- `PATCH /api/tasks/:taskId/status`
- `GET /api/action-queue`
- `GET /api/action-queue/acks/prune-preview`
- `POST /api/action-queue/:itemId/ack`
- `GET /exceptions`
- `GET /api/commander/exceptions`
- `GET /graph`
- `GET /view/pixel-state.json`
- `GET /export/state.json`
- `GET /notifications/preview`
- `GET /cron`
- `GET /healthz`
- `GET /digest/latest`
- `GET /audit`
- `GET /api/audit`
- `GET /api/ui/preferences`
- `PATCH /api/ui/preferences`
- `GET /api/search/tasks`
- `GET /api/search/projects`
- `GET /api/search/sessions`
- `GET /api/search/exceptions`
- `GET /api/replay/index`
- `GET /api/docs`
- `GET /done-checklist`
- `GET /api/done-checklist`
- `POST /api/import/dry-run`
- `POST /api/import/live`
- `POST /api/approvals/:approvalId/approve`
- `POST /api/approvals/:approvalId/reject`
+101
View File
@@ -0,0 +1,101 @@
# 24/7 Control Plan (Pandas)
## P0 Checklist (must pass in order)
- [x] P0-1 Single Run Lock
- lock file: `runtime/run.lock`
- acquire rejects concurrent non-expired lock
- renew only works for owner
- release requires owner or expired lock
- fencing token increments on each new acquire after expiry/reacquire
- [x] P0-2 Evidence Gate
- [x] P0-3 Stall Detection + Auto-Heal
- [x] P0-4 Goal State Machine (DoD + phase gates)
## P1 Checklist
- [x] P1-1 Unified artifact directories baseline (`runtime/evidence`, `runtime/health`, `runtime/recovery`)
- [x] P1-2 Periodic health/evidence snapshot automation
- [x] P1-3 Evidence-only reporter wiring to outbound updates
- [x] P1-4 Single-chain watchdog orchestration
## P2 Checklist
- [x] P2-1 Resident worker loop (`worker:resident`) with lock lease renew + cycle heartbeat/progress artifacts
- [x] P2-2 Resident supervisor (`supervisor:resident`) with stale/dead restart decisions, bounded retries, recovery reports
- [x] P2-3 Independent DoD checker (`dod:check`) with machine-readable DoD status artifact and hard gate for done state
## Evidence for P0-1
- command: `npm run lock:acquire`
- command: `npm run lock:status`
- command: `npm run lock:renew`
- command: `npm run lock:release`
- artifact: `runtime/run.lock`
## Evidence for P1-2
- command: `npm run health:snapshot:periodic`
- artifacts:
- `runtime/health/snapshots/<timestamp>.json`
- `runtime/health/latest.pointer.json`
- `runtime/evidence/snapshots/<timestamp>.json`
- `runtime/evidence/latest.pointer.json`
## Evidence for P1-3
- command: `npm run evidence:report`
- command: `node --import tsx scripts/evidence-reporter.ts <incomplete-evidence.json> <outbound-dir>` (fails closed)
- artifacts:
- `runtime/evidence/outbound/<timestamp>.json`
- `runtime/evidence/outbound/latest.json`
- `runtime/evidence/outbound/latest.pointer.json`
- hard gate:
- 当 evidence.completed 包含完成语义(done/completed/完成)时,`evidence:report` 会先执行 `dod:check`
- 若 DoD 未通过,系统层拦截并返回 `REPORT_BLOCKED_DOD NOT_DONE`,禁止发出“完成”消息
## Evidence for P1-4
- command: `npm run watchdog:run`
- artifact: `runtime/recovery/watchdog-latest.json`
- behavior:
- lock owner conflict exits non-zero (`WATCHDOG_LOCK_CONFLICT`)
- owner lock renew/acquire + heal chain succeeds (`WATCHDOG_OK`)
## Product DoD (Ti acceptance gate)
Mission Control 只有在以下标准全部通过时才允许标记完成,否则必须保持 `NOT_DONE`:
- 全量任务可视化:所有 cron + heartbeat 任务可见(谁在执行、在做什么、下次时间、最近结果)
- 非技术化默认视图:不暴露无用技术噪音(PID/命令/JSON键等)
- 全中文彻底 + 初中生可懂
- 一眼看全局(cron/heartbeat/当前任务/工具调用)
- UI 达到 Apple 原生风格水准
- 订阅窗口必须真实接通;若未接通必须明确阻塞与下一步,不可模糊 partial
- Ti 人工验收通过
系统硬规则:
- 未达标 = `NOT_DONE`
- `NOT_DONE` 时禁止宣布完成
- `NOT_DONE` 时施工链必须持续运行,不能停工
DoD 定义文件:`runtime/evidence/product-dod.json`
DoD 状态产物:`runtime/evidence/dod-status.json`
## Evidence for P2 Triplet
- commands:
- `npm run worker:resident`
- `npm run supervisor:resident`
- `npm run dod:check`
- artifacts:
- `runtime/health/worker-heartbeat.json`
- `runtime/evidence/worker-progress.json`
- `runtime/evidence/dod-status.json`
- `runtime/recovery/supervisor-latest.json`
- `runtime/recovery/supervisor-audit.log`
- guarantees:
- worker exits non-zero on lock owner conflict (`WORKER_LOCK_CONFLICT`)
- worker runs resident loop by default: `codex-patch -> test -> evidence-report -> health-snapshot -> next cycle`
- supervisor restarts on stale/dead worker with bounded retries
- neither worker nor supervisor treats run as done unless `dod:check` returns `DONE` (otherwise `NOT_DONE`)
+25
View File
@@ -0,0 +1,25 @@
# Mission Control v3 Parity Matrix
Date: 2026-03-04
## Goal
Track Mission Control v3 parity against core OpenClaw Mac-app control surfaces.
## Surface Matrix
| Surface | Route | Status | Notes |
| --- | --- | --- | --- |
| Conversations / session visibility | `/sessions` | Implemented | Session list, filters, detail drill-down, history preview. |
| Approvals + decision queue | `/?section=projects-tasks&quick=attention#task-lane` + `/api/action-queue` | Implemented | Queue + approval feed surfaced in the task hub decision lane. |
| Cron / scheduling | `/cron` | Implemented | Health, next-run, and lag indicators available. |
| Projects / tasks | `/?section=projects-tasks` | Implemented | Lanes + table + filters and task/project APIs. |
| Usage / cost | `/?section=usage-cost` + `/api/usage-cost` | Implemented | Runtime usage periods, breakdowns, context windows, budget/burn. |
| Subscription consumed/remaining | `/?section=usage-cost` + `/api/usage-cost` | Implemented | Provider snapshot first; runtime usage + 30d budget-limit fallback supplies consumed/remaining/limit when provider snapshots are missing. |
| Replay / audit | `/audit` | Implemented | Audit page/API are the stable user-facing replay entry; old replay section routes now resolve back to overview. |
| Health / digest | `/digest/latest` + `/healthz` | Implemented | Digest page is the operator-facing entry; `/healthz` remains the machine-readable health payload. |
| Export / import dry-run + safety gates | `/?section=settings` + `/export/state.json` + `/api/import/dry-run` | Implemented | Guard table shows gate posture; dry-run remains non-mutating by default. |
| Pixel / canvas adapter | `/view/pixel-state.json` | Implemented | Pixel scene payload (rooms/entities/links). |
| Full agent roster office model | `/?section=office-space` | Implemented | Roster merged from config + runtime with zone/desk occupancy floor. |
## Known Caveats
- UI-mode smoke in restricted sandboxes can fail with environment bind errors; use free ports and elevated execution when required.
- Provider subscription snapshots can still be unavailable; in that case values are derived from runtime spend + configured 30d budget limit and marked in source/detail fields.
+4080
View File
File diff suppressed because it is too large. Load diff
+54
View File
@@ -0,0 +1,54 @@
# Publishing Guide
This project is safest to publish as its own standalone repository.
## Recommended release boundary
- Publish only the `control-center/` directory.
- Do not publish the larger parent workspace/repository.
- Keep runtime output, build output, local env files, and internal planning artifacts out of the public repo.
## Best-practice flow
### 1. Work inside `control-center/`
All release checks and commits should happen from this directory.
### 2. Run the public-release audit
```bash
npm run release:audit
npm run build
npm test
npm run smoke:ui
```
### 3. Keep the first public repo safe
Use these defaults for the public project:
- `READONLY_MODE=true`
- `LOCAL_TOKEN_AUTH_REQUIRED=true`
- `APPROVAL_ACTIONS_ENABLED=false`
- `IMPORT_MUTATION_ENABLED=false`
### 4. Create a standalone local git repo here
If this directory is not already its own git repo:
```bash
git init -b main
git add .
git commit -m "Initial public release"
```
### 5. Create an empty GitHub repository
Create a new empty repository on GitHub for `control-center` only.
### 6. Connect and push
```bash
git remote add origin <your-github-repo-url>
git push -u origin main
```
## Pre-push checklist
- README renders correctly on GitHub
- screenshots load
- no absolute personal home paths
- no hard-coded secrets or live tokens
- no runtime artifacts
- no internal-only planning or workflow files
- tests/build/smoke all pass
+442
View File
@@ -0,0 +1,442 @@
# Mission Control Runbook
## 1) Safety defaults
- Keep `READONLY_MODE=true` unless explicitly validating live reads.
- Keep `LOCAL_TOKEN_AUTH_REQUIRED=true` by default.
- Keep `APPROVAL_ACTIONS_ENABLED=false` by default.
- Keep `APPROVAL_ACTIONS_DRY_RUN=true` by default.
- Keep `IMPORT_MUTATION_ENABLED=false` by default.
- Keep `IMPORT_MUTATION_DRY_RUN=false` by default.
- Never modify `~/.openclaw/openclaw.json` from this project.
## 2) Startup
1. `npm run build`
2. `npm test`
3. `npm run validate`
4. `npm run dev` (smoke monitor run)
5. Optional UI mode: `UI_MODE=true npm run dev`
- In restricted sandboxes, `listen EPERM` on `127.0.0.1:*` is environment-only (socket bind restriction), not a control-center functional regression.
6. Optional continuous monitor: `npm run dev:continuous`
## 3) Enable live mode safely
1. Confirm baseline safety checks:
- `READONLY_MODE=true`
- `LOCAL_TOKEN_AUTH_REQUIRED=true`
- `APPROVAL_ACTIONS_ENABLED=false`
- `APPROVAL_ACTIONS_DRY_RUN=true`
- `IMPORT_MUTATION_ENABLED=false`
- `IMPORT_MUTATION_DRY_RUN=false`
- `npm run build` passes
2. Enable live reads only (no approval execution):
- Run with `READONLY_MODE=false` and keep:
- `APPROVAL_ACTIONS_ENABLED=false`
- `APPROVAL_ACTIONS_DRY_RUN=true`
3. Validate behavior in UI:
- `GET /snapshot`
- `GET /tasks`
- `GET /exceptions`
4. Only if explicitly required, enable approval execution:
- `READONLY_MODE=false`
- `APPROVAL_ACTIONS_ENABLED=true`
- `APPROVAL_ACTIONS_DRY_RUN=false`
5. After any live test, return to defaults immediately:
- `READONLY_MODE=true`
- `LOCAL_TOKEN_AUTH_REQUIRED=true`
- `APPROVAL_ACTIONS_ENABLED=false`
- `APPROVAL_ACTIONS_DRY_RUN=true`
- `IMPORT_MUTATION_ENABLED=false`
- `IMPORT_MUTATION_DRY_RUN=false`
## 4) Local token auth gate
- Protected operations require local token auth when `LOCAL_TOKEN_AUTH_REQUIRED=true` (default):
- all state-changing routes (`POST`/`PATCH` APIs and form ack route)
- task heartbeat execution route (`POST /api/tasks/heartbeat`)
- import/export routes with side effects (`POST /api/import/dry-run`, `POST /api/import/live`, `GET /export/state.json`, `GET /api/export/state.json`)
- protected command modes (`command:backup-export`, `command:import-validate`, `command:acks-prune`, `command:task-heartbeat`)
- Configure explicit gate token:
- `LOCAL_API_TOKEN=<strong-random-token>`
- Send token from client:
- `x-local-token: <LOCAL_API_TOKEN>`
- or `Authorization: Bearer <LOCAL_API_TOKEN>`
- If `LOCAL_API_TOKEN` is unset while gate is enabled:
- protected operations are blocked by design.
## 5) Approval actions (runtime-gated)
- Endpoints (also require local token auth):
- `POST /api/approvals/:approvalId/approve`
- `POST /api/approvals/:approvalId/reject` (requires JSON body `{"reason":"..."}`)
- Gate logic:
- If `APPROVAL_ACTIONS_ENABLED=false`: blocked (no execution)
- If `APPROVAL_ACTIONS_DRY_RUN=true`: simulated (no execution)
- If `READONLY_MODE=true`: blocked (no execution)
- Real execution only when:
- `APPROVAL_ACTIONS_ENABLED=true`
- `APPROVAL_ACTIONS_DRY_RUN=false`
- `READONLY_MODE=false`
- All attempts are written to `runtime/approval-actions.log` as JSON lines.
## 6) Project + Task APIs
- `GET /projects`: project list with optional filters:
- `status=planned|active|blocked|done`
- `owner=<owner>`
- `projectId=<projectId>`
- `GET /api/projects`: compatibility endpoint with same query filters.
- `POST /api/projects`: create project in `runtime/projects.json`.
- `PATCH /api/projects/:projectId`: update `title`, `status`, `owner`.
- `GET /tasks`: flattened task list with optional filters:
- `status=todo|in_progress|blocked|done`
- `owner=<owner>`
- `project=<projectId>`
- `GET /api/tasks`: compatibility endpoint with the same query filters.
- `POST /api/tasks`: create task in `runtime/tasks.json`.
- `PATCH /api/tasks/:taskId/status`: status transition.
- `GET /api/tasks/heartbeat`: recent heartbeat runs from `runtime/task-heartbeat.log`.
- `POST /api/tasks/heartbeat`: execute assigned-backlog heartbeat pickup (supports `dryRun` + `maxTasksPerRun` override).
- Mutation endpoints above require local token auth.
- Validation:
- Strong schema checks for required fields, enums, and bounded field lengths.
- Task create/update validates linked `projectId` against `runtime/projects.json`.
- Invalid payloads return 4xx with `issues`.
- Unknown project/task IDs return 404.
### Heartbeat backlog automation
- Monitor cycles now include a task heartbeat pass that checks the board for assigned backlog tasks (`status=todo` + assigned owner).
- Default gate posture:
- `TASK_HEARTBEAT_ENABLED=true`
- `TASK_HEARTBEAT_DRY_RUN=true`
- `TASK_HEARTBEAT_MAX_TASKS_PER_RUN=3`
- Live promotion (`todo -> in_progress`) requires:
- heartbeat dry-run disabled (`TASK_HEARTBEAT_DRY_RUN=false` or request body `{"dryRun":false}`)
- local token configured when `LOCAL_TOKEN_AUTH_REQUIRED=true`
- Evidence file:
- `runtime/task-heartbeat.log` (JSONL; includes selected/executed counts and gate mode)
## 7) Commander exceptions + action queue
- Endpoint: `GET /api/commander/exceptions`
- Summarizes:
- blocked sessions
- errored sessions
- pending approvals
- over-budget evaluations
- tasks due (`dueAt <= now`, excluding `done`)
- Routed feed endpoint: `GET /exceptions`
- Levels: `info`, `warn`, `action-required`
- Routes: `timeline`, `operator-watch`, `action-queue`
- Notification center endpoint: `GET /api/action-queue`
- Derived from `GET /exceptions` action-required route entries
- Includes acknowledgement state from `runtime/acks.json`
- Includes `links[]` with relevant session/task/project endpoints for operator jump navigation
- Acknowledge endpoint: `POST /api/action-queue/:itemId/ack`
- Persists/updates local ack entry in `runtime/acks.json`
- Optional expiry controls: `ttlMinutes` (1..10080) or `snoozeUntil` (future ISO timestamp)
- `ttlMinutes` and `snoozeUntil` are mutually exclusive
- UI form uses minimal POST to `/action-queue/ack`
- Requires local token auth
## 8) Session visibility APIs
- `GET /sessions` / `GET /api/sessions`
- Local readonly view combining session list with latest history snippets per session
- Query filters:
- `state=idle|running|blocked|waiting_approval|error`
- `agentId=<agentId>`
- `q=<search>`
- pagination: `page`, `pageSize`, `historyLimit`
- `GET /sessions/:id`
- Session detail + usage status + recent history entries (`historyLimit` optional)
- `GET /api/sessions/:id`
- Explicit API alias for session detail JSON
- `GET /session/:id`
- UI drill-down page rendering latest messages and tool events per session
- History content is safely truncated for readonly rendering
## 9) Audit timeline
- `GET /audit` renders newest-first runtime timeline view with severity filter.
- `GET /api/audit` returns JSON timeline.
- Data sources:
- `runtime/timeline.log`
- `runtime/approval-actions.log`
- `runtime/operation-audit.log`
- current snapshot (`runtime/last-snapshot.json`)
- Supported severities: `all`, `info`, `warn`, `action-required`, `error`.
## 10) Graph + export APIs
- `GET /graph`
- Returns project-task-session-agent linkage graph JSON (nodes + edges + counts)
- `GET /export/state.json`
- Bundles current sessions/tasks/projects/budgets/exceptions into one portable JSON document
- Requires local token auth
- Writes timestamped copies to:
- `runtime/export-snapshots/*.json` (replay/debug index)
- `runtime/exports/*.json` (backup/import target bundles)
## 11) Commander digest
- Every monitor run writes/refreshes the current day digest files:
- `runtime/digests/YYYY-MM-DD.json`
- `runtime/digests/YYYY-MM-DD.md`
- Digest summarizes sessions, approvals, projects, tasks, budgets, alerts, and top exceptions.
## 12) Phase 7 operations
- Pixel adapter:
- endpoint: `GET /view/pixel-state.json`
- source: snapshot + local project/task/session links
- Notification policy preview:
- config file: `runtime/notification-policy.json`
- endpoint: `GET /notifications/preview`
- optional simulation: `GET /notifications/preview?at=<ISO-8601>`
- Cron overview:
- endpoint: `GET /cron`
- reports next run + per-job health + monitor lag summary
- System health:
- endpoint: `GET /healthz`
- includes build info + snapshot freshness + monitor lag
- Digest renderer:
- endpoint: `GET /digest/latest`
- renders latest markdown digest file as HTML
## 13) Phase 8 operations
- Operator dashboard polish:
- compact status strip toggle persisted in `runtime/ui-preferences.json`
- quick filter chips (`all`, `attention`, task states)
- home/dashboard query choices auto-persist as default UI preferences
- UI preference APIs:
- `GET /api/ui/preferences`
- `PATCH /api/ui/preferences`
- Search APIs (safe substring + bounded limits):
- `GET /api/search/tasks?q=&limit=`
- `GET /api/search/projects?q=&limit=`
- `GET /api/search/sessions?q=&limit=`
- `GET /api/search/exceptions?q=&limit=`
- Replay/debug index API:
- `GET /api/replay/index?timelineLimit=&digestLimit=&exportLimit=&from=&to=`
- Data sources: `runtime/timeline.log`, `runtime/digests/`, `runtime/export-snapshots/`, `runtime/exports/`
- `from` / `to` are optional ISO date-time filters for replay artifact windows
- response includes per-source replay filter stats (`total`, `returned`, `filteredOut`, `filteredOutByWindow`, `filteredOutByLimit`, `latencyMs`, `latencyBucketsMs(p50,p95)`, `totalSizeBytes`, `returnedSizeBytes`)
- API docs summary endpoint:
- `GET /api/docs`
- Telemetry correlation:
- every response returns `x-request-id` header
- JSON responses include `requestId`
- error logs include requestId for correlation
## 14) Phase 9 operations
- Final integration checklist endpoint:
- `GET /done-checklist` (alias: `GET /api/done-checklist`)
- combines docs-aligned checks + runtime capability checks
- includes readiness scoring for:
- observability
- governance
- collaboration
- security
- Commander feed polish:
- `/exceptions` now returns items sorted by severity first (`action-required` > `warn` > `info`), then newest by event timestamp.
- Backup/export command mode:
- `npm run command:backup-export`
- requires `LOCAL_API_TOKEN` when `LOCAL_TOKEN_AUTH_REQUIRED=true`
- writes timestamped bundle under `runtime/exports/*.json`
- Import dry-run validator:
- command: `npm run command:import-validate -- runtime/exports/<file>.json`
- API: `POST /api/import/dry-run`
- command/API require local token auth when `LOCAL_TOKEN_AUTH_REQUIRED=true`
- validation is dry-run only; no state mutation.
- Ack prune command mode:
- command: `npm run command:acks-prune`
- optional dry-run: `COMMAND_ARG=--dry-run npm run command:acks-prune`
- command requires local token auth when `LOCAL_TOKEN_AUTH_REQUIRED=true`
- prunes expired acknowledgement records from `runtime/acks.json`
- Ack prune preview API:
- API: `GET /api/action-queue/acks/prune-preview`
- requires local token auth when `LOCAL_TOKEN_AUTH_REQUIRED=true`
- returns `before/removed/after` counts only (no write)
- Optional live import mutation endpoint:
- API: `POST /api/import/live`
- HIGH RISK and disabled by default (`IMPORT_MUTATION_ENABLED=false`)
- requires local token auth and `READONLY_MODE=false` for live apply
- `dryRun=true` forces non-mutating validation path on this endpoint
- Operation audit:
- import dry-run + import apply + backup export + ack prune actions are appended to `runtime/operation-audit.log`.
## 15) UX v2 operator navigation
- Home dashboard now uses section tabs via `/?section=<tab>` with six supported values:
- `overview`
- `office-space`
- `projects-tasks`
- `alerts`
- `replay-audit`
- `settings`
- Layout intent:
- left rail: major section navigation
- center panel: section content
- right rail: office context + quick shortcuts
- Office-space behavior:
- auto-derives agent roster from sessions, task owners, project owners, and agent budget entries
- maps each agent to a deterministic animal identity from name semantics
- reports zone + workload summary ("busy on what") per agent
- Empty states:
- user-facing empty blocks should render as `Not activated yet`
- avoid exposing raw zero-heavy debug values when no actionable signal exists
## 16) Usage and cost observability (Phase 22)
- New route surfaces:
- `GET /usage-cost` (redirects to `/?section=usage-cost`)
- `GET /api/usage-cost` (JSON snapshot)
- Dashboard placement:
- card-level pulse on `Overview`
- dedicated `Usage & Cost` section in left sidebar navigation
- right-rail usage summary card
- Usage adapter sources:
- live snapshot status data (`sessions + session_status`)
- OpenClaw session runtime stores (`~/.openclaw/agents/*/sessions/sessions.json` + `*.jsonl`) for real request/tokens/cost windows (`today/7d/30d`) and per-source breakdown
- digest history (`runtime/digests/*.json`) as fallback trend source when runtime request events are unavailable
- optional model context catalog (`runtime/model-context-catalog.json`) for context-window percentages
- Disconnected metrics policy:
- if source is missing, render `Data source not connected` (do not fabricate zero values)
- if runtime source is connected but usage is truly zero, render zero values (not placeholders)
- context-window limits prefer live session context metadata and fallback to model catalog only when needed
- Settings connector checklist:
- model context catalog
- digest history continuity (fallback)
- runtime request/event source
- cost budget limit source
- provider attribution refinement
## 19) Mission Control v3 operations (Phase 25)
- Navigation and IA (UI sections):
- `overview` -> Command Deck
- `usage-cost` -> Usage & Billing
- `office-space` -> Pixel Office
- `projects-tasks` -> Work Board
- `alerts` -> Decisions
- `replay-audit` -> Timeline
- `settings` -> Control Room
- Mac parity surfaces panel:
- shown in Command Deck with per-surface status + route links
- covers sessions, approvals, cron, projects/tasks, usage/cost, replay/audit, health/digest, export/import safety, and pixel adapter
- Full office roster behavior:
- best-effort config source: `~/.openclaw/openclaw.json` (read-only)
- adapter module: `src/runtime/agent-roster.ts`
- fallback keeps known agents visible even when no active runtime sessions exist
- Subscription usage/remaining behavior:
- adapter module: `src/runtime/usage-cost.ts`
- source probes (best effort):
- `runtime/subscription-snapshot.json`
- `~/.openclaw/subscription.json`
- `~/.openclaw/billing/subscription.json`
- `~/.openclaw/billing/usage.json`
- `~/.openclaw/usage/subscription.json`
- contract states:
- `connected`: consumed/remaining/limit/cycle shown
- `partial`: file detected but fields incomplete
- `not_connected`: explicit connect hint shown (no fake zeros)
## 17) Runtime artifacts
- `runtime/last-snapshot.json`: latest read model snapshot.
- `runtime/timeline.log`: monitor run deltas.
- `runtime/projects.json`: local project store.
- `runtime/tasks.json`: local task store.
- `runtime/budgets.json`: budget policy defaults and per-scope overrides.
- `runtime/notification-policy.json`: notification quiet-hours + severity-route policy.
- `runtime/model-context-catalog.json`: optional model context-window map (`match/contextWindowTokens/provider`) for context-usage percentages.
- `runtime/ui-preferences.json`: dashboard preference state (`compactStatusStrip`, `quickFilter`, `taskFilters`).
- `runtime/acks.json`: notification action-queue acknowledgements.
- `runtime/approval-actions.log`: audit trail for approval action attempts.
- `runtime/operation-audit.log`: audit trail for import dry-run, import apply, backup export, and ack prune actions.
- `runtime/digests/`: daily commander digest JSON + Markdown.
- `runtime/export-snapshots/`: timestamped state export snapshots for replay/debug.
- `runtime/exports/`: timestamped export bundles for backup + import validation.
## 18) Rollback steps
1. Restore safe runtime mode:
- `READONLY_MODE=true`
- `LOCAL_TOKEN_AUTH_REQUIRED=true`
- `APPROVAL_ACTIONS_ENABLED=false`
- `APPROVAL_ACTIONS_DRY_RUN=true`
2. Revert only `control-center/` code changes (git revert or selective checkout from known-good commit).
3. Rebuild and smoke test:
- `npm run build`
- `npm run dev`
4. Verify rollback signals:
- `/projects` and `/api/projects` return expected payloads
- `/tasks` and `/api/tasks` return expected payloads
- `/api/action-queue` returns valid JSON and ack state
- `/exceptions` and `/api/commander/exceptions` return valid JSON
- `/view/pixel-state.json` returns valid adapter JSON
- `/notifications/preview` returns policy evaluation JSON
- `/cron` and `/healthz` return health payloads
- `/digest/latest` renders latest digest page
- `/api/docs` returns route/schema summary
- `/api/replay/index` returns timeline/digest/export index
- `/api/search/*` endpoints return bounded matches
- UI project board/task board/action queue sections match expected view
5. If budget behavior regressed, restore `runtime/budgets.json` to the last known-good content.
6. If notification routing behavior regressed, restore `runtime/notification-policy.json` to last known-good.
## 19) Troubleshooting
- Build failures:
- Run `npm run build` and fix TypeScript errors first.
- Validation failures:
- Run `npm run validate` and inspect failing assertions.
- Empty dashboard:
- Confirm `runtime/last-snapshot.json` exists (created by `npm run dev`).
- Health endpoint stale:
- confirm monitor wrote a fresh line in `runtime/timeline.log`
- rerun `npm run dev` to regenerate snapshot and timeline tick
- UI mode bind fails with `listen EPERM`:
- classify as environment-only in restricted sandboxes where localhost bind is blocked
- re-run `UI_MODE=true npm run dev` in unrestricted host runtime for bind + `/healthz` verification
## 20) Operational Checklist (Phase 22)
- [ ] Safety gates unchanged:
- `READONLY_MODE=true`
- `LOCAL_TOKEN_AUTH_REQUIRED=true`
- `APPROVAL_ACTIONS_ENABLED=false`
- `APPROVAL_ACTIONS_DRY_RUN=true`
- `IMPORT_MUTATION_ENABLED=false`
- `IMPORT_MUTATION_DRY_RUN=false`
- `LOCAL_API_TOKEN` set explicitly for protected operations.
- [ ] `runtime/notification-policy.json` exists and is valid JSON.
- [ ] `runtime/ui-preferences.json` exists and is valid JSON.
- [ ] `GET /view/pixel-state.json` returns `rooms/entities/links`.
- [ ] `GET /notifications/preview` returns quiet-hours + route preview.
- [ ] `GET /cron` returns next-run and health summary.
- [ ] `GET /healthz` returns build/snapshot/monitor status fields.
- [ ] `GET /digest/latest` renders latest digest markdown.
- [ ] `GET /api/ui/preferences` + `PATCH /api/ui/preferences` work and persist.
- [ ] `GET /api/search/tasks|projects|sessions|exceptions` support `q` + bounded `limit`.
- [ ] Home dashboard search panel is visible and returns scoped results for `tasks|projects|sessions|exceptions`.
- [ ] `GET /api/replay/index` includes timeline, digests, export snapshots, and export bundles.
- [ ] `GET /api/replay/index?from=&to=` applies optional ISO time-window filtering.
- [ ] `GET /api/replay/index` includes replay `stats` with `total/returned/filteredOut` (+ window/limit breakdown) and per-source latency/size indicators.
- [ ] Home dashboard replay/export visibility panel shows counts + latest snapshot/bundle labels.
- [ ] Home dashboard shows section navigation for:
- Overview, Usage & Cost, Office Space, Projects/Tasks, Alerts, Replay/Audit, Settings.
- [ ] Office Space section renders per-agent cards with:
- semantic animal identity
- status label
- zone placement
- "busy on what" summary.
- [ ] Empty lists/cards use `Not activated yet` operator copy (instead of raw `none` placeholders).
- [ ] `GET /api/usage-cost` returns:
- periods (`today/7d/30d`)
- context windows
- breakdown by agent/project/model/provider
- budget burn-rate status
- connector TODO list.
- [ ] `runtime/model-context-catalog.json` exists or Settings TODO explicitly shows the missing connector.
- [ ] Usage & Cost section displays disconnected metrics as `Data source not connected` instead of fake zeros.
- [ ] `GET /api/docs` returns route/schema summary.
- [ ] Home import/safety guard table shows explicit disabled/enabled badges.
- [ ] Guard/checklist doc refs are clickable and resolve via `/docs/*`.
- [ ] `GET /docs` renders local docs index.
- [ ] `GET /docs/runbook|architecture|progress|readme` return markdown content.
- [ ] `GET /done-checklist` returns checklist + readiness scores.
- [ ] `POST /api/import/dry-run` validates bundles with no mutation.
- [ ] `POST /api/import/live` stays blocked unless explicit env gate + token are enabled.
- [ ] `POST /api/action-queue/:itemId/ack` supports optional `ttlMinutes` / `snoozeUntil` and expired acks re-open queue items.
- [ ] `GET /api/action-queue/acks/prune-preview` returns token-gated stale-ack prune counts with no mutation.
- [ ] `npm run command:backup-export` writes a bundle to `runtime/exports/`.
- [ ] `npm run command:acks-prune` removes expired entries from `runtime/acks.json` (or reports no-op in dry-run).
- [ ] import dry-run + import apply + backup export + ack prune actions write entries in `runtime/operation-audit.log`.
- [ ] responses include `x-request-id` and JSON `requestId`.
- [ ] `npm test` passes.
- [ ] `npm run build` passes.
- [ ] `npm run dev` smoke passes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

+29
View File
@@ -0,0 +1,29 @@
# Mission Control Runbook v2
## Quick health checks
- `openclaw status --deep`
- `openclaw cron list --json`
- Verify reporter output in your configured reporting destination
## Snapshot / rollback
Snapshot file:
- `runtime/evidence/mission-control-jobs-v2-snapshot.json`
Rollback principle:
1) Read snapshot
2) For each job id in snapshot, `cron update` with stored schedule/payload/delivery/enable settings
3) Re-run regression check job
## Regression monitor
- Job: `pandas-mc-regression-check-30m`
- Expected line:
- `REGRESSION | status=ok | ...`
- If `status=alert|fail`:
1) check `runtime/evidence/regression-status.json`
2) check `runtime/state.json` and lockdirs
3) restart affected chain only (avoid global restart first)
## SLO suggestion
- state freshness <= 20m
- stale lockdirs = 0
- reporter cadence = 15m
+48
View File
@@ -0,0 +1,48 @@
# Mission Control Template v2 (Reusable)
## Roles (4 jobs)
1. Worker (do work)
2. Watchdog (single-chain + restart)
3. Guard (resident process liveness)
4. Reporter (human-facing status)
## Required files
- runtime/state.json (single source of truth)
- runtime/evidence/dod-status.json
- runtime/evidence/<mission>-report.md
- runtime/locks/*.lockdir (atomic lock via mkdir)
## State contract (minimum)
```json
{
"role": "worker|watchdog|guard|reporter",
"lastRunAt": "ISO-8601",
"updatedAt": "ISO-8601",
"status": "IN_PROGRESS|DONE|ALERT|FAIL|STALE",
"partial": 0,
"missing": 0,
"activeCodex": 0,
"blocker": "none",
"next": "next action"
}
```
## Schedule defaults (staggered)
- Worker: every 5m
- Watchdog: every 7m
- Guard: every 11m
- Reporter: every 15m
## Failure escalation
- Worker fail -> fallback command once -> still fail => FAIL + blocker
- Watchdog same blocker 3 rounds => ALERT
- Guard fail 2 rounds => ALERT
- Reporter stale >20m => STALE + human intervention line
## Reporter output contract (strict 4 lines)
- 做了什么:
- 目标是什么:
- 哪些没完成:
- 正在做什么:
No extra text.
+30
View File
@@ -0,0 +1,30 @@
const env = {
UI_MODE: process.env.UI_MODE ?? "true",
UI_PORT: process.env.UI_PORT ?? "4310",
READONLY_MODE: process.env.READONLY_MODE ?? "true",
LOCAL_TOKEN_AUTH_REQUIRED: process.env.LOCAL_TOKEN_AUTH_REQUIRED ?? "true",
MONITOR_CONTINUOUS: process.env.MONITOR_CONTINUOUS ?? "true",
GATEWAY_URL: process.env.GATEWAY_URL ?? "ws://127.0.0.1:18789",
};
if (process.env.LOCAL_API_TOKEN) {
env.LOCAL_API_TOKEN = process.env.LOCAL_API_TOKEN;
}
module.exports = {
apps: [
{
name: "pandas-control-center",
cwd: __dirname,
script: "node",
args: "--import tsx src/index.ts",
env,
autorestart: true,
max_restarts: 10,
restart_delay: 2000,
out_file: "runtime/pm2-out.log",
error_file: "runtime/pm2-error.log",
time: true
}
]
};
+590
View File
@@ -0,0 +1,590 @@
{
"name": "openclaw-mission-control-mvp",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-mission-control-mvp",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^22.13.10",
"tsx": "^4.20.0",
"typescript": "^5.8.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "openclaw-mission-control-mvp",
"version": "0.1.0",
"description": "Safety-first local control center for OpenClaw observability, task operations, and operator review.",
"license": "MIT",
"type": "commonjs",
"scripts": {
"dev": "node --import tsx src/index.ts",
"dev:continuous": "MONITOR_CONTINUOUS=true node --import tsx src/index.ts",
"dev:ui": "UI_MODE=true node --import tsx src/index.ts",
"smoke:ui": "bash scripts/ui-smoke.sh",
"command:backup-export": "APP_COMMAND=backup-export node --import tsx src/index.ts",
"avatars:export": "node --import tsx scripts/export-staff-avatars.ts",
"command:import-validate": "APP_COMMAND=import-validate node --import tsx src/index.ts",
"command:acks-prune": "APP_COMMAND=acks-prune node --import tsx src/index.ts",
"command:task-heartbeat": "APP_COMMAND=task-heartbeat node --import tsx src/index.ts",
"test": "node --import tsx --test test/**/*.test.ts",
"build": "tsc -p tsconfig.json",
"release:audit": "bash scripts/release-audit.sh",
"validate:task-store": "node --import tsx scripts/validate-task-store.ts",
"validate:budget": "node --import tsx scripts/validate-budget-compute.ts",
"validate": "npm run validate:task-store && npm run validate:budget",
"lock:status": "node --import tsx scripts/run-lock.ts status",
"lock:acquire": "node --import tsx scripts/run-lock.ts acquire",
"lock:renew": "node --import tsx scripts/run-lock.ts renew",
"lock:release": "node --import tsx scripts/run-lock.ts release",
"evidence:emit": "node --import tsx scripts/evidence-gate.ts emit",
"evidence:validate": "node --import tsx scripts/evidence-gate.ts validate",
"evidence:report": "node --import tsx scripts/evidence-reporter.ts",
"heal:check": "node --import tsx scripts/stall-auto-heal.ts",
"watchdog:run": "node --import tsx scripts/watchdog-orchestrator.ts",
"goal:gate": "node --import tsx scripts/goal-gate.ts",
"health:snapshot": "node --import tsx scripts/health-snapshot.ts",
"health:snapshot:periodic": "node --import tsx scripts/periodic-snapshot.ts",
"worker:resident": "node --import tsx scripts/resident-worker.ts",
"supervisor:resident": "node --import tsx scripts/resident-supervisor.ts",
"dod:check": "node --import tsx scripts/dod-check.ts"
},
"devDependencies": {
"@types/node": "^22.13.10",
"tsx": "^4.20.0",
"typescript": "^5.8.2"
}
}
+651
View File
@@ -0,0 +1,651 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
interface DoDCheckResult {
id: string;
passed: boolean;
detail: string;
}
type DoDStatus = "DONE" | "NOT_DONE";
interface DoDStatusArtifact {
generatedAt: string;
runtimeDir: string;
status: DoDStatus;
passed: boolean;
checks: DoDCheckResult[];
}
interface WorkerProgress {
cycle?: number;
lastStep?: {
id?: string;
exitCode?: number;
};
}
interface ProductDoDDefinition {
fullChinese: boolean;
nonTechnicalCopy: boolean;
middleSchoolReadable: boolean;
globalVisibility: boolean;
appleNativeUI?: boolean;
realSubscriptionConnected?: boolean;
tiApproval?: boolean;
notes?: string;
}
interface ProductSourceAudit {
fullChinese: boolean;
nonTechnicalCopy: boolean;
middleSchoolReadable: boolean;
globalVisibility: boolean;
appleNativeUI: boolean;
details: {
fullChinese: string;
nonTechnicalCopy: string;
middleSchoolReadable: string;
globalVisibility: string;
appleNativeUI: string;
};
}
interface RealSubscriptionAudit {
connected: boolean;
detail: string;
sourcePath?: string;
}
interface PhraseGroup {
id: string;
options: string[];
}
const REQUIRED_ZH_GROUPS: PhraseGroup[] = [
{ id: "title", options: ["全局可视", "全局总览"] },
{
id: "summary",
options: [
"一眼看全局:定时任务、任务心跳、当前任务和工具调用。",
"一眼看全局:定时任务、任务心跳、当前任务、工具调用。",
"一眼看四件事:定时任务、任务心跳、当前任务、工具调用。",
],
},
{ id: "schedule", options: ["定时任务:"] },
{ id: "heartbeat", options: ["任务心跳:", "心跳检查:"] },
{ id: "currentTasks", options: ["当前任务:", "进行中任务:"] },
{ id: "toolCalls", options: ["工具调用:"] },
];
const REQUIRED_EN_GROUPS: PhraseGroup[] = [
{ id: "title", options: ["Global Visibility"] },
{
id: "summary",
options: [
"One place to see cron, heartbeat, current tasks, and tool calls.",
"One place to see timed jobs, heartbeat, current tasks, and tool calls.",
"At a glance: timed jobs, heartbeat, current tasks, and tool calls.",
"Simple view: schedule checks (cron), heartbeat checks, current tasks, and tool calls.",
"Simple view: schedule (cron), heartbeat, tasks in progress, and tool calls.",
],
},
{ id: "schedule", options: ["Cron:", "Timed jobs:", "Schedule checks (cron):", "Schedule (cron):"] },
{ id: "heartbeat", options: ["Heartbeat:", "Heartbeat checks:"] },
{ id: "currentTasks", options: ["Current tasks:", "Tasks in progress:"] },
{ id: "toolCalls", options: ["Tool calls:"] },
];
const GLOBAL_VISIBILITY_RENDER_TOKENS = [
"renderGlobalVisibilityStrip(",
"renderGlobalVisibilityCard(",
"renderGlobalVisibilityStripCard(",
'<div class="content-stack">${globalVisibilityBlock}${sectionBody}</div>',
"copy.scheduleLabel",
"copy.heartbeatLabel",
"copy.currentTasksLabel",
"copy.toolCallsLabel",
'buildGlobalVisibilityDetailHref("cron", language)',
'buildGlobalVisibilityDetailHref("heartbeat", language)',
'buildGlobalVisibilityDetailHref("current_task", language)',
'buildGlobalVisibilityDetailHref("tool_call", language)',
'taskType: "cron"',
'taskType: "heartbeat"',
'taskType: "current_task"',
'taskType: "tool_call"',
"signalCounts: {",
"schedule: enabledCronCount",
"heartbeat: enabledHeartbeatCount",
"currentTasks: currentTasksCount",
"toolCalls: toolCallsCount",
];
const GLOBAL_VISIBILITY_RENDER_GROUPS: PhraseGroup[] = [
{
id: "overview-block",
options: [
'const globalVisibilityBlock = options.section === "overview" ? globalVisibilityCard : globalVisibilityStripCard;',
'const globalVisibilityBlock = options.section === "overview" ? globalVisibilityCard : "";',
],
},
];
const NON_TECH_JARGON = ["telemetry", "instrumentation", "payload", "schema", "protocol", "SDK"];
const MIXED_ZH_PHRASES = [
"Cron 正在运行。",
"还没有设置 Cron。",
"保持 Cron 开启。",
"定时任务(cron)",
"定时检查(cron)",
"定时安排(cron)",
];
const APPLE_NATIVE_UI_RENDER_TOKENS = [
"data-apple-window-controls=\"true\"",
"--apple-glass-blur",
"-webkit-backdrop-filter",
"@media (prefers-reduced-motion: reduce)",
];
const APPLE_NATIVE_UI_RENDER_GROUPS: PhraseGroup[] = [
{
id: "polish-marker",
options: [
'data-ui-polish="apple-native-v1"',
'data-ui-polish="apple-native-v2"',
'data-ui-polish="apple-native-v3"',
],
},
{
id: "hover-elevation",
options: [
".card:hover { transform: translateY(-1px);",
".card:hover {",
],
},
];
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function runNodeScript(
scriptPath: string,
args: string[],
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
cwd: process.cwd(),
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", rejectPromise);
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
});
}
async function readProgress(path: string): Promise<WorkerProgress | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as WorkerProgress;
} catch {
return null;
}
}
async function readProductDoD(path: string): Promise<ProductDoDDefinition | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as ProductDoDDefinition;
} catch {
return null;
}
}
function findMissingPhrases(input: string, phrases: string[]): string[] {
return phrases.filter((phrase) => !input.includes(phrase));
}
function findMissingGroups(input: string, groups: PhraseGroup[]): string[] {
return groups
.filter((group) => group.options.every((phrase) => !input.includes(phrase)))
.map((group) => `${group.id}(${group.options.join(" | ")})`);
}
async function auditProductSource(uiServerPath: string): Promise<ProductSourceAudit> {
let source = "";
try {
source = await readFile(uiServerPath, "utf8");
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return {
fullChinese: false,
nonTechnicalCopy: false,
middleSchoolReadable: false,
globalVisibility: false,
appleNativeUI: false,
details: {
fullChinese: `unable to read ${uiServerPath}: ${detail}`,
nonTechnicalCopy: `unable to read ${uiServerPath}: ${detail}`,
middleSchoolReadable: `unable to read ${uiServerPath}: ${detail}`,
globalVisibility: `unable to read ${uiServerPath}: ${detail}`,
appleNativeUI: `unable to read ${uiServerPath}: ${detail}`,
},
};
}
const copyBlock = source.match(
/function globalVisibilityCopy[\s\S]*?function formatExecutorAgentLabel/,
)?.[0];
const modelBlock = source.match(
/async function buildGlobalVisibilityViewModel[\s\S]*?function dashboardSectionLinks/,
)?.[0];
const globalVisibilityBlock =
[copyBlock, modelBlock]
.filter((segment): segment is string => typeof segment === "string" && segment.length > 0)
.join("\n\n")
.trim() || source;
const missingZh = findMissingGroups(globalVisibilityBlock, REQUIRED_ZH_GROUPS);
const missingEn = findMissingGroups(globalVisibilityBlock, REQUIRED_EN_GROUPS);
const missingRender = findMissingPhrases(source, GLOBAL_VISIBILITY_RENDER_TOKENS);
const missingRenderGroups = findMissingGroups(source, GLOBAL_VISIBILITY_RENDER_GROUPS);
const missingAppleNativeTokens = findMissingPhrases(source, APPLE_NATIVE_UI_RENDER_TOKENS);
const missingAppleNativeGroups = findMissingGroups(source, APPLE_NATIVE_UI_RENDER_GROUPS);
const matchedMixedZh = MIXED_ZH_PHRASES.filter((phrase) => globalVisibilityBlock.includes(phrase));
const matchedJargon = NON_TECH_JARGON.filter((token) =>
globalVisibilityBlock.toLowerCase().includes(token.toLowerCase()),
);
const readabilitySamples = [
...REQUIRED_EN_GROUPS.flatMap((group) => group.options),
"Tasks checked:",
"Tasks selected:",
"Tasks started:",
"Recent tool calls:",
"Tool calls in recent activity:",
];
const longPhrases = readabilitySamples.filter((phrase) => phrase.length > 90);
const hardWords = readabilitySamples
.flatMap((phrase) => phrase.split(/[^A-Za-z]+/))
.filter((word) => word.length >= 14);
return {
fullChinese: missingZh.length === 0 && matchedMixedZh.length === 0,
nonTechnicalCopy: missingEn.length === 0 && matchedJargon.length === 0,
middleSchoolReadable: longPhrases.length === 0 && hardWords.length === 0,
globalVisibility:
missingRender.length === 0 &&
missingRenderGroups.length === 0 &&
missingEn.length === 0 &&
missingZh.length === 0,
appleNativeUI: missingAppleNativeTokens.length === 0 && missingAppleNativeGroups.length === 0,
details: {
fullChinese:
missingZh.length === 0 && matchedMixedZh.length === 0
? "ui source includes required zh copy for global visibility"
: `missing zh copy: ${missingZh.join(", ") || "none"} mixed zh/en copy: ${matchedMixedZh.join(", ") || "none"}`,
nonTechnicalCopy:
missingEn.length === 0 && matchedJargon.length === 0
? "global visibility copy remains plain-language and operator-facing"
: `missing en plain copy=${missingEn.join(", ") || "none"} jargon=${matchedJargon.join(", ") || "none"}`,
middleSchoolReadable:
longPhrases.length === 0 && hardWords.length === 0
? "global visibility copy length/wording stays middle-school readable"
: `long phrases=${longPhrases.join(" | ") || "none"} hard words=${hardWords.join(", ") || "none"}`,
globalVisibility:
missingRender.length === 0 && missingRenderGroups.length === 0
? "strip + card render all four signals (cron/heartbeat/current tasks/tool calls) across sections"
: `missing render tokens: ${[...missingRender, ...missingRenderGroups].join(", ")}`,
appleNativeUI:
missingAppleNativeTokens.length === 0 && missingAppleNativeGroups.length === 0
? "apple-native polish markers present (glass surfaces, window controls, reduced-motion safety)"
: `missing apple-native tokens: ${[...missingAppleNativeTokens, ...missingAppleNativeGroups].join(", ")}`,
},
};
}
function subscriptionSnapshotPaths(runtimeDir: string): string[] {
const openclawHome = process.env.OPENCLAW_HOME?.trim() || join(homedir(), ".openclaw");
const paths = [
process.env.OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH?.trim(),
resolve(runtimeDir, "subscription-snapshot.json"),
join(openclawHome, "subscription.json"),
join(openclawHome, "subscription-snapshot.json"),
join(openclawHome, "billing", "subscription.json"),
join(openclawHome, "billing", "subscription-snapshot.json"),
join(openclawHome, "billing", "usage.json"),
join(openclawHome, "usage", "subscription.json"),
join(openclawHome, "usage", "subscription-snapshot.json"),
].filter((item): item is string => typeof item === "string" && item.trim().length > 0);
return [...new Set(paths)];
}
function asObjectValue(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
function asStringValue(input: unknown): string | undefined {
return typeof input === "string" ? input : undefined;
}
function pickFirstNonNegative(values: unknown[]): number | undefined {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
}
return undefined;
}
function parseSubscriptionSnapshotSignal(raw: unknown): {
consumed?: number;
remaining?: number;
limit?: number;
unit: string;
planLabel: string;
} {
const root = asObjectValue(raw) ?? {};
const subscription = asObjectValue(root.subscription) ?? {};
const usage = asObjectValue(root.usage) ?? asObjectValue(subscription.usage) ?? {};
const rootCost = asObjectValue(root.cost) ?? {};
const subscriptionCost = asObjectValue(subscription.cost) ?? {};
const plan = asObjectValue(root.plan) ?? asObjectValue(subscription.plan) ?? {};
const consumed = pickFirstNonNegative([
root.consumed,
root.used,
root.spent,
subscription.consumed,
subscription.used,
subscription.spent,
usage.consumed,
usage.used,
usage.spent,
rootCost.used,
rootCost.spent,
subscriptionCost.used,
subscriptionCost.spent,
]);
const limit = pickFirstNonNegative([
root.limit,
root.total,
root.quota,
root.cap,
subscription.limit,
subscription.total,
subscription.quota,
subscription.cap,
usage.limit,
usage.total,
usage.quota,
rootCost.limit,
subscriptionCost.limit,
]);
const remaining = pickFirstNonNegative([
root.remaining,
root.left,
subscription.remaining,
subscription.left,
usage.remaining,
usage.left,
typeof limit === "number" && typeof consumed === "number" ? Math.max(0, limit - consumed) : undefined,
]);
const unit =
asStringValue(root.unit) ??
asStringValue(subscription.unit) ??
asStringValue(usage.unit) ??
asStringValue(root.currency) ??
asStringValue(subscription.currency) ??
asStringValue(usage.currency) ??
"USD";
const planLabel =
asStringValue(plan.name) ??
asStringValue(root.planLabel) ??
asStringValue(root.planName) ??
asStringValue(root.plan) ??
asStringValue(root.tier) ??
asStringValue(subscription.planLabel) ??
asStringValue(subscription.planName) ??
asStringValue(subscription.plan) ??
asStringValue(subscription.tier) ??
"Subscription";
return {
consumed,
remaining,
limit,
unit,
planLabel,
};
}
async function auditRealSubscriptionSource(runtimeDir: string): Promise<RealSubscriptionAudit> {
const paths = subscriptionSnapshotPaths(runtimeDir);
const partial: string[] = [];
const unreadable: string[] = [];
const missing: string[] = [];
for (const path of paths) {
try {
const raw = JSON.parse(await readFile(path, "utf8")) as unknown;
const parsed = parseSubscriptionSnapshotSignal(raw);
const hasAllFields =
typeof parsed.consumed === "number" && typeof parsed.remaining === "number" && typeof parsed.limit === "number";
if (hasAllFields) {
return {
connected: true,
sourcePath: path,
detail: `provider snapshot connected at ${path} (${parsed.planLabel}; ${parsed.unit})`,
};
}
const missingFields = [
typeof parsed.consumed === "number" ? "" : "consumed",
typeof parsed.remaining === "number" ? "" : "remaining",
typeof parsed.limit === "number" ? "" : "limit",
].filter((item) => item !== "");
partial.push(`${path} missing ${missingFields.join("/") || "required fields"}`);
} catch (error) {
const code =
error && typeof error === "object" && "code" in error && typeof (error as { code?: unknown }).code === "string"
? (error as { code: string }).code
: undefined;
if (code === "ENOENT") {
missing.push(path);
} else {
unreadable.push(`${path}${code ? ` (${code})` : ""}`);
}
}
}
if (partial.length > 0) {
return {
connected: false,
detail: `provider snapshot found but incomplete: ${partial.join("; ")}`,
};
}
if (unreadable.length > 0) {
return {
connected: false,
detail: `provider snapshot unreadable: ${unreadable.join("; ")}`,
};
}
return {
connected: false,
detail: missing.length > 0 ? `provider snapshot missing at: ${missing.join(", ")}` : "provider snapshot paths not configured",
};
}
function toProductChecks(
product: ProductDoDDefinition | null,
sourceAudit: ProductSourceAudit,
subscriptionAudit: RealSubscriptionAudit,
requireTiApproval: boolean,
): DoDCheckResult[] {
const declared = product ?? null;
const checks: DoDCheckResult[] = [
{
id: "product-dod-definition",
passed: declared !== null,
detail: declared ? "runtime/evidence/product-dod.json present" : "missing runtime/evidence/product-dod.json",
},
];
const declaredFullChinese = declared?.fullChinese === true;
checks.push({
id: "product-full-chinese",
passed: declaredFullChinese && sourceAudit.fullChinese,
detail: `声明=${declaredFullChinese ? "通过" : "未通过"};源码=${sourceAudit.fullChinese ? "通过" : "未通过"}(${sourceAudit.details.fullChinese})`,
});
const declaredNonTechnical = declared?.nonTechnicalCopy === true;
checks.push({
id: "product-non-technical-copy",
passed: declaredNonTechnical && sourceAudit.nonTechnicalCopy,
detail: `声明=${declaredNonTechnical ? "通过" : "未通过"};源码=${sourceAudit.nonTechnicalCopy ? "通过" : "未通过"}(${sourceAudit.details.nonTechnicalCopy})`,
});
const declaredReadable = declared?.middleSchoolReadable === true;
checks.push({
id: "product-middle-school-readable",
passed: declaredReadable && sourceAudit.middleSchoolReadable,
detail: `声明=${declaredReadable ? "通过" : "未通过"};源码=${sourceAudit.middleSchoolReadable ? "通过" : "未通过"}(${sourceAudit.details.middleSchoolReadable})`,
});
const declaredGlobalVisibility = declared?.globalVisibility === true;
checks.push({
id: "product-global-visibility",
passed: declaredGlobalVisibility && sourceAudit.globalVisibility,
detail: `声明=${declaredGlobalVisibility ? "通过" : "未通过"};源码=${sourceAudit.globalVisibility ? "通过" : "未通过"}(${sourceAudit.details.globalVisibility})`,
});
const declaredAppleNativeUI = declared?.appleNativeUI === true;
checks.push({
id: "product-apple-native-ui",
passed: declaredAppleNativeUI && sourceAudit.appleNativeUI,
detail:
`声明=${declaredAppleNativeUI ? "通过" : "未通过"};` +
`客观检查=${sourceAudit.appleNativeUI ? "通过" : "未通过"}(${sourceAudit.details.appleNativeUI})`,
});
const declaredRealSubscriptionConnected = declared?.realSubscriptionConnected === true;
checks.push({
id: "product-real-subscription-connected",
passed: declaredRealSubscriptionConnected && subscriptionAudit.connected,
detail:
`声明=${declaredRealSubscriptionConnected ? "通过" : "未通过"};` +
`客观检查=${subscriptionAudit.connected ? "通过" : "未通过"}(${subscriptionAudit.detail})`,
});
const tiApproved = declared?.tiApproval === true;
checks.push({
id: "product-ti-approval",
passed: requireTiApproval ? tiApproved : true,
detail: requireTiApproval
? tiApproved
? "Ti 人工验收:通过(严格模式)"
: "Ti 人工验收:未通过(严格模式)"
: tiApproved
? "Ti 人工验收:已记录(非阻塞)"
: "Ti 人工验收:待补充(非阻塞)",
});
return checks;
}
async function runChecks(runtimeDir: string): Promise<DoDCheckResult[]> {
const checks: DoDCheckResult[] = [];
const goalPath = resolve(runtimeDir, "goal-state.json");
const evidencePath = resolve(runtimeDir, "evidence", "latest.json");
const progressPath = resolve(runtimeDir, "evidence", "worker-progress.json");
const productDoDPath = resolve(runtimeDir, "evidence", "product-dod.json");
const uiServerPath = resolve(process.cwd(), "src", "ui", "server.ts");
const requireTiApproval = (process.env.DOD_REQUIRE_TI_APPROVAL ?? "false").toLowerCase() === "true";
const goalGate = await runNodeScript(resolve(process.cwd(), "scripts", "goal-gate.ts"), [goalPath]);
checks.push({
id: "goal-gate",
passed: goalGate.code === 0,
detail: goalGate.code === 0 ? "goal gate passed" : (goalGate.stderr.trim() || goalGate.stdout.trim() || "goal gate failed"),
});
const evidenceGate = await runNodeScript(resolve(process.cwd(), "scripts", "evidence-gate.ts"), ["validate", evidencePath]);
checks.push({
id: "evidence-gate",
passed: evidenceGate.code === 0,
detail:
evidenceGate.code === 0
? "evidence bundle valid"
: (evidenceGate.stderr.trim() || evidenceGate.stdout.trim() || "evidence validation failed"),
});
const progress = await readProgress(progressPath);
const stepPassed = progress?.lastStep?.exitCode === 0;
checks.push({
id: "worker-last-step",
passed: stepPassed,
detail: stepPassed
? `cycle=${progress?.cycle ?? "unknown"} step=${progress?.lastStep?.id ?? "unknown"} exit=0`
: "missing or failing worker-progress lastStep.exitCode",
});
const product = await readProductDoD(productDoDPath);
const sourceAudit = await auditProductSource(uiServerPath);
const subscriptionAudit = await auditRealSubscriptionSource(runtimeDir);
checks.push(...toProductChecks(product, sourceAudit, subscriptionAudit, requireTiApproval));
return checks;
}
async function main(): Promise<void> {
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
const statusPath = resolve(process.argv[3] ?? join(runtimeDir, "evidence", "dod-status.json"));
let checks: DoDCheckResult[] = [];
try {
checks = await runChecks(runtimeDir);
} catch (error) {
checks = [
{
id: "dod-check-runtime",
passed: false,
detail: error instanceof Error ? error.message : String(error),
},
];
}
const passed = checks.every((check) => check.passed);
const artifact: DoDStatusArtifact = {
generatedAt: new Date().toISOString(),
runtimeDir,
status: passed ? "DONE" : "NOT_DONE",
passed,
checks,
};
await writeJsonAtomic(statusPath, artifact);
if (!artifact.passed) {
console.error(`NOT_DONE status=${statusPath}`);
process.exit(1);
}
console.log(`DONE status=${statusPath}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+78
View File
@@ -0,0 +1,78 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export interface EvidenceBundle {
generatedAt: string;
itemId: string;
completed: string;
filesChanged: string[];
verification: string;
visibleMarker: string;
}
const cmd = (process.argv[2] ?? "validate").toLowerCase();
const targetPath = resolve(process.argv[3] ?? join(process.cwd(), "runtime", "evidence", "latest.json"));
export function validateBundle(bundle: Partial<EvidenceBundle>): string[] {
const errors: string[] = [];
if (!bundle.itemId || bundle.itemId.trim().length === 0) errors.push("missing itemId");
if (!bundle.completed || bundle.completed.trim().length === 0) errors.push("missing completed");
if (!Array.isArray(bundle.filesChanged) || bundle.filesChanged.length === 0) errors.push("missing filesChanged[]");
if (!bundle.verification || bundle.verification.trim().length === 0) errors.push("missing verification");
if (!bundle.visibleMarker || bundle.visibleMarker.trim().length === 0) errors.push("missing visibleMarker");
return errors;
}
async function emit(path: string): Promise<number> {
const files = (process.env.EVIDENCE_FILES ?? "")
.split(",")
.map((x) => x.trim())
.filter(Boolean);
const bundle: EvidenceBundle = {
generatedAt: new Date().toISOString(),
itemId: process.env.EVIDENCE_ITEM_ID ?? "",
completed: process.env.EVIDENCE_COMPLETED ?? "",
filesChanged: files,
verification: process.env.EVIDENCE_VERIFICATION ?? "",
visibleMarker: process.env.EVIDENCE_VISIBLE_MARKER ?? "",
};
const errors = validateBundle(bundle);
if (errors.length > 0) {
console.error(`EVIDENCE_INVALID ${errors.join("; ")}`);
return 1;
}
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
console.log(`EVIDENCE_EMITTED ${path}`);
return 0;
}
async function validate(path: string): Promise<number> {
try {
const raw = await readFile(path, "utf8");
const bundle = JSON.parse(raw) as Partial<EvidenceBundle>;
const errors = validateBundle(bundle);
if (errors.length > 0) {
console.error(`EVIDENCE_INVALID ${errors.join("; ")}`);
return 1;
}
console.log(`EVIDENCE_VALID item=${bundle.itemId} file=${path}`);
return 0;
} catch (error) {
console.error(`EVIDENCE_READ_ERROR ${error instanceof Error ? error.message : String(error)}`);
return 1;
}
}
async function main(): Promise<void> {
const code = cmd === "emit" ? await emit(targetPath) : await validate(targetPath);
process.exit(code);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+128
View File
@@ -0,0 +1,128 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
interface EvidenceBundle {
generatedAt: string;
itemId: string;
completed: string;
filesChanged: string[];
verification: string;
visibleMarker: string;
}
interface OutboundProgressPayload {
reportedAt: string;
evidencePath: string;
progress: {
itemId: string;
completed: string;
filesChanged: string[];
verification: string;
visibleMarker: string;
};
}
function buildSnapshotId(now = new Date()): string {
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
const dd = String(now.getUTCDate()).padStart(2, "0");
const hh = String(now.getUTCHours()).padStart(2, "0");
const min = String(now.getUTCMinutes()).padStart(2, "0");
const sec = String(now.getUTCSeconds()).padStart(2, "0");
return `${yyyy}${mm}${dd}-${hh}${min}${sec}Z`;
}
function isCompletionLike(text: string): boolean {
return /(\bdone\b|\bcompleted\b|完成|已完成|全部完成)/i.test(text);
}
function isFullPassLike(text: string): boolean {
return /(\bfull\s+pass\b|\ball\s+checks?\s+pass(?:ed)?\b|全部通过|全通过|全量通过|全面通过)/i.test(text);
}
async function runNodeScript(scriptPath: string, args: string[]): Promise<number | null> {
return await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
cwd: process.cwd(),
env: process.env,
stdio: "ignore",
});
child.on("error", rejectPromise);
child.on("close", (code) => resolvePromise(code));
});
}
function validateEvidenceBundle(bundle: Partial<EvidenceBundle>): string[] {
const errors: string[] = [];
if (!bundle.itemId || bundle.itemId.trim() === "") errors.push("missing itemId");
if (!bundle.completed || bundle.completed.trim() === "") errors.push("missing completed");
if (!Array.isArray(bundle.filesChanged) || bundle.filesChanged.length === 0) errors.push("missing filesChanged[]");
if (!bundle.verification || bundle.verification.trim() === "") errors.push("missing verification");
if (!bundle.visibleMarker || bundle.visibleMarker.trim() === "") errors.push("missing visibleMarker");
return errors;
}
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function main(): Promise<void> {
const evidencePath = resolve(process.argv[2] ?? join(process.cwd(), "runtime", "evidence", "latest.json"));
const outboundDir = resolve(process.argv[3] ?? join(process.cwd(), "runtime", "evidence", "outbound"));
const raw = await readFile(evidencePath, "utf8");
const evidence = JSON.parse(raw) as Partial<EvidenceBundle>;
const errors = validateEvidenceBundle(evidence);
if (errors.length > 0) {
console.error(`REPORT_BLOCKED_EVIDENCE ${errors.join("; ")}`);
process.exit(1);
}
const completionText = String(evidence.completed ?? "");
const requireDodGate = (process.env.REQUIRE_DOD_FOR_COMPLETION ?? "true").toLowerCase() !== "false";
const claimsCompletion = isCompletionLike(completionText) || isFullPassLike(completionText);
if ((requireDodGate && claimsCompletion) || isFullPassLike(completionText)) {
const runtimeDir = resolve(process.cwd(), "runtime");
const dodCode = await runNodeScript(resolve(process.cwd(), "scripts", "dod-check.ts"), [runtimeDir]);
if (dodCode !== 0) {
console.error("REPORT_BLOCKED_DOD NOT_DONE");
process.exit(1);
}
}
const now = new Date();
const snapshotId = buildSnapshotId(now);
const snapshotPath = resolve(outboundDir, `${snapshotId}.json`);
const latestPath = resolve(outboundDir, "latest.json");
const pointerPath = resolve(outboundDir, "latest.pointer.json");
const payload: OutboundProgressPayload = {
reportedAt: now.toISOString(),
evidencePath,
progress: {
itemId: evidence.itemId ?? "",
completed: evidence.completed ?? "",
filesChanged: evidence.filesChanged ?? [],
verification: evidence.verification ?? "",
visibleMarker: evidence.visibleMarker ?? "",
},
};
await writeJsonAtomic(snapshotPath, payload);
await writeJsonAtomic(latestPath, payload);
await writeJsonAtomic(pointerPath, {
updatedAt: now.toISOString(),
snapshotId,
latest: snapshotPath,
});
console.log(`REPORT_EMITTED latest=${latestPath} snapshot=${snapshotPath}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+403
View File
@@ -0,0 +1,403 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { deflateSync } from "node:zlib";
type Sprite = string[][];
type SpriteRuntime = {
spriteSize: number;
spriteFactory: Record<string, () => Sprite>;
spriteVisualSpanCompensation: Record<string, number>;
};
type StaffAvatarExport = {
agentId: string;
animal: string;
label: string;
labelZh: string;
};
type SpriteBounds = {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
span: number;
};
export const STAFF_AVATARS: StaffAvatarExport[] = [
{ agentId: "codex", animal: "robot", label: "Codex", labelZh: "机器人" },
{ agentId: "coq", animal: "rooster", label: "Coq", labelZh: "公鸡" },
{ agentId: "dolphin", animal: "dolphin", label: "Dolphin", labelZh: "海豚" },
{ agentId: "main", animal: "lion", label: "Main", labelZh: "狮子" },
{ agentId: "monkey", animal: "monkey", label: "Monkey", labelZh: "猴子" },
{ agentId: "otter", animal: "otter", label: "Otter", labelZh: "水獭" },
{ agentId: "pandas", animal: "panda", label: "Pandas", labelZh: "熊猫" },
{ agentId: "tiger", animal: "tiger", label: "Tiger", labelZh: "老虎" },
];
export function extractSpriteRuntime(source: string): SpriteRuntime {
const start = source.indexOf("const spriteSize = 44;");
const end = source.indexOf("const motionActors = [];", start);
if (start < 0 || end < 0 || end <= start) {
throw new Error("Unable to locate embedded staff avatar sprite runtime in src/ui/server.ts");
}
const block = source.slice(start, end);
const runtime = vm.runInNewContext(
`
(() => {
${block}
return { spriteSize, spriteFactory, spriteVisualSpanCompensation };
})()
`,
{ Math },
) as SpriteRuntime;
if (!runtime || typeof runtime.spriteSize !== "number" || !runtime.spriteFactory || !runtime.spriteVisualSpanCompensation) {
throw new Error("Failed to evaluate embedded avatar sprite runtime");
}
return runtime;
}
export async function loadSpriteRuntime(serverPath: string): Promise<SpriteRuntime> {
const source = await fs.readFile(serverPath, "utf8");
return extractSpriteRuntime(source);
}
export function getSpriteBounds(sprite: Sprite): SpriteBounds {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let y = 0; y < sprite.length; y += 1) {
for (let x = 0; x < sprite[y].length; x += 1) {
if (!sprite[y][x]) continue;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 1, height: 1, span: 1 };
}
const width = maxX - minX + 1;
const height = maxY - minY + 1;
return { minX, minY, maxX, maxY, width, height, span: Math.max(width, height) };
}
export function centerSprite(sprite: Sprite, padding = 2): Sprite {
const bounds = getSpriteBounds(sprite);
const size = bounds.span + padding * 2;
const centered = Array.from({ length: size }, () => Array(size).fill(""));
const offsetX = Math.floor((size - bounds.width) / 2);
const offsetY = Math.floor((size - bounds.height) / 2);
for (let y = bounds.minY; y <= bounds.maxY; y += 1) {
for (let x = bounds.minX; x <= bounds.maxX; x += 1) {
const color = sprite[y]?.[x];
if (!color) continue;
centered[offsetY + (y - bounds.minY)][offsetX + (x - bounds.minX)] = color;
}
}
return centered;
}
export function renderSpriteSvg(sprite: Sprite, pixelScale = 8): string {
const centered = centerSprite(sprite);
const size = centered.length;
const width = size * pixelScale;
const rects: string[] = [];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const color = centered[y]?.[x];
if (!color) continue;
rects.push(
`<rect x="${x * pixelScale}" y="${y * pixelScale}" width="${pixelScale}" height="${pixelScale}" fill="${color}" />`,
);
}
}
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${width}" viewBox="0 0 ${width} ${width}" shape-rendering="crispEdges">`,
`<rect width="${width}" height="${width}" fill="transparent" />`,
...rects,
`</svg>`,
].join("\n");
}
export function renderSpriteRgba(sprite: Sprite, pixelScale = 8): { width: number; height: number; data: Buffer } {
const centered = centerSprite(sprite);
const size = centered.length;
const width = size * pixelScale;
const height = size * pixelScale;
const data = Buffer.alloc(width * height * 4);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const color = centered[y]?.[x];
for (let py = 0; py < pixelScale; py += 1) {
for (let px = 0; px < pixelScale; px += 1) {
const idx = ((y * pixelScale + py) * width + (x * pixelScale + px)) * 4;
if (!color) {
data[idx + 0] = 0;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = 0;
continue;
}
const rgb = hexToRgb(color);
data[idx + 0] = rgb.r;
data[idx + 1] = rgb.g;
data[idx + 2] = rgb.b;
data[idx + 3] = 255;
}
}
}
}
return { width, height, data };
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const safe = hex.trim().replace(/^#/, "");
if (safe.length !== 6) {
throw new Error(`Unsupported color "${hex}"`);
}
return {
r: Number.parseInt(safe.slice(0, 2), 16),
g: Number.parseInt(safe.slice(2, 4), 16),
b: Number.parseInt(safe.slice(4, 6), 16),
};
}
const crcTable = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c >>> 0;
}
return table;
})();
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buffer.length; i += 1) {
crc = crcTable[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer): Buffer {
const typeBuffer = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const crcBuffer = Buffer.concat([typeBuffer, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(crcBuffer), 0);
return Buffer.concat([length, typeBuffer, data, crc]);
}
export function renderSpritePng(sprite: Sprite, pixelScale = 8): Buffer {
const rgba = renderSpriteRgba(sprite, pixelScale);
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(rgba.width, 0);
ihdr.writeUInt32BE(rgba.height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const stride = rgba.width * 4;
const raw = Buffer.alloc((stride + 1) * rgba.height);
for (let y = 0; y < rgba.height; y += 1) {
const rowStart = y * (stride + 1);
raw[rowStart] = 0;
rgba.data.copy(raw, rowStart + 1, y * stride, (y + 1) * stride);
}
const idat = deflateSync(raw, { level: 9 });
return Buffer.concat([
signature,
pngChunk("IHDR", ihdr),
pngChunk("IDAT", idat),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
export function renderPreviewHtml(items: StaffAvatarExport[]): string {
const cards = items
.map(
(item) => `<article class="card">
<img src="./${item.agentId}.png" alt="${item.label}" width="176" height="176" />
<div class="meta">
<h2>${item.label}</h2>
<p>${item.labelZh}</p>
<code>${item.agentId}</code>
</div>
</article>`,
)
.join("\n");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Staff Avatar Exports</title>
<style>
:root {
color-scheme: light;
--bg: #f5f5f7;
--panel: #ffffff;
--border: rgba(17, 24, 39, 0.08);
--text: #1d1d1f;
--muted: #6e6e73;
--shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
font-family: "SF Pro Display", "SF Pro Text", "Helvetica Neue", sans-serif;
}
body {
margin: 0;
background: linear-gradient(180deg, #fbfbfd 0%, var(--bg) 100%);
color: var(--text);
padding: 40px;
}
h1 {
margin: 0 0 12px;
font-size: 34px;
line-height: 1.05;
}
p.lead {
margin: 0 0 28px;
color: var(--muted);
font-size: 17px;
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
}
.card {
background: rgba(255,255,255,0.88);
border: 1px solid var(--border);
border-radius: 24px;
padding: 18px;
box-shadow: var(--shadow);
backdrop-filter: blur(20px);
}
img {
display: block;
width: 176px;
height: 176px;
margin: 0 auto 12px;
image-rendering: pixelated;
}
h2 {
margin: 0;
font-size: 22px;
}
.meta p,
.meta code {
margin: 6px 0 0;
color: var(--muted);
font-size: 14px;
display: block;
}
</style>
</head>
<body>
<h1>Staff Avatar Exports</h1>
<p class="lead">Exported from the current pixel avatar definitions embedded in <code>src/ui/server.ts</code>.</p>
<section class="grid">
${cards}
</section>
</body>
</html>`;
}
export async function exportStaffAvatars(outputDir: string, serverPath: string): Promise<string[]> {
const runtime = await loadSpriteRuntime(serverPath);
await fs.mkdir(outputDir, { recursive: true });
const written: string[] = [];
for (const item of STAFF_AVATARS) {
const renderer = runtime.spriteFactory[item.animal];
if (!renderer) {
throw new Error(`Missing renderer for animal "${item.animal}"`);
}
const sprite = renderer();
const svg = renderSpriteSvg(sprite);
const svgPath = path.join(outputDir, `${item.agentId}.svg`);
await fs.writeFile(svgPath, svg, "utf8");
written.push(svgPath);
const png = renderSpritePng(sprite);
const pngPath = path.join(outputDir, `${item.agentId}.png`);
await fs.writeFile(pngPath, png);
written.push(pngPath);
}
const manifestPath = path.join(outputDir, "manifest.json");
await fs.writeFile(
manifestPath,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
source: "src/ui/server.ts",
count: STAFF_AVATARS.length,
avatars: STAFF_AVATARS.map((item) => ({
agentId: item.agentId,
animal: item.animal,
label: item.label,
labelZh: item.labelZh,
svg: `${item.agentId}.svg`,
png: `${item.agentId}.png`,
})),
},
null,
2,
),
"utf8",
);
written.push(manifestPath);
const previewPath = path.join(outputDir, "index.html");
await fs.writeFile(previewPath, renderPreviewHtml(STAFF_AVATARS), "utf8");
written.push(previewPath);
return written;
}
async function main(): Promise<void> {
const repoRoot = process.cwd();
const serverPath = path.join(repoRoot, "src/ui/server.ts");
const outputDir = path.join(repoRoot, "runtime/exports/staff-avatars");
const written = await exportStaffAvatars(outputDir, serverPath);
process.stdout.write(`Exported ${written.length} staff avatar files to ${outputDir}\n`);
}
if (require.main === module) {
main().catch((error: unknown) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}
+38
View File
@@ -0,0 +1,38 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
interface GoalState {
goalId: string;
definitionOfDone: string[];
phases: Array<{ id: string; status: string }>;
}
const path = resolve(process.argv[2] ?? "runtime/goal-state.json");
async function main() {
const raw = await readFile(path, "utf8");
const state = JSON.parse(raw) as GoalState;
const inProgress = state.phases.filter((p) => p.status === "in_progress");
const blocked = state.phases.filter((p) => p.status === "blocked");
if (blocked.length > 0) {
console.error(`GATE_BLOCKED phases=${blocked.map((p) => p.id).join(",")}`);
process.exit(1);
}
if (inProgress.length > 1) {
console.error(`GATE_INVALID multiple_in_progress=${inProgress.map((p) => p.id).join(",")}`);
process.exit(1);
}
const doneCount = state.phases.filter((p) => p.status === "done").length;
const total = state.phases.length;
const current = inProgress[0]?.id ?? "none";
console.log(`GATE_OK goal=${state.goalId} done=${doneCount}/${total} current=${current}`);
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
+56
View File
@@ -0,0 +1,56 @@
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
export interface HealthSnapshot {
generatedAt: string;
files: {
lock: { path: string; ageMs: number | null };
evidence: { path: string; ageMs: number | null };
recovery: { path: string; ageMs: number | null };
};
}
async function ageMs(path: string): Promise<number | null> {
try {
const s = await stat(path);
return Math.max(0, Date.now() - s.mtimeMs);
} catch {
return null;
}
}
export async function buildHealthSnapshot(runtimeDir = resolve(process.cwd(), "runtime")): Promise<HealthSnapshot> {
return {
generatedAt: new Date().toISOString(),
files: {
lock: {
path: join(runtimeDir, "run.lock"),
ageMs: await ageMs(join(runtimeDir, "run.lock")),
},
evidence: {
path: join(runtimeDir, "evidence", "latest.json"),
ageMs: await ageMs(join(runtimeDir, "evidence", "latest.json")),
},
recovery: {
path: join(runtimeDir, "recovery", "latest.json"),
ageMs: await ageMs(join(runtimeDir, "recovery", "latest.json")),
},
},
};
}
async function main() {
const runtime = resolve(process.cwd(), "runtime");
const out = resolve(runtime, "health", "latest.json");
const snapshot = await buildHealthSnapshot(runtime);
await mkdir(resolve(runtime, "health"), { recursive: true });
await writeFile(out, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
console.log(`HEALTH_SNAPSHOT_WRITTEN ${out}`);
}
if ((process.argv[1] ?? "").endsWith("health-snapshot.ts")) {
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
import datetime
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / "runtime"
STATE_PATH = BASE / "state.json"
DOD_PATH = BASE / "evidence" / "dod-status.json"
OUTPUT_PATH = BASE / "evidence" / "dod-eval.json"
now = datetime.datetime.now(datetime.timezone.utc)
res = {"ts": now.isoformat(), "status": "fail", "reason": "init", "checks": {}}
def load(p):
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
try:
state = load(STATE_PATH)
dod = load(DOD_PATH)
res["checks"]["state_exists"] = True
res["checks"]["dod_exists"] = True
missing = []
for k in ["updatedAt", "status"]:
if k not in state:
missing.append(k)
res["checks"]["state_fields_ok"] = len(missing) == 0
res["checks"]["state_missing_fields"] = missing
fresh = False
try:
t = datetime.datetime.fromisoformat(str(state.get("updatedAt")).replace("Z", "+00:00"))
fresh = (now - t).total_seconds() <= 1200
except Exception:
fresh = False
res["checks"]["fresh_20m"] = fresh
text = json.dumps(dod, ensure_ascii=False)
done_all = "A1" in text and "E8" in text and (
"all_pass" in text or "allPassed" in text or "全达标" in text
)
res["checks"]["dod_all_pass_hint"] = done_all
st = str(state.get("status", "")).upper()
if st in ["ALERT", "FAIL", "STALE"]:
res["status"] = "alert"
res["reason"] = f"state_status={st}"
elif not fresh:
res["status"] = "alert"
res["reason"] = "state_stale"
else:
res["status"] = "ok"
res["reason"] = "healthy"
except FileNotFoundError as e:
res["status"] = "fail"
res["reason"] = f"missing_file:{e.filename}"
except Exception as e:
res["status"] = "fail"
res["reason"] = f"exception:{e}"
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
print(json.dumps(res, ensure_ascii=False))
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import datetime
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / "runtime" / "evidence"
REGRESSION_PATH = BASE / "regression-status.json"
SNAPSHOT_PATH = BASE / "mission-control-jobs-v2-snapshot.json"
OUTPUT_PATH = BASE / "rollback-plan.json"
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
plan = {"ts": now, "canRollback": False, "reason": "", "jobs": [], "commands": []}
try:
with open(REGRESSION_PATH, "r", encoding="utf-8") as regression_file:
reg = json.load(regression_file)
with open(SNAPSHOT_PATH, "r", encoding="utf-8") as snapshot_file:
snap = json.load(snapshot_file)
status = str(reg.get("status", "")).lower()
if status in ["fail", "alert"]:
jobs = snap.get("jobs", [])
plan["canRollback"] = True
plan["reason"] = f"regression_{status}"
for j in jobs:
jid = j.get("id")
plan["jobs"].append(jid)
plan["commands"].append(f"openclaw cron update {jid} --json '<snapshot-payload>'")
else:
plan["reason"] = "regression_not_triggered"
except Exception as e:
plan["reason"] = f"error:{e}"
BASE.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as output_file:
json.dump(plan, output_file, ensure_ascii=False, indent=2)
print(json.dumps(plan, ensure_ascii=False))
+76
View File
@@ -0,0 +1,76 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { buildHealthSnapshot } from "./health-snapshot";
interface SnapshotPointer {
updatedAt: string;
snapshotId: string;
latest: string;
}
function buildSnapshotId(now = new Date()): string {
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
const dd = String(now.getUTCDate()).padStart(2, "0");
const hh = String(now.getUTCHours()).padStart(2, "0");
const min = String(now.getUTCMinutes()).padStart(2, "0");
const sec = String(now.getUTCSeconds()).padStart(2, "0");
return `${yyyy}${mm}${dd}-${hh}${min}${sec}Z`;
}
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function readJson<T>(path: string): Promise<T> {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as T;
}
async function main(): Promise<void> {
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
const healthDir = resolve(runtimeDir, "health");
const evidenceDir = resolve(runtimeDir, "evidence");
const evidenceLatestPath = resolve(evidenceDir, "latest.json");
const now = new Date();
const snapshotId = buildSnapshotId(now);
const healthSnapshotPath = resolve(healthDir, "snapshots", `${snapshotId}.json`);
const evidenceSnapshotPath = resolve(evidenceDir, "snapshots", `${snapshotId}.json`);
const healthLatestPath = resolve(healthDir, "latest.json");
const healthPointerPath = resolve(healthDir, "latest.pointer.json");
const evidencePointerPath = resolve(evidenceDir, "latest.pointer.json");
const healthSnapshot = await buildHealthSnapshot(runtimeDir);
const evidenceSnapshot = await readJson<Record<string, unknown>>(evidenceLatestPath);
const healthPointer: SnapshotPointer = {
updatedAt: now.toISOString(),
snapshotId,
latest: healthSnapshotPath,
};
const evidencePointer: SnapshotPointer & { sourceLatest: string } = {
updatedAt: now.toISOString(),
snapshotId,
latest: evidenceSnapshotPath,
sourceLatest: evidenceLatestPath,
};
await writeJsonAtomic(healthSnapshotPath, healthSnapshot);
await writeJsonAtomic(evidenceSnapshotPath, evidenceSnapshot);
await writeJsonAtomic(healthLatestPath, healthSnapshot);
await writeJsonAtomic(healthPointerPath, healthPointer);
await writeJsonAtomic(evidencePointerPath, evidencePointer);
console.log(
`PERIODIC_SNAPSHOT_OK id=${snapshotId} health=${healthSnapshotPath} evidence=${evidenceSnapshotPath}`,
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
FILES=()
while IFS= read -r file; do
FILES+=("$file")
done < <(
rg --files --hidden \
-g '!.git/**' \
-g '!node_modules/**' \
-g '!dist/**' \
-g '!runtime/**' \
-g '!coverage/**' \
-g '!scripts/release-audit.sh'
)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "release-audit: no source files found" >&2
exit 1
fi
check_no_match() {
local description="$1"
local pattern="$2"
if rg -n --no-heading -P -e "$pattern" "${FILES[@]}" >/tmp/release-audit-match.txt 2>/dev/null; then
echo "release-audit: failed: ${description}" >&2
cat /tmp/release-audit-match.txt >&2
rm -f /tmp/release-audit-match.txt
exit 1
fi
rm -f /tmp/release-audit-match.txt
}
check_exists() {
local file="$1"
if [ ! -f "$file" ]; then
echo "release-audit: missing required file: $file" >&2
exit 1
fi
}
check_exists "README.md"
check_exists "LICENSE"
check_exists ".gitignore"
check_exists ".env.example"
check_exists "package.json"
check_no_match "absolute macOS home paths" '/Users/[^/]+/'
check_no_match "absolute Linux home paths" '/home/[^/]+/'
check_no_match "hard-coded internal channel ids" '1477617216529760378'
check_no_match "obvious OpenAI-style secret keys" 'sk-[A-Za-z0-9]{20,}'
check_no_match "hard-coded bearer tokens" 'Authorization: Bearer (?!<)[^[:space:]]+'
check_no_match "hard-coded local API tokens" 'LOCAL_API_TOKEN=(?!<)[^[:space:]]+'
check_no_match "hard-coded x-local-token header values" 'x-local-token:[[:space:]]*(?!<)[^[:space:]]+'
if [ -d ".git" ]; then
if git ls-files | rg '^(runtime|dist|node_modules|coverage|plans|workflows)/' >/tmp/release-audit-match.txt 2>/dev/null; then
echo "release-audit: failed: ignored build/runtime/internal-only paths are tracked" >&2
cat /tmp/release-audit-match.txt >&2
rm -f /tmp/release-audit-match.txt
exit 1
fi
rm -f /tmp/release-audit-match.txt
fi
echo "release-audit: passed"
+306
View File
@@ -0,0 +1,306 @@
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
interface RunLock {
owner: string;
leaseUntil: string;
}
interface WorkerHeartbeat {
generatedAt: string;
pid?: number;
state?: string;
}
interface SupervisorReport {
generatedAt: string;
status: "healthy" | "restarted" | "failed" | "done";
reason: string;
retries: number;
maxRetries: number;
observed: {
heartbeatAgeMs?: number;
heartbeatState?: string;
workerPid?: number;
workerDead?: boolean;
};
actions: string[];
}
function isExpired(lock: RunLock): boolean {
const expiry = Date.parse(lock.leaseUntil);
if (Number.isNaN(expiry)) return true;
return expiry <= Date.now();
}
async function readLock(path: string): Promise<RunLock | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as RunLock;
} catch {
return null;
}
}
async function readHeartbeat(path: string): Promise<WorkerHeartbeat | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as WorkerHeartbeat;
} catch {
return null;
}
}
async function ageMs(path: string): Promise<number | undefined> {
try {
const fileStat = await stat(path);
return Math.max(0, Date.now() - fileStat.mtimeMs);
} catch {
return undefined;
}
}
function isPidAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "EPERM") return true;
return false;
}
}
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function appendAudit(path: string, line: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
let existing = "";
try {
existing = await readFile(path, "utf8");
} catch {
existing = "";
}
await writeFile(path, `${existing}${line}\n`, "utf8");
}
async function runNodeScript(
scriptPath: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
cwd: process.cwd(),
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", rejectPromise);
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
});
}
async function runLockCommand(action: "acquire" | "renew" | "release", lockPath: string, owner: string): Promise<void> {
const result = await runNodeScript(resolve(process.cwd(), "scripts", "run-lock.ts"), [action, lockPath], {
...process.env,
RUN_OWNER: owner,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || `run-lock ${action} failed`);
}
}
async function runDoDCheck(runtimeDir: string, statusPath: string): Promise<boolean> {
const result = await runNodeScript(resolve(process.cwd(), "scripts", "dod-check.ts"), [runtimeDir, statusPath]);
return result.code === 0;
}
async function startWorker(
workerEntry: string,
runtimeDir: string,
mode: "detached" | "sync",
): Promise<{ ok: boolean; detail: string }> {
if (mode === "sync") {
const run = await runNodeScript(resolve(workerEntry), [runtimeDir], process.env);
if (run.code !== 0) {
const combined = `${run.stdout}\n${run.stderr}`;
if (combined.includes("WORKER_MAX_CYCLES_REACHED")) {
return { ok: true, detail: "worker restarted (sync bounded cycles)" };
}
return { ok: false, detail: run.stderr.trim() || run.stdout.trim() || "worker restart failed" };
}
return { ok: true, detail: "worker restarted (sync)" };
}
return await new Promise((resolvePromise) => {
const child = spawn(process.execPath, ["--import", "tsx", resolve(workerEntry), runtimeDir], {
cwd: process.cwd(),
env: process.env,
detached: true,
stdio: "ignore",
});
child.on("error", (error) => {
resolvePromise({ ok: false, detail: error instanceof Error ? error.message : String(error) });
});
child.unref();
resolvePromise({ ok: true, detail: "worker restarted (detached)" });
});
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms);
});
}
async function main(): Promise<void> {
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
const owner = process.env.RUN_OWNER ?? "pandas-main";
const once = (process.env.SUPERVISOR_ONCE ?? "false").toLowerCase() === "true";
const pollMs = Number(process.env.SUPERVISOR_POLL_MS ?? 15_000);
const maxRetries = Number(process.env.SUPERVISOR_MAX_RESTARTS ?? 3);
const heartbeatMaxAgeMs = Number(process.env.SUPERVISOR_HEARTBEAT_MAX_AGE_MS ?? 120_000);
const restartMode = (process.env.SUPERVISOR_RESTART_MODE ?? "detached") === "sync" ? "sync" : "detached";
const workerEntry = resolve(process.env.SUPERVISOR_WORKER_ENTRY ?? join(process.cwd(), "scripts", "resident-worker.ts"));
const lockPath = resolve(runtimeDir, "run.lock");
const heartbeatPath = resolve(runtimeDir, "health", "worker-heartbeat.json");
const dodStatusPath = resolve(runtimeDir, "evidence", "dod-status.json");
const supervisorLockPath = resolve(runtimeDir, "health", "supervisor-lock.json");
const existingSupervisor = await readHeartbeat(supervisorLockPath);
const existingPid = existingSupervisor?.pid;
if (existingPid && existingPid !== process.pid && isPidAlive(existingPid)) {
console.log(`SUPERVISOR_ALREADY_RUNNING pid=${existingPid}`);
process.exit(0);
}
await writeJsonAtomic(supervisorLockPath, {
generatedAt: new Date().toISOString(),
pid: process.pid,
state: "running",
});
const reportPath = resolve(runtimeDir, "recovery", "supervisor-latest.json");
const auditPath = resolve(runtimeDir, "recovery", "supervisor-audit.log");
let retries = 0;
while (true) {
const actions: string[] = [];
const heartbeat = await readHeartbeat(heartbeatPath);
const heartbeatAgeMs = await ageMs(heartbeatPath);
const workerPid = heartbeat?.pid;
const workerDead = workerPid !== undefined ? !isPidAlive(workerPid) : false;
const stale = heartbeatAgeMs === undefined || heartbeatAgeMs > heartbeatMaxAgeMs;
const dead = workerDead;
let status: SupervisorReport["status"] = "healthy";
let reason = stale ? "worker-heartbeat-stale" : "worker-healthy";
if (heartbeat?.state === "done") {
const dodPassed = await runDoDCheck(runtimeDir, dodStatusPath);
actions.push(`dod-check-${dodPassed ? "pass" : "fail"}`);
if (dodPassed) {
status = "done";
reason = "dod-pass";
} else {
reason = "worker-done-but-dod-failed";
}
}
if (status !== "done" && (stale || dead)) {
if (retries >= maxRetries) {
status = "failed";
reason = "max-retries-exhausted";
actions.push("restart-skipped-max-retries");
} else {
const lock = await readLock(lockPath);
if (lock && !isExpired(lock) && lock.owner !== owner) {
status = "failed";
reason = `lock-conflict owner=${lock.owner}`;
actions.push("restart-blocked-lock-conflict");
} else {
if (lock && !isExpired(lock) && lock.owner === owner) {
await runLockCommand("release", lockPath, owner).catch(() => {
// best-effort release before restart
});
actions.push("released-existing-owner-lock");
}
await runLockCommand("acquire", lockPath, owner).catch(() => {
// worker can also acquire after restart; keep attempting restart.
});
actions.push("acquired-supervisor-lock");
const started = await startWorker(workerEntry, runtimeDir, restartMode);
if (!started.ok) {
retries += 1;
status = retries >= maxRetries ? "failed" : "healthy";
reason = `restart-failed: ${started.detail}`;
actions.push("restart-worker-failed");
} else {
retries += 1;
status = "restarted";
reason = stale && dead ? "worker-stale-and-dead" : stale ? "worker-stale" : "worker-dead";
actions.push("restart-worker");
}
}
}
}
const report: SupervisorReport = {
generatedAt: new Date().toISOString(),
status,
reason,
retries,
maxRetries,
observed: {
heartbeatAgeMs,
heartbeatState: heartbeat?.state,
workerPid,
workerDead,
},
actions,
};
await writeJsonAtomic(reportPath, report);
await appendAudit(
auditPath,
`${report.generatedAt} | ${status} | ${reason} | retries=${retries}/${maxRetries} | actions=${actions.join(",") || "none"}`,
);
if (status === "failed") {
console.error(`SUPERVISOR_FAILED report=${reportPath}`);
process.exit(1);
}
if (status === "done") {
console.log(`SUPERVISOR_DONE report=${reportPath}`);
process.exit(0);
}
if (once) {
console.log(`SUPERVISOR_ONCE status=${status} report=${reportPath}`);
process.exit(0);
}
await sleep(pollMs);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+322
View File
@@ -0,0 +1,322 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
interface RunLock {
runId: string;
owner: string;
leaseUntil: string;
}
interface WorkerProgressArtifact {
generatedAt: string;
runId: string;
owner: string;
cycle: number;
state: "running" | "waiting" | "done";
lastStep: {
id: string;
command: string;
startedAt: string;
finishedAt: string;
exitCode: number | null;
stdoutTail: string;
stderrTail: string;
};
dod: {
passed: boolean;
statusPath: string;
};
}
interface WorkerHeartbeatArtifact {
generatedAt: string;
runId: string;
owner: string;
pid: number;
cycle: number;
state: "running" | "waiting" | "done";
stepId: string;
stepExitCode: number | null;
dodPassed: boolean;
}
function getNpmCommand(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function parseSteps(raw: string): string[] {
const steps = raw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return steps.length > 0 ? steps : ["goal-gate"];
}
function getStepCommand(
stepId: string,
runtimeDir: string,
): { command: string; args: string[]; label: string } {
const npm = getNpmCommand();
if (stepId === "build") return { command: npm, args: ["run", "build"], label: "npm run build" };
if (stepId === "test") return { command: npm, args: ["test"], label: "npm test" };
if (stepId === "evidence-report") {
return { command: npm, args: ["run", "evidence:report"], label: "npm run evidence:report" };
}
if (stepId === "goal-gate") {
return {
command: process.execPath,
args: ["--import", "tsx", resolve(process.cwd(), "scripts", "goal-gate.ts"), resolve(runtimeDir, "goal-state.json")],
label: "goal-gate",
};
}
if (stepId === "evidence-validate") {
return {
command: process.execPath,
args: [
"--import",
"tsx",
resolve(process.cwd(), "scripts", "evidence-gate.ts"),
"validate",
resolve(runtimeDir, "evidence", "latest.json"),
],
label: "evidence-validate",
};
}
if (stepId === "health-snapshot") {
return {
command: process.execPath,
args: ["--import", "tsx", resolve(process.cwd(), "scripts", "health-snapshot.ts")],
label: "health-snapshot",
};
}
if (stepId === "codex-patch") {
const prompt = process.env.WORKER_CODE_PROMPT ??
"Improve Mission Control product DoD gaps: full Chinese coverage, non-technical copy, middle-school readability, and global visibility for cron/heartbeat/current tasks/tool calls. Make minimal safe code changes.";
return {
command: "codex",
args: ["exec", "--full-auto", prompt],
label: "codex exec --full-auto",
};
}
throw new Error(`unknown worker step: ${stepId}`);
}
function tail(value: string, maxChars = 2000): string {
if (value.length <= maxChars) return value;
return value.slice(value.length - maxChars);
}
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function runCommand(
command: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", rejectPromise);
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
});
}
async function readLock(path: string): Promise<RunLock | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as RunLock;
} catch {
return null;
}
}
function isExpired(lock: RunLock): boolean {
const expiry = Date.parse(lock.leaseUntil);
if (Number.isNaN(expiry)) return true;
return expiry <= Date.now();
}
async function runLockCommand(action: "acquire" | "renew" | "release", lockPath: string, owner: string): Promise<void> {
const result = await runCommand(
process.execPath,
["--import", "tsx", resolve(process.cwd(), "scripts", "run-lock.ts"), action, lockPath],
{ ...process.env, RUN_OWNER: owner },
);
if (result.code !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || `run-lock ${action} failed`);
}
}
async function ensureOwnedLock(lockPath: string, owner: string): Promise<void> {
const existing = await readLock(lockPath);
if (existing && !isExpired(existing) && existing.owner !== owner) {
throw new Error(`WORKER_LOCK_CONFLICT expected=${owner} current=${existing.owner} leaseUntil=${existing.leaseUntil}`);
}
if (!existing || isExpired(existing)) {
await runLockCommand("acquire", lockPath, owner);
return;
}
await runLockCommand("renew", lockPath, owner);
}
async function runDoDCheck(runtimeDir: string, statusPath: string): Promise<boolean> {
const result = await runCommand(process.execPath, [
"--import",
"tsx",
resolve(process.cwd(), "scripts", "dod-check.ts"),
runtimeDir,
statusPath,
]);
return result.code === 0;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms);
});
}
async function main(): Promise<void> {
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
const owner = process.env.RUN_OWNER ?? "pandas-main";
const steps = parseSteps(process.env.WORKER_STEPS ?? "codex-patch,test,evidence-report,health-snapshot");
const intervalMs = Number(process.env.WORKER_INTERVAL_MS ?? 15_000);
const maxCycles = Number(process.env.WORKER_MAX_CYCLES ?? 0);
const lockPath = resolve(runtimeDir, "run.lock");
const heartbeatPath = resolve(runtimeDir, "health", "worker-heartbeat.json");
const progressPath = resolve(runtimeDir, "evidence", "worker-progress.json");
const dodStatusPath = resolve(runtimeDir, "evidence", "dod-status.json");
const runId = randomUUID();
let cycle = 0;
try {
await ensureOwnedLock(lockPath, owner);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
while (true) {
cycle += 1;
const stepId = steps[(cycle - 1) % steps.length];
const step = getStepCommand(stepId, runtimeDir);
const stepStartedAt = new Date().toISOString();
const runningHeartbeat: WorkerHeartbeatArtifact = {
generatedAt: new Date().toISOString(),
runId,
owner,
pid: process.pid,
cycle,
state: "running",
stepId,
stepExitCode: null,
dodPassed: false,
};
await writeJsonAtomic(heartbeatPath, runningHeartbeat);
let stepResult: { code: number | null; stdout: string; stderr: string };
try {
stepResult = await runCommand(step.command, step.args);
} catch (error) {
stepResult = { code: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
}
const stepFinishedAt = new Date().toISOString();
try {
await ensureOwnedLock(lockPath, owner);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const interimProgress: WorkerProgressArtifact = {
generatedAt: new Date().toISOString(),
runId,
owner,
cycle,
state: "waiting",
lastStep: {
id: stepId,
command: step.label,
startedAt: stepStartedAt,
finishedAt: stepFinishedAt,
exitCode: stepResult.code,
stdoutTail: tail(stepResult.stdout),
stderrTail: tail(stepResult.stderr),
},
dod: {
passed: false,
statusPath: dodStatusPath,
},
};
await writeJsonAtomic(progressPath, interimProgress);
const dodPassed = await runDoDCheck(runtimeDir, dodStatusPath);
const state: WorkerProgressArtifact["state"] = dodPassed ? "done" : "waiting";
const progress: WorkerProgressArtifact = {
...interimProgress,
generatedAt: new Date().toISOString(),
state,
dod: {
passed: dodPassed,
statusPath: dodStatusPath,
},
};
const heartbeat: WorkerHeartbeatArtifact = {
generatedAt: new Date().toISOString(),
runId,
owner,
pid: process.pid,
cycle,
state,
stepId,
stepExitCode: stepResult.code,
dodPassed,
};
await writeJsonAtomic(progressPath, progress);
await writeJsonAtomic(heartbeatPath, heartbeat);
if (dodPassed) {
await runLockCommand("release", lockPath, owner).catch(() => {
// Keep done outcome even if release fails; lock lease expiry is still a safe fallback.
});
console.log(`WORKER_DONE cycles=${cycle} dodStatus=${dodStatusPath}`);
process.exit(0);
}
if (maxCycles > 0 && cycle >= maxCycles) {
console.error(`WORKER_MAX_CYCLES_REACHED cycles=${cycle}`);
process.exit(1);
}
await sleep(intervalMs);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+143
View File
@@ -0,0 +1,143 @@
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
interface RunLock {
runId: string;
owner: string;
startedAt: string;
updatedAt: string;
leaseUntil: string;
fencingToken: number;
pid?: number;
}
const runtimeDir = resolve(process.cwd(), "runtime");
const lockPath = resolve(process.argv[3] ?? join(runtimeDir, "run.lock"));
const cmd = (process.argv[2] ?? "status").toLowerCase();
const owner = process.env.RUN_OWNER || "pandas-main";
const leaseMs = Number(process.env.RUN_LEASE_MS ?? 10 * 60 * 1000);
async function readLock(path: string): Promise<RunLock | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as RunLock;
} catch {
return null;
}
}
function isExpired(lock: RunLock, now = Date.now()): boolean {
const expiry = Date.parse(lock.leaseUntil);
if (Number.isNaN(expiry)) return true;
return expiry <= now;
}
async function writeLock(path: string, lock: RunLock): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function acquire(path: string): Promise<number> {
const now = Date.now();
const current = await readLock(path);
if (current && !isExpired(current, now)) {
console.error(`LOCKED runId=${current.runId} owner=${current.owner} leaseUntil=${current.leaseUntil}`);
return 1;
}
const nextToken = (current?.fencingToken ?? 0) + 1;
const runId = randomUUID();
const lock: RunLock = {
runId,
owner,
startedAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
leaseUntil: new Date(now + leaseMs).toISOString(),
fencingToken: nextToken,
pid: process.pid,
};
await writeLock(path, lock);
console.log(`ACQUIRED runId=${lock.runId} fencingToken=${lock.fencingToken} leaseUntil=${lock.leaseUntil}`);
return 0;
}
async function renew(path: string): Promise<number> {
const now = Date.now();
const lock = await readLock(path);
if (!lock) {
console.error("MISSING_LOCK");
return 1;
}
if (isExpired(lock, now)) {
console.error(`EXPIRED runId=${lock.runId}`);
return 1;
}
if (lock.owner !== owner) {
console.error(`NOT_OWNER expected=${owner} current=${lock.owner}`);
return 1;
}
const next: RunLock = {
...lock,
updatedAt: new Date(now).toISOString(),
leaseUntil: new Date(now + leaseMs).toISOString(),
pid: process.pid,
};
await writeLock(path, next);
console.log(`RENEWED runId=${next.runId} leaseUntil=${next.leaseUntil}`);
return 0;
}
async function release(path: string): Promise<number> {
const lock = await readLock(path);
if (!lock) {
console.log("RELEASED noop=no-lock");
return 0;
}
if (lock.owner !== owner && !isExpired(lock)) {
console.error(`NOT_OWNER expected=${owner} current=${lock.owner}`);
return 1;
}
await rm(path, { force: true });
console.log(`RELEASED runId=${lock.runId}`);
return 0;
}
async function status(path: string): Promise<number> {
const lock = await readLock(path);
if (!lock) {
console.log("STATUS unlocked");
return 0;
}
const expired = isExpired(lock);
console.log(`STATUS ${expired ? "expired" : "locked"} runId=${lock.runId} owner=${lock.owner} leaseUntil=${lock.leaseUntil} fencingToken=${lock.fencingToken}`);
return expired ? 2 : 0;
}
async function main(): Promise<void> {
// quick sanity: parent runtime dir should be writable once
await mkdir(dirname(lockPath), { recursive: true });
try {
await stat(dirname(lockPath));
} catch {
// ignore
}
let code = 0;
if (cmd === "acquire") code = await acquire(lockPath);
else if (cmd === "renew") code = await renew(lockPath);
else if (cmd === "release") code = await release(lockPath);
else code = await status(lockPath);
process.exit(code);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="$ROOT/runtime/evidence/verifier-status.json"
TMP="$OUT.tmp"
cd "$ROOT"
./scripts/mc_dod_evaluator.py > "$TMP"
python3 - "$TMP" "$OUT" <<'PY2'
import datetime
import json
import pathlib
import sys
tmp_path = pathlib.Path(sys.argv[1])
out_path = pathlib.Path(sys.argv[2])
raw = tmp_path.read_text(encoding="utf-8").strip()
data = json.loads(raw) if raw else {}
if not isinstance(data, dict):
raise SystemExit("Verifier must output a JSON object")
data.setdefault("ok", bool(data.get("status") == "DONE"))
data.setdefault("status", data.get("status", "UNKNOWN"))
data.setdefault("checkedItems", [])
data.setdefault("failedChecks", [])
data.setdefault("evidence", [])
data.setdefault("nextRecommendedItem", "")
data["updatedAt"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
out_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(data, ensure_ascii=False))
tmp_path.unlink(missing_ok=True)
PY2
+109
View File
@@ -0,0 +1,109 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
type HealStatus = "healthy" | "stalled" | "healed" | "failed";
interface HealReport {
generatedAt: string;
status: HealStatus;
reason: string;
thresholds: {
evidenceMaxAgeMs: number;
lockMaxAgeMs: number;
};
observed: {
evidenceAgeMs?: number;
lockAgeMs?: number;
};
actions: string[];
}
const runtimeDir = resolve(process.cwd(), "runtime");
const evidencePath = resolve(process.argv[2] ?? join(runtimeDir, "evidence", "latest.json"));
const lockPath = resolve(process.argv[3] ?? join(runtimeDir, "run.lock"));
const reportPath = resolve(process.argv[4] ?? join(runtimeDir, "recovery", "latest.json"));
const auditPath = resolve(process.argv[5] ?? join(dirname(reportPath), "audit.log"));
const evidenceMaxAgeMs = Number(process.env.STALL_EVIDENCE_MAX_AGE_MS ?? 30 * 60 * 1000);
const lockMaxAgeMs = Number(process.env.STALL_LOCK_MAX_AGE_MS ?? 30 * 60 * 1000);
const owner = process.env.RUN_OWNER ?? "pandas-main";
async function mtimeAgeMs(path: string): Promise<number | undefined> {
try {
const s = await stat(path);
return Date.now() - s.mtimeMs;
} catch {
return undefined;
}
}
function runReleaseLock(): Promise<boolean> {
return new Promise((resolvePromise) => {
const child = spawn("npm", ["run", "lock:release"], {
cwd: process.cwd(),
env: { ...process.env, RUN_OWNER: owner },
stdio: "ignore",
});
child.on("exit", (code) => resolvePromise(code === 0));
});
}
async function appendRecoveryAudit(line: string): Promise<void> {
await mkdir(dirname(auditPath), { recursive: true });
let old = "";
try {
old = await readFile(auditPath, "utf8");
} catch {
old = "";
}
await writeFile(auditPath, `${old}${line}\n`, "utf8");
}
async function main(): Promise<void> {
const evidenceAgeMs = await mtimeAgeMs(evidencePath);
const lockAgeMs = await mtimeAgeMs(lockPath);
const stalledByEvidence = evidenceAgeMs === undefined || evidenceAgeMs > evidenceMaxAgeMs;
const stalledByLock = lockAgeMs !== undefined && lockAgeMs > lockMaxAgeMs;
const actions: string[] = [];
let status: HealStatus = "healthy";
let reason = "within-threshold";
if (stalledByEvidence || stalledByLock) {
status = "stalled";
reason = `${stalledByEvidence ? "evidence-stale" : ""}${stalledByEvidence && stalledByLock ? "+" : ""}${stalledByLock ? "lock-stale" : ""}`;
const released = await runReleaseLock();
actions.push(released ? "release-lock-ok" : "release-lock-failed");
if (released) {
status = "healed";
actions.push("restart-chain-required");
} else {
status = "failed";
}
}
const report: HealReport = {
generatedAt: new Date().toISOString(),
status,
reason,
thresholds: { evidenceMaxAgeMs, lockMaxAgeMs },
observed: { evidenceAgeMs, lockAgeMs },
actions,
};
await mkdir(dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
await appendRecoveryAudit(`${report.generatedAt} | ${report.status} | ${report.reason} | actions=${actions.join(",") || "none"}`);
console.log(`AUTO_HEAL_${report.status.toUpperCase()} reason=${report.reason} actions=${actions.join(",") || "none"}`);
process.exit(report.status === "failed" ? 1 : 0);
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
PORT="${UI_SMOKE_PORT:-4516}"
WAIT_SECONDS="${UI_SMOKE_WAIT_SECONDS:-10}"
LOG_DIR="$ROOT/runtime"
LOG_FILE="$LOG_DIR/ui-smoke-${PORT}.log"
mkdir -p "$LOG_DIR"
cd "$ROOT"
UI_MODE=true UI_PORT="$PORT" npm run dev >"$LOG_FILE" 2>&1 &
PID=$!
cleanup() {
kill "$PID" >/dev/null 2>&1 || true
wait "$PID" 2>/dev/null || true
}
trap cleanup EXIT
wait_for_ui() {
local deadline=$((SECONDS + WAIT_SECONDS))
while true; do
if curl -fsS "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
return 0
fi
if (( SECONDS >= deadline )); then
echo "UI smoke failed: server did not become ready within ${WAIT_SECONDS}s." >&2
cat "$LOG_FILE" >&2 || true
return 1
fi
sleep 1
done
}
wait_for_ui
curl -fsS "http://127.0.0.1:${PORT}/" | grep -E "Mission Control|Office|Usage|Language:" >/dev/null
curl -fsS "http://127.0.0.1:${PORT}/docs?lang=en" | grep -E "Open document workbench|Mission Control|Docs" >/dev/null
printf 'UI smoke passed on http://127.0.0.1:%s\n' "$PORT"
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { computeBudgetSummary } from "../src/runtime/budget-governance";
import type {
ProjectStoreSnapshot,
SessionStatusSnapshot,
SessionSummary,
TaskStoreSnapshot,
} from "../src/types";
function main(): void {
const sessions: SessionSummary[] = [
{ sessionKey: "s1", agentId: "main", state: "running" },
{ sessionKey: "s2", agentId: "main", state: "running" },
{ sessionKey: "s3", agentId: "worker", state: "idle" },
];
const statuses: SessionStatusSnapshot[] = [
{
sessionKey: "s1",
tokensIn: 110,
tokensOut: 30,
cost: 5,
updatedAt: "2026-03-01T00:00:00.000Z",
},
{
sessionKey: "s2",
tokensIn: 15,
tokensOut: 10,
cost: 1,
updatedAt: "2026-03-01T00:00:00.000Z",
},
{
sessionKey: "s3",
tokensIn: 20,
tokensOut: 5,
cost: 0.5,
updatedAt: "2026-03-01T00:00:00.000Z",
},
];
const tasks: TaskStoreSnapshot = {
tasks: [
{
projectId: "proj-1",
taskId: "task-over",
title: "Task Over",
status: "in_progress",
owner: "main",
definitionOfDone: [],
artifacts: [],
rollback: { strategy: "manual", steps: [] },
sessionKeys: ["s1"],
budget: { tokensIn: 100, warnRatio: 0.8 },
updatedAt: "2026-03-01T00:00:00.000Z",
},
{
projectId: "proj-1",
taskId: "task-ok",
title: "Task Ok",
status: "todo",
owner: "main",
definitionOfDone: [],
artifacts: [],
rollback: { strategy: "manual", steps: [] },
sessionKeys: ["s2"],
budget: { totalTokens: 100, warnRatio: 0.8 },
updatedAt: "2026-03-01T00:00:00.000Z",
},
],
agentBudgets: [
{
agentId: "main",
thresholds: { totalTokens: 200, warnRatio: 0.8 },
},
],
updatedAt: "2026-03-01T00:00:00.000Z",
};
const projects: ProjectStoreSnapshot = {
projects: [
{
projectId: "proj-1",
title: "Project 1",
status: "active",
owner: "main",
budget: { totalTokens: 120, warnRatio: 0.8 },
updatedAt: "2026-03-01T00:00:00.000Z",
},
],
updatedAt: "2026-03-01T00:00:00.000Z",
};
const summary = computeBudgetSummary(sessions, statuses, tasks, projects);
assert.equal(summary.total, 4);
assert.equal(summary.ok, 1);
assert.equal(summary.warn, 1);
assert.equal(summary.over, 2);
const byId = new Map(summary.evaluations.map((item) => [`${item.scope}:${item.scopeId}`, item.status]));
assert.equal(byId.get("task:task-over"), "over");
assert.equal(byId.get("task:task-ok"), "ok");
assert.equal(byId.get("project:proj-1"), "over");
assert.equal(byId.get("agent:main"), "warn");
console.log("validate-budget-compute: ok");
}
main();
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
async function main(): Promise<void> {
const previousCwd = process.cwd();
const tempRoot = await mkdtemp(join(tmpdir(), "cc-task-store-"));
try {
process.chdir(tempRoot);
await mkdir(join(tempRoot, "runtime"), { recursive: true });
await writeFile(
join(tempRoot, "runtime", "tasks.json"),
JSON.stringify(
{
tasks: [],
agentBudgets: [],
updatedAt: "2026-03-01T00:00:00.000Z",
},
null,
2,
),
"utf8",
);
await writeFile(
join(tempRoot, "runtime", "projects.json"),
JSON.stringify(
{
projects: [
{
projectId: "p1",
title: "Project One",
status: "active",
owner: "ops",
budget: {},
updatedAt: "2026-03-01T00:00:00.000Z",
},
],
updatedAt: "2026-03-01T00:00:00.000Z",
},
null,
2,
),
"utf8",
);
const taskStore = await import("../src/runtime/task-store");
const created = await taskStore.createTask({
projectId: "p1",
taskId: "task-1",
title: "Ship endpoint",
owner: "main",
dueAt: "2026-03-03T10:00:00.000Z",
sessionKeys: ["agent:main:main"],
status: "todo",
});
assert.equal(created.projectId, "p1");
assert.equal(created.task.taskId, "task-1");
assert.equal(created.task.status, "todo");
assert.equal(created.task.dueAt, "2026-03-03T10:00:00.000Z");
const updated = await taskStore.updateTaskStatus({
taskId: "task-1",
projectId: "p1",
status: "blocked",
});
assert.equal(updated.task.status, "blocked");
const store = await taskStore.loadTaskStore();
const listed = taskStore.listTasks(store);
assert.equal(listed.length, 1);
assert.equal(listed[0].taskId, "task-1");
assert.equal(listed[0].status, "blocked");
await assert.rejects(
taskStore.createTask({
projectId: "missing",
taskId: "task-2",
title: "Invalid",
}),
(error: unknown) =>
error instanceof taskStore.TaskStoreValidationError &&
error.statusCode === 404,
);
await assert.rejects(
taskStore.updateTaskStatus({
taskId: "task-1",
projectId: "p1",
status: "bad-status",
}),
(error: unknown) =>
error instanceof taskStore.TaskStoreValidationError &&
error.statusCode === 400,
);
console.log("validate-task-store: ok");
} finally {
process.chdir(previousCwd);
await rm(tempRoot, { recursive: true, force: true });
}
}
void main();
+219
View File
@@ -0,0 +1,219 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
interface RunLock {
runId: string;
owner: string;
startedAt: string;
updatedAt: string;
leaseUntil: string;
fencingToken: number;
pid?: number;
}
interface RecoveryReport {
generatedAt: string;
status: "healthy" | "stalled" | "healed" | "failed";
reason: string;
thresholds: {
evidenceMaxAgeMs: number;
lockMaxAgeMs: number;
};
observed: {
evidenceAgeMs?: number;
lockAgeMs?: number;
};
actions: string[];
}
interface DoDStatus {
status?: "DONE" | "NOT_DONE";
passed?: boolean;
}
interface WorkerHeartbeat {
pid?: number;
}
function isExpired(lock: RunLock, nowMs = Date.now()): boolean {
const expiry = Date.parse(lock.leaseUntil);
if (Number.isNaN(expiry)) return true;
return expiry <= nowMs;
}
function isPidAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "EPERM") return true;
return false;
}
}
async function readJson<T>(path: string): Promise<T | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function readLock(path: string): Promise<RunLock | null> {
return await readJson<RunLock>(path);
}
async function runNodeScript(
scriptPath: string,
args: string[],
env: NodeJS.ProcessEnv,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
cwd: process.cwd(),
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", rejectPromise);
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
});
}
async function readRecovery(path: string): Promise<RecoveryReport | null> {
return await readJson<RecoveryReport>(path);
}
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function inspectWorker(runtimeDir: string): Promise<{ active: boolean; detail: string }> {
const heartbeatPath = resolve(runtimeDir, "health", "worker-heartbeat.json");
const hb = await readJson<WorkerHeartbeat>(heartbeatPath);
const active = hb?.pid !== undefined && isPidAlive(hb.pid);
if (active) {
return { active: true, detail: `worker-alive pid=${hb?.pid}` };
}
return { active: false, detail: "worker-not-alive" };
}
async function main(): Promise<void> {
const runtimeDir = resolve(process.cwd(), "runtime");
const owner = process.env.RUN_OWNER ?? "pandas-main";
const lockPath = resolve(process.argv[2] ?? join(runtimeDir, "run.lock"));
const evidencePath = resolve(process.argv[3] ?? join(runtimeDir, "evidence", "latest.json"));
const recoveryPath = resolve(process.argv[4] ?? join(runtimeDir, "recovery", "latest.json"));
const reportPath = resolve(process.argv[5] ?? join(dirname(recoveryPath), "watchdog-latest.json"));
const dodStatusPath = resolve(runtimeDir, "evidence", "dod-status.json");
const lockBefore = await readLock(lockPath);
const lockExpired = lockBefore ? isExpired(lockBefore) : true;
const actions: string[] = [];
let lockAction = "none";
if (lockBefore && !lockExpired && lockBefore.owner !== owner) {
console.error(
`WATCHDOG_LOCK_CONFLICT expected=${owner} current=${lockBefore.owner} leaseUntil=${lockBefore.leaseUntil}`,
);
process.exit(1);
}
const runLockScript = resolve(process.cwd(), "scripts", "run-lock.ts");
if (!lockBefore || lockExpired) {
lockAction = "acquire";
const acquire = await runNodeScript(runLockScript, ["acquire", lockPath], {
...process.env,
RUN_OWNER: owner,
});
if (acquire.code !== 0) {
console.error(`WATCHDOG_LOCK_ACQUIRE_FAILED ${acquire.stderr.trim() || acquire.stdout.trim()}`);
process.exit(1);
}
actions.push("lock-acquire");
} else {
lockAction = "renew";
const renew = await runNodeScript(runLockScript, ["renew", lockPath], {
...process.env,
RUN_OWNER: owner,
});
if (renew.code !== 0) {
console.error(`WATCHDOG_LOCK_RENEW_FAILED ${renew.stderr.trim() || renew.stdout.trim()}`);
process.exit(1);
}
actions.push("lock-renew");
}
const healScript = resolve(process.cwd(), "scripts", "stall-auto-heal.ts");
const heal = await runNodeScript(healScript, [evidencePath, lockPath, recoveryPath], {
...process.env,
RUN_OWNER: owner,
});
if (heal.code !== 0) {
console.error(`WATCHDOG_HEAL_FAILED ${heal.stderr.trim() || heal.stdout.trim()}`);
process.exit(1);
}
actions.push("heal-check");
const dod = await readJson<DoDStatus>(dodStatusPath);
const isNotDone = dod?.status === "NOT_DONE" || dod?.passed === false || dod == null;
if (isNotDone) {
const worker = await inspectWorker(runtimeDir);
actions.push(worker.detail);
// Single starter rule: watchdog never starts worker directly.
// Supervisor is the only component allowed to (re)start resident worker.
if (!worker.active) {
actions.push("restart-delegated-to-supervisor");
}
} else {
actions.push("dod-done");
}
const lockAfter = await readLock(lockPath);
const recovery = await readRecovery(recoveryPath);
const outcome =
recovery?.status === "healed" ? "healed" : recovery?.status === "failed" ? "failed" : "healthy";
const report = {
generatedAt: new Date().toISOString(),
owner,
lockPath,
lockAction,
lock: lockAfter,
evidencePath,
recoveryPath,
recovery,
dodStatus: dod?.status ?? "UNKNOWN",
outcome,
actions,
};
await writeJsonAtomic(reportPath, report);
if (outcome === "failed") {
console.error(`WATCHDOG_FAILED report=${reportPath}`);
process.exit(1);
}
console.log(`WATCHDOG_OK outcome=${outcome} action=${lockAction} report=${reportPath}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+88
View File
@@ -0,0 +1,88 @@
import {
mapApprovalsGetToSummaries,
mapCronListToSummaries,
mapSessionsListToSummaries,
} from "../mappers/openclaw-mappers";
import { parseSessionStatusText } from "../mappers/session-status-parser";
import type {
ApprovalSummary,
CronJobSummary,
ReadModelSnapshot,
SessionStatusSnapshot,
SessionSummary,
} from "../types";
import type { ToolClient } from "../clients/tool-client";
import { computeBudgetSummary } from "../runtime/budget-governance";
import { loadBudgetPolicy } from "../runtime/budget-policy";
import { loadProjectStore } from "../runtime/project-store";
import { computeProjectSummaries } from "../runtime/project-summary";
import { loadTaskStore } from "../runtime/task-store";
import { computeTasksSummary } from "../runtime/task-summary";
/**
* Official-first adapter (read path only).
*
* In readonly mode it uses a no-op client.
* In live mode it uses a client that is currently guarded and not enabled yet.
*/
export class OpenClawReadonlyAdapter {
constructor(private readonly client: ToolClient) {}
async listSessions(): Promise<SessionSummary[]> {
const raw = await this.client.sessionsList();
return mapSessionsListToSummaries(raw);
}
async listSessionStatuses(sessionKeys: string[]): Promise<SessionStatusSnapshot[]> {
const statuses = await Promise.all(
sessionKeys.map(async (sessionKey) => {
const raw = await this.client.sessionStatus(sessionKey);
return parseSessionStatusText(sessionKey, raw.rawText);
}),
);
return statuses;
}
async listCronJobs(): Promise<CronJobSummary[]> {
const raw = await this.client.cronList();
return mapCronListToSummaries(raw);
}
async listApprovals(): Promise<ApprovalSummary[]> {
const raw = await this.client.approvalsGet();
return mapApprovalsGetToSummaries(raw);
}
async snapshot(): Promise<ReadModelSnapshot> {
const sessions = await this.listSessions();
const statuses = await this.listSessionStatuses(sessions.map((s) => s.sessionKey));
const cronJobs = await this.listCronJobs();
const approvals = await this.listApprovals();
const [projects, tasks] = await Promise.all([loadProjectStore(), loadTaskStore()]);
const budgetPolicy = await loadBudgetPolicy();
const tasksSummary = computeTasksSummary(tasks, projects.projects.length);
const projectSummaries = computeProjectSummaries(projects, tasks);
const budgetSummary = computeBudgetSummary(sessions, statuses, tasks, projects, budgetPolicy.policy);
if (budgetPolicy.issues.length > 0) {
console.warn("[mission-control] budget policy issues", {
path: budgetPolicy.path,
issues: budgetPolicy.issues,
});
}
return {
sessions,
statuses,
cronJobs,
approvals,
projects,
projectSummaries,
tasks,
tasksSummary,
budgetSummary,
generatedAt: new Date().toISOString(),
};
}
}
+6
View File
@@ -0,0 +1,6 @@
import { OpenClawLiveClient } from "./openclaw-live-client";
import type { ToolClient } from "./tool-client";
export function createToolClient(): ToolClient {
return new OpenClawLiveClient();
}
+620
View File
@@ -0,0 +1,620 @@
import { execFile } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import type {
ApprovalsActionResponse,
ApprovalsApproveRequest,
ApprovalsGetResponse,
ApprovalsRejectRequest,
CronListResponse,
SessionStatusResponse,
SessionsHistoryRequest,
SessionsHistoryResponse,
SessionsListResponse,
} from "../contracts/openclaw-tools";
import { APPROVAL_ACTIONS_ENABLED } from "../config";
import { loadCurrentAgentCatalog, resolveOpenClawHomePath } from "../runtime/current-agent-catalog";
import type { ToolClient } from "./tool-client";
const execFileAsync = promisify(execFile);
interface SessionCacheItem {
model?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
sessionFile?: string;
}
const ACTIVE_SESSION_STATES = new Set([
"running",
"active",
"busy",
"blocked",
"waiting_approval",
"working",
"in_progress",
"processing",
"thinking",
"executing",
"streaming",
]);
const INACTIVE_SESSION_STATES = new Set([
"idle",
"inactive",
"error",
"failed",
"stopped",
"stopping",
"closed",
"done",
"completed",
"complete",
"paused",
"aborted",
"terminated",
"cancelled",
"canceled",
]);
const FALLBACK_ACTIVE_RECENCY_WINDOW_MS = 45 * 60 * 1000;
/**
* Live read client using official OpenClaw CLI JSON outputs.
* Read-only by design: only list/status commands are used.
*/
export class OpenClawLiveClient implements ToolClient {
private sessionCache = new Map<string, SessionCacheItem>();
private sessionFileCache = new Map<string, string>();
async sessionsList(): Promise<SessionsListResponse> {
const openclawHome = resolveOpenClawHomePath();
const configuredAgentKeys = await this.loadConfiguredAgentKeys();
let data: { sessions?: Array<Record<string, unknown>> };
try {
data = await runJson<{ sessions?: Array<Record<string, unknown>> }>([
"sessions",
"--json",
]);
} catch {
return this.loadSessionsFromStores();
}
const cliSessions: NonNullable<SessionsListResponse["sessions"]> = (data.sessions ?? []).map((item) => ({
key: asString(item.key),
sessionKey: asString(item.key),
sessionId: asString(item.sessionId),
agentId: asString(item.agentId),
updatedAtMs: asNumber(item.updatedAt),
sessionFile:
asString(item.sessionFile) ??
buildSessionFilePath(openclawHome, asString(item.agentId), asString(item.sessionId)),
model: asString(item.model),
inputTokens: asNumber(item.inputTokens),
outputTokens: asNumber(item.outputTokens),
totalTokens: asNumber(item.totalTokens),
state: readSessionState(item),
active: asBoolean(item.active) ?? false,
})).filter((item) => matchesConfiguredAgents(item.agentId ?? extractAgentIdFromSessionKey(item.sessionKey), configuredAgentKeys));
let sessions: NonNullable<SessionsListResponse["sessions"]> = cliSessions;
try {
const storeSessions = (await this.loadSessionsFromStores()).sessions ?? [];
sessions = mergeSessionLists(cliSessions, storeSessions);
} catch {
sessions = cliSessions;
}
this.sessionCache.clear();
for (const s of sessions) {
if (!s.sessionKey) continue;
this.sessionCache.set(s.sessionKey, {
model: s.model,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
totalTokens: s.totalTokens,
sessionFile: s.sessionFile,
});
}
return { sessions };
}
async sessionStatus(sessionKey: string): Promise<SessionStatusResponse> {
const cached = this.sessionCache.get(sessionKey);
const rawText = cached
? `Model: ${cached.model ?? "unknown"}\nTokens: ${cached.inputTokens ?? 0} in / ${cached.outputTokens ?? 0} out\nTotal: ${cached.totalTokens ?? 0}`
: "";
return { rawText };
}
async sessionsHistory(request: SessionsHistoryRequest): Promise<SessionsHistoryResponse> {
const sessionKey = request.sessionKey.trim();
if (!sessionKey) {
return { rawText: "" };
}
const limit = normalizeLimit(request.limit);
let sessionFile = this.sessionCache.get(sessionKey)?.sessionFile;
if (!sessionFile) {
sessionFile = await this.lookupSessionFile(sessionKey);
}
if (sessionFile) {
const fromFile = await readSessionHistoryFile(sessionFile, limit);
if (fromFile) return fromFile;
}
const attempts: string[][] = [
["sessions", "history", sessionKey, "--json", "--limit", String(limit)],
["sessions", "history", sessionKey, "--limit", String(limit), "--json"],
["sessions", "history", sessionKey, "--json"],
];
for (const args of attempts) {
try {
const json = await runJson<Record<string, unknown>>(args);
return {
json,
rawText: JSON.stringify(json),
};
} catch {
continue;
}
}
try {
const rawText = await runHistoryText(sessionKey, limit);
return normalizeRawHistoryText(rawText, limit);
} catch {
return { rawText: "" };
}
}
async cronList(): Promise<CronListResponse> {
let data: { jobs?: Array<Record<string, unknown>> };
try {
data = await runJson<{ jobs?: Array<Record<string, unknown>> }>(
["cron", "list", "--json"],
{ timeoutMs: 2_500 },
);
} catch {
return { jobs: [] };
}
const jobs = (data.jobs ?? []).map((job) => ({
id: asString(job.id),
name: asString(job.name),
enabled: asBoolean(job.enabled),
state: asObject(job.state)
? {
nextRunAtMs: asNumber(asObject(job.state)?.nextRunAtMs),
}
: undefined,
}));
return { jobs };
}
async approvalsGet(): Promise<ApprovalsGetResponse> {
try {
const json = await runJson<Record<string, unknown>>(
["approvals", "get", "--json"],
{ timeoutMs: 2_500 },
);
return {
json,
rawText: JSON.stringify(json),
};
} catch {
try {
const rawText = await runText(["approvals", "get"], { timeoutMs: 1_500 });
return { rawText };
} catch {
return { rawText: "" };
}
}
}
async approvalsApprove(request: ApprovalsApproveRequest): Promise<ApprovalsActionResponse> {
assertApprovalActionsEnabled("approve");
const args = ["approvals", "approve", request.approvalId];
if (request.reason) args.push("--reason", request.reason);
const rawText = await runText(args);
return {
ok: true,
action: "approve",
approvalId: request.approvalId,
reason: request.reason,
rawText,
};
}
async approvalsReject(request: ApprovalsRejectRequest): Promise<ApprovalsActionResponse> {
assertApprovalActionsEnabled("reject");
const args = ["approvals", "reject", request.approvalId, "--reason", request.reason];
const rawText = await runText(args);
return {
ok: true,
action: "reject",
approvalId: request.approvalId,
reason: request.reason,
rawText,
};
}
private async loadSessionsFromStores(): Promise<SessionsListResponse> {
const openclawHome = resolveOpenClawHomePath();
const agentsPath = join(openclawHome, "agents");
const configuredAgentKeys = await this.loadConfiguredAgentKeys();
let agentDirs: string[] = [];
try {
const entries = await readdir(agentsPath, { withFileTypes: true });
agentDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
} catch {
return { sessions: [] };
}
if (configuredAgentKeys.size > 0) {
agentDirs = agentDirs.filter((agentId) => matchesConfiguredAgents(agentId, configuredAgentKeys));
}
const sessions: SessionsListResponse["sessions"] = [];
for (const agentId of agentDirs) {
const sessionsPath = join(agentsPath, agentId, "sessions", "sessions.json");
try {
const parsed = JSON.parse(await readFile(sessionsPath, "utf8")) as unknown;
const records = extractSessionRecords(parsed);
for (const record of records) {
const sessionKey = asString(record.key) ?? asString(record.sessionKey);
if (!sessionKey) continue;
const updatedAtMs = readUpdatedAtMs(record);
sessions.push({
key: sessionKey,
sessionKey,
sessionId: asString(record.sessionId),
agentId: asString(record.agentId) ?? agentId,
updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : undefined,
sessionFile:
asString(record.sessionFile) ??
buildSessionFilePath(openclawHome, asString(record.agentId) ?? agentId, asString(record.sessionId)),
model: asString(record.model),
inputTokens: asNumber(record.inputTokens),
outputTokens: asNumber(record.outputTokens),
totalTokens: asNumber(record.totalTokens),
state: readSessionState(record),
active: isSessionActive(record, updatedAtMs),
});
}
} catch {
continue;
}
}
sessions.sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0));
this.sessionCache.clear();
for (const session of sessions) {
if (!session.sessionKey) continue;
this.sessionCache.set(session.sessionKey, {
model: session.model,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalTokens: session.totalTokens,
sessionFile: session.sessionFile,
});
if (session.sessionFile) {
this.sessionFileCache.set(session.sessionKey, session.sessionFile);
}
}
return { sessions };
}
private async lookupSessionFile(sessionKey: string): Promise<string | undefined> {
const cached = this.sessionFileCache.get(sessionKey);
if (cached) return cached;
const openclawHome = resolveOpenClawHomePath();
const agentsPath = join(openclawHome, "agents");
const configuredAgentKeys = await this.loadConfiguredAgentKeys();
if (!matchesConfiguredAgents(extractAgentIdFromSessionKey(sessionKey), configuredAgentKeys)) {
return undefined;
}
let agentDirs: string[] = [];
try {
const entries = await readdir(agentsPath, { withFileTypes: true });
agentDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
} catch {
return undefined;
}
if (configuredAgentKeys.size > 0) {
agentDirs = agentDirs.filter((agentId) => matchesConfiguredAgents(agentId, configuredAgentKeys));
}
for (const agentId of agentDirs) {
const sessionsPath = join(agentsPath, agentId, "sessions", "sessions.json");
try {
const parsed = JSON.parse(await readFile(sessionsPath, "utf8")) as unknown;
for (const record of extractSessionRecords(parsed)) {
const key = asString(record.key) ?? asString(record.sessionKey);
const sessionFile =
asString(record.sessionFile) ??
buildSessionFilePath(openclawHome, asString(record.agentId) ?? agentId, asString(record.sessionId));
if (!key || !sessionFile) continue;
this.sessionFileCache.set(key, sessionFile);
}
} catch {
continue;
}
}
return this.sessionFileCache.get(sessionKey);
}
private async loadConfiguredAgentKeys(): Promise<Set<string>> {
const catalog = await loadCurrentAgentCatalog();
return new Set(catalog.entries.map((entry) => normalizeAgentKey(entry.agentId)));
}
}
async function runJson<T>(args: string[], options?: { timeoutMs?: number; maxBuffer?: number }): Promise<T> {
const stdout = await runText(args, options);
return JSON.parse(stdout) as T;
}
async function runText(
args: string[],
options?: { timeoutMs?: number; maxBuffer?: number },
): Promise<string> {
const { stdout } = await execFileAsync("openclaw", args, {
timeout: options?.timeoutMs ?? 20_000,
maxBuffer: options?.maxBuffer ?? 2 * 1024 * 1024,
});
return stdout;
}
async function runHistoryText(sessionKey: string, limit: number): Promise<string> {
try {
return await runText(["sessions", "history", sessionKey, "--limit", String(limit)]);
} catch (error) {
if (!isUnknownLimitOptionError(error)) throw error;
}
const rawText = await runText(["sessions", "history", sessionKey]);
const trimmed = rawText.trim();
if (trimmed === "") return rawText;
const lines = trimmed.split(/\r?\n/);
return lines.slice(-limit).join("\n");
}
function normalizeRawHistoryText(rawText: string, limit: number): SessionsHistoryResponse {
const trimmed = rawText.trim();
if (trimmed === "") return { rawText };
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "");
const jsonLike = lines.every((line) => line.startsWith("{") || line.startsWith("["));
if (jsonLike) {
return normalizeSessionHistoryChunk(lines.join("\n"), limit);
}
return { rawText };
}
function isUnknownLimitOptionError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return /unknown option '--limit'/.test(error.message);
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
function asNumber(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
function asBoolean(v: unknown): boolean | undefined {
return typeof v === "boolean" ? v : undefined;
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" ? (v as Record<string, unknown>) : undefined;
}
function normalizeLimit(input: number | undefined): number {
if (typeof input !== "number" || !Number.isFinite(input)) return 12;
return Math.max(1, Math.min(200, Math.trunc(input)));
}
async function readSessionHistoryFile(
sessionFile: string,
limit: number,
): Promise<SessionsHistoryResponse | undefined> {
try {
const { stdout } = await execFileAsync("tail", ["-n", String(Math.max(limit * 8, 80)), sessionFile], {
timeout: 5_000,
maxBuffer: 512 * 1024,
});
return normalizeSessionHistoryChunk(stdout, limit);
} catch {
try {
const raw = await readFile(sessionFile, "utf8");
return normalizeSessionHistoryChunk(raw, limit);
} catch {
return undefined;
}
}
}
function normalizeSessionHistoryChunk(raw: string, limit: number): SessionsHistoryResponse {
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "");
if (lines.length === 0) return { rawText: "" };
const recentLines = lines.slice(-limit);
const history = recentLines.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return line;
}
});
return {
json: { history },
rawText: recentLines.join("\n"),
};
}
function extractSessionRecords(parsed: unknown): Record<string, unknown>[] {
if (Array.isArray(parsed)) return parsed.flatMap((item) => (asObject(item) ? [item] : []));
const root = asObject(parsed);
if (!root) return [];
const nestedCollections = [root.sessions, root.items, root.records];
for (const collection of nestedCollections) {
if (!Array.isArray(collection)) continue;
const records = collection.flatMap((item) => (asObject(item) ? [item] : []));
if (records.length > 0) return records;
}
return Object.entries(root).flatMap(([key, value]) => {
const record = asObject(value);
if (!record) return [];
return [{ key, ...record }];
});
}
function mergeSessionLists(
primary: NonNullable<SessionsListResponse["sessions"]> = [],
secondary: NonNullable<SessionsListResponse["sessions"]> = [],
): NonNullable<SessionsListResponse["sessions"]> {
const merged = new Map<string, NonNullable<SessionsListResponse["sessions"]>[number]>();
const mergeItem = (item: NonNullable<SessionsListResponse["sessions"]>[number]): void => {
const sessionKey = item.sessionKey ?? item.key;
if (!sessionKey) return;
const current = merged.get(sessionKey);
if (!current) {
merged.set(sessionKey, item);
return;
}
merged.set(sessionKey, {
...current,
...item,
sessionKey,
key: item.key ?? current.key ?? sessionKey,
sessionFile: item.sessionFile ?? current.sessionFile,
sessionId: item.sessionId ?? current.sessionId,
agentId: item.agentId ?? current.agentId,
updatedAtMs: Math.max(current.updatedAtMs ?? 0, item.updatedAtMs ?? 0) || undefined,
active: item.active ?? current.active ?? false,
state: item.state ?? current.state,
model: item.model ?? current.model,
inputTokens: item.inputTokens ?? current.inputTokens,
outputTokens: item.outputTokens ?? current.outputTokens,
totalTokens: item.totalTokens ?? current.totalTokens,
});
};
for (const item of secondary) mergeItem(item);
for (const item of primary) mergeItem(item);
return [...merged.values()].sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0));
}
function isSessionActive(item: Record<string, unknown>, updatedAtMs: number): boolean {
const explicitActive = asBoolean(item.active) ?? asBoolean(item.isActive);
if (typeof explicitActive === "boolean") return explicitActive;
const explicitState = readSessionState(item);
if (explicitState) {
if (ACTIVE_SESSION_STATES.has(explicitState)) return true;
if (INACTIVE_SESSION_STATES.has(explicitState)) return false;
}
if (!Number.isFinite(updatedAtMs)) return false;
return Date.now() - updatedAtMs <= FALLBACK_ACTIVE_RECENCY_WINDOW_MS;
}
function readSessionState(item: Record<string, unknown>): string | undefined {
const direct =
asString(item.state) ??
asString(item.status) ??
asString(item.runState) ??
asString(item.lifecycleState);
if (direct) return direct.trim().toLowerCase();
const acp = asObject(item.acp);
const acpState = asString(acp?.state);
return acpState ? acpState.trim().toLowerCase() : undefined;
}
function readUpdatedAtMs(item: Record<string, unknown>): number {
const candidates = [
item.updatedAt,
item.lastActivityAt,
item.createdAt,
asObject(item.acp)?.lastActivityAt,
asObject(item.acp)?.updatedAt,
asObject(item.acp)?.createdAt,
];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return normalizeEpochMs(candidate);
if (typeof candidate === "string" && candidate.trim() !== "") {
const trimmed = candidate.trim();
if (/^\d+(\.\d+)?$/.test(trimmed)) {
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) return normalizeEpochMs(numeric);
}
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) return parsed;
}
}
return Number.NaN;
}
function normalizeEpochMs(value: number): number {
const abs = Math.abs(value);
if (abs >= 1e14) return value / 1000;
if (abs > 0 && abs < 1e12) return value * 1000;
return value;
}
function buildSessionFilePath(
openclawHome: string,
agentId: string | undefined,
sessionId: string | undefined,
): string | undefined {
if (!agentId || !sessionId) return undefined;
return join(openclawHome, "agents", agentId, "sessions", `${sessionId}.jsonl`);
}
function extractAgentIdFromSessionKey(sessionKey: string | undefined): string | undefined {
const value = sessionKey?.trim();
if (!value) return undefined;
const match = /^agent:([^:]+):/i.exec(value);
return match?.[1];
}
function matchesConfiguredAgents(agentId: string | undefined, configuredAgentKeys: ReadonlySet<string>): boolean {
if (configuredAgentKeys.size === 0) return true;
const normalized = normalizeAgentKey(agentId);
return normalized.length > 0 && configuredAgentKeys.has(normalized);
}
function normalizeAgentKey(agentId: string | undefined): string {
return agentId?.trim().toLowerCase() ?? "";
}
function assertApprovalActionsEnabled(action: "approve" | "reject"): void {
if (APPROVAL_ACTIONS_ENABLED) return;
throw new Error(
`approvals ${action} is disabled by safety gate (APPROVAL_ACTIONS_ENABLED=${String(
APPROVAL_ACTIONS_ENABLED,
)}).`,
);
}
+80
View File
@@ -0,0 +1,80 @@
import type {
ApprovalsActionResponse,
ApprovalsApproveRequest,
ApprovalsGetResponse,
ApprovalsRejectRequest,
CronListResponse,
SessionStatusResponse,
SessionsHistoryRequest,
SessionsHistoryResponse,
SessionsListResponse,
} from "../contracts/openclaw-tools";
import { APPROVAL_ACTIONS_ENABLED } from "../config";
export interface ToolClient {
sessionsList(): Promise<SessionsListResponse>;
sessionStatus(sessionKey: string): Promise<SessionStatusResponse>;
sessionsHistory(request: SessionsHistoryRequest): Promise<SessionsHistoryResponse>;
cronList(): Promise<CronListResponse>;
approvalsGet(): Promise<ApprovalsGetResponse>;
approvalsApprove(request: ApprovalsApproveRequest): Promise<ApprovalsActionResponse>;
approvalsReject(request: ApprovalsRejectRequest): Promise<ApprovalsActionResponse>;
}
export class ReadonlyToolClient implements ToolClient {
async sessionsList(): Promise<SessionsListResponse> {
return { sessions: [] };
}
async sessionStatus(_sessionKey: string): Promise<SessionStatusResponse> {
return { rawText: "" };
}
async sessionsHistory(_request: SessionsHistoryRequest): Promise<SessionsHistoryResponse> {
return { rawText: "" };
}
async cronList(): Promise<CronListResponse> {
return { jobs: [] };
}
async approvalsGet(): Promise<ApprovalsGetResponse> {
return { rawText: "" };
}
async approvalsApprove(request: ApprovalsApproveRequest): Promise<ApprovalsActionResponse> {
if (!APPROVAL_ACTIONS_ENABLED) {
throw new Error(
`approvalsApprove is disabled by safety gate (APPROVAL_ACTIONS_ENABLED=${String(
APPROVAL_ACTIONS_ENABLED,
)}).`,
);
}
return {
ok: false,
action: "approve",
approvalId: request.approvalId,
reason: request.reason,
rawText: "readonly client has no approve capability",
};
}
async approvalsReject(request: ApprovalsRejectRequest): Promise<ApprovalsActionResponse> {
if (!APPROVAL_ACTIONS_ENABLED) {
throw new Error(
`approvalsReject is disabled by safety gate (APPROVAL_ACTIONS_ENABLED=${String(
APPROVAL_ACTIONS_ENABLED,
)}).`,
);
}
return {
ok: false,
action: "reject",
approvalId: request.approvalId,
reason: request.reason,
rawText: "readonly client has no reject capability",
};
}
}
+37
View File
@@ -0,0 +1,37 @@
export const GATEWAY_URL = readStringEnv(process.env.GATEWAY_URL, "ws://127.0.0.1:18789");
export const READONLY_MODE = process.env.READONLY_MODE !== "false";
export const APPROVAL_ACTIONS_ENABLED = process.env.APPROVAL_ACTIONS_ENABLED === "true";
export const APPROVAL_ACTIONS_DRY_RUN = process.env.APPROVAL_ACTIONS_DRY_RUN !== "false";
export const IMPORT_MUTATION_ENABLED = process.env.IMPORT_MUTATION_ENABLED === "true";
export const IMPORT_MUTATION_DRY_RUN = process.env.IMPORT_MUTATION_DRY_RUN === "true";
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 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(
process.env.TASK_HEARTBEAT_MAX_TASKS_PER_RUN,
3,
);
export const POLLING_INTERVALS_MS = {
sessionsList: 5000,
sessionStatus: 2000,
cron: 10000,
approvals: 2000,
canvas: 5000,
} as const;
export type PollingTarget = keyof typeof POLLING_INTERVALS_MS;
function parsePositiveInt(input: string | undefined, fallback: number): number {
const parsed = Number.parseInt(input ?? "", 10);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return parsed;
}
function readStringEnv(input: string | undefined, fallback: string): string {
const value = (input ?? "").trim();
return value === "" ? fallback : value;
}
+101
View File
@@ -0,0 +1,101 @@
export interface SessionsListRequest {
limit?: number;
}
export interface SessionsListItem {
key?: string;
sessionKey?: string;
sessionId?: string;
label?: string;
agentId?: string;
sessionFile?: string;
updatedAt?: string;
updatedAtMs?: number;
active?: boolean;
state?: string;
model?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
}
export interface SessionsListResponse {
sessions?: SessionsListItem[];
}
export interface SessionStatusRequest {
sessionKey: string;
}
export interface SessionStatusResponse {
rawText: string;
}
export interface SessionsHistoryRequest {
sessionKey: string;
limit?: number;
}
export interface SessionsHistoryResponse {
json?: Record<string, unknown>;
rawText: string;
}
export interface CronListRequest {
includeDisabled?: boolean;
}
export interface CronJobItem {
jobId?: string;
id?: string;
name?: string;
enabled?: boolean;
nextRunAt?: string;
state?: {
nextRunAtMs?: number;
};
}
export interface CronListResponse {
jobs?: CronJobItem[];
}
export interface ApprovalItem {
id?: string;
key?: string;
sessionKey?: string;
agentId?: string;
state?: string;
status?: string;
decision?: string;
command?: string;
prompt?: string;
reason?: string;
createdAt?: string;
requestedAt?: string;
updatedAt?: string;
expiresAt?: string;
}
export interface ApprovalsGetResponse {
json?: Record<string, unknown>;
rawText: string;
}
export interface ApprovalsApproveRequest {
approvalId: string;
reason?: string;
}
export interface ApprovalsRejectRequest {
approvalId: string;
reason: string;
}
export interface ApprovalsActionResponse {
ok: boolean;
action: "approve" | "reject";
approvalId: string;
reason?: string;
rawText: string;
}
+250
View File
@@ -0,0 +1,250 @@
import { OpenClawReadonlyAdapter } from "./adapters/openclaw-readonly";
import { createToolClient } from "./clients/factory";
import {
APPROVAL_ACTIONS_DRY_RUN,
APPROVAL_ACTIONS_ENABLED,
GATEWAY_URL,
IMPORT_MUTATION_DRY_RUN,
IMPORT_MUTATION_ENABLED,
LOCAL_API_TOKEN,
LOCAL_TOKEN_AUTH_REQUIRED,
POLLING_INTERVALS_MS,
READONLY_MODE,
TASK_HEARTBEAT_DRY_RUN,
TASK_HEARTBEAT_ENABLED,
TASK_HEARTBEAT_MAX_TASKS_PER_RUN,
} from "./config";
import { buildExportBundle, writeExportBundle } from "./runtime/export-bundle";
import { validateExportFileDryRun } from "./runtime/import-dry-run";
import { monitorIntervalMs, runMonitorOnce } from "./runtime/monitor";
import { pruneStaleAcks } from "./runtime/notification-center";
import { appendOperationAudit } from "./runtime/operation-audit";
import { runTaskHeartbeat, runtimeTaskHeartbeatGate } from "./runtime/task-heartbeat";
import { startUiServer } from "./ui/server";
const CONTINUOUS_MODE = process.env.MONITOR_CONTINUOUS === "true";
const UI_MODE = process.env.UI_MODE === "true";
const UI_PORT = Number.parseInt(process.env.UI_PORT ?? "4310", 10);
const COMMAND = normalizeCommand(process.env.APP_COMMAND ?? process.argv[2]);
const COMMAND_ARG =
process.env.COMMAND_ARG ??
(process.env.APP_COMMAND ? process.argv[2] : process.argv[3]);
async function start(): Promise<void> {
const client = createToolClient();
const adapter = new OpenClawReadonlyAdapter(client);
console.log("[mission-control] startup", {
gateway: GATEWAY_URL,
readonlyMode: READONLY_MODE,
approvalActionsEnabled: APPROVAL_ACTIONS_ENABLED,
approvalActionsDryRun: APPROVAL_ACTIONS_DRY_RUN,
importMutationEnabled: IMPORT_MUTATION_ENABLED,
importMutationDryRun: IMPORT_MUTATION_DRY_RUN,
localTokenAuthRequired: LOCAL_TOKEN_AUTH_REQUIRED,
localTokenConfigured: LOCAL_API_TOKEN !== "",
taskHeartbeat: {
enabled: TASK_HEARTBEAT_ENABLED,
dryRun: TASK_HEARTBEAT_DRY_RUN,
maxTasksPerRun: TASK_HEARTBEAT_MAX_TASKS_PER_RUN,
},
pollingIntervalsMs: POLLING_INTERVALS_MS,
networkCalls: !READONLY_MODE,
continuousMode: CONTINUOUS_MODE,
command: COMMAND ?? "monitor",
});
if (COMMAND) {
await runCommand(COMMAND, adapter, COMMAND_ARG);
return;
}
await runMonitorOnce(adapter);
if (CONTINUOUS_MODE) {
const intervalMs = monitorIntervalMs();
setInterval(() => {
void runMonitorOnce(adapter);
}, intervalMs);
}
if (UI_MODE) {
startUiServer(UI_PORT, client);
}
}
void start();
async function runCommand(
command: "backup-export" | "import-validate" | "acks-prune" | "task-heartbeat",
adapter: OpenClawReadonlyAdapter,
arg?: string,
): Promise<void> {
assertCommandOperationGate(command);
if (command === "backup-export") {
try {
const snapshot = await adapter.snapshot();
const bundle = await buildExportBundle(snapshot, "command", "cmd-backup-export");
const written = await writeExportBundle(bundle, "backup");
await appendOperationAudit({
action: "backup_export",
source: "command",
ok: true,
requestId: "cmd-backup-export",
detail: `wrote ${written.fileName}`,
metadata: {
path: written.path,
sizeBytes: written.sizeBytes,
},
});
console.log("[mission-control] backup export", {
exportedAt: bundle.exportedAt,
snapshotGeneratedAt: bundle.snapshotGeneratedAt,
fileName: written.fileName,
path: written.path,
sizeBytes: written.sizeBytes,
});
} catch (error) {
await appendOperationAudit({
action: "backup_export",
source: "command",
ok: false,
requestId: "cmd-backup-export",
detail: error instanceof Error ? error.message : "backup export failed",
});
throw error;
}
return;
}
if (command === "acks-prune") {
const dryRun = resolveAcksPruneDryRun(arg);
try {
const result = await pruneStaleAcks({ dryRun });
await appendOperationAudit({
action: "ack_prune",
source: "command",
ok: true,
requestId: "cmd-acks-prune",
detail: `removed ${result.removed} stale ack(s)`,
metadata: {
dryRun,
before: result.before,
removed: result.removed,
after: result.after,
},
});
console.log("[mission-control] acks prune", result);
} catch (error) {
await appendOperationAudit({
action: "ack_prune",
source: "command",
ok: false,
requestId: "cmd-acks-prune",
detail: error instanceof Error ? error.message : "acks prune failed",
});
throw error;
}
return;
}
if (command === "task-heartbeat") {
const gate = runtimeTaskHeartbeatGate();
const runDryRun = resolveTaskHeartbeatDryRun(arg, gate.dryRun);
const result = await runTaskHeartbeat({
gate: {
...gate,
dryRun: runDryRun,
},
});
await appendOperationAudit({
action: "task_heartbeat",
source: "command",
ok: result.ok,
requestId: "cmd-task-heartbeat",
detail: `${result.mode} ${result.message}`,
metadata: {
checked: result.checked,
eligible: result.eligible,
selected: result.selected,
executed: result.executed,
dryRun: result.gate.dryRun,
},
});
console.log("[mission-control] task heartbeat", result);
if (!result.ok) {
process.exitCode = 1;
}
return;
}
if (!arg) {
throw new Error(
"import-validate requires a file path argument. Example: APP_COMMAND=import-validate COMMAND_ARG=<file.json> npm run dev",
);
}
const validation = await validateExportFileDryRun(arg);
await appendOperationAudit({
action: "import_dry_run",
source: "command",
ok: validation.valid,
requestId: "cmd-import-validate",
detail: `validated ${validation.source}`,
metadata: {
valid: validation.valid,
issues: validation.issues.length,
warnings: validation.warnings.length,
},
});
console.log("[mission-control] import dry-run", validation);
if (!validation.valid) {
process.exitCode = 1;
}
}
function assertCommandOperationGate(
command: "backup-export" | "import-validate" | "acks-prune" | "task-heartbeat",
): void {
if (!LOCAL_TOKEN_AUTH_REQUIRED) return;
if (LOCAL_API_TOKEN !== "") return;
throw new Error(
`${command} is blocked by local token gate. Set LOCAL_API_TOKEN to explicitly allow protected command execution.`,
);
}
function normalizeCommand(
input: string | undefined,
): "backup-export" | "import-validate" | "acks-prune" | "task-heartbeat" | undefined {
if (!input) return undefined;
const trimmed = input.trim().toLowerCase();
if (trimmed === "") return undefined;
if (trimmed === "backup-export") return "backup-export";
if (trimmed === "import-validate") return "import-validate";
if (trimmed === "acks-prune") return "acks-prune";
if (trimmed === "task-heartbeat") return "task-heartbeat";
throw new Error(
`Unknown command '${input}'. Supported: backup-export, import-validate, acks-prune, task-heartbeat.`,
);
}
function resolveAcksPruneDryRun(arg: string | undefined): boolean {
const envDryRun = process.env.ACK_PRUNE_DRY_RUN === "true";
if (!arg) return envDryRun;
const normalized = arg.trim().toLowerCase();
if (normalized === "" || normalized === "--live" || normalized === "live") return false;
if (normalized === "--dry-run" || normalized === "dry-run") return true;
throw new Error("acks-prune optional arg must be one of: --dry-run, dry-run, --live, live.");
}
function resolveTaskHeartbeatDryRun(arg: string | undefined, fallback: boolean): boolean {
if (!arg) return fallback;
const normalized = arg.trim().toLowerCase();
if (normalized === "") return fallback;
if (normalized === "--dry-run" || normalized === "dry-run") return true;
if (normalized === "--live" || normalized === "live") return false;
throw new Error("task-heartbeat optional arg must be one of: --dry-run, dry-run, --live, live.");
}
+314
View File
@@ -0,0 +1,314 @@
import type {
ApprovalsGetResponse,
CronListResponse,
SessionsListResponse,
} from "../contracts/openclaw-tools";
import type {
ApprovalState,
ApprovalSummary,
AgentRunState,
CronJobSummary,
SessionSummary,
} from "../types";
const RUNNING_SESSION_STATES = new Set([
"running",
"active",
"busy",
"working",
"in_progress",
"processing",
"thinking",
"executing",
"streaming",
]);
const BLOCKED_SESSION_STATES = new Set([
"blocked",
"needs_support",
"stuck",
]);
const WAITING_APPROVAL_SESSION_STATES = new Set([
"waiting_approval",
"pending_approval",
"approval_pending",
"awaiting_approval",
"needs_approval",
]);
const ERROR_SESSION_STATES = new Set([
"error",
"failed",
"failure",
"crashed",
"panic",
]);
function toState(active: boolean | undefined, explicitState?: string): AgentRunState {
const normalized = explicitState?.trim().toLowerCase();
if (normalized) {
if (WAITING_APPROVAL_SESSION_STATES.has(normalized)) return "waiting_approval";
if (BLOCKED_SESSION_STATES.has(normalized)) return "blocked";
if (ERROR_SESSION_STATES.has(normalized)) return "error";
if (RUNNING_SESSION_STATES.has(normalized)) return "running";
if (normalized === "idle" || normalized === "inactive" || normalized === "done" || normalized === "completed" || normalized === "stopped" || normalized === "closed") {
return "idle";
}
}
if (active === true) return "running";
return "idle";
}
function toIso(updatedAtMs?: number, updatedAt?: string): string | undefined {
if (updatedAt) return updatedAt;
if (typeof updatedAtMs === "number") return new Date(updatedAtMs).toISOString();
return undefined;
}
export function mapSessionsListToSummaries(input: SessionsListResponse): SessionSummary[] {
const mapped = (input.sessions ?? []).map((item) => ({
sessionKey: item.sessionKey ?? item.key,
label: item.label,
agentId: item.agentId,
lastMessageAt: toIso(item.updatedAtMs, item.updatedAt),
state: toState(item.active, item.state),
}));
return mapped
.filter((item) => Boolean(item.sessionKey))
.map((item) => ({
sessionKey: item.sessionKey as string,
label: item.label,
agentId: item.agentId,
lastMessageAt: item.lastMessageAt,
state: item.state,
}));
}
export function mapCronListToSummaries(input: CronListResponse): CronJobSummary[] {
const mapped = (input.jobs ?? []).map((job) => ({
jobId: job.jobId ?? job.id,
name: job.name,
enabled: job.enabled ?? true,
nextRunAt:
job.nextRunAt ??
(typeof job.state?.nextRunAtMs === "number"
? new Date(job.state.nextRunAtMs).toISOString()
: undefined),
}));
return mapped
.filter((job) => Boolean(job.jobId))
.map((job) => ({
jobId: job.jobId as string,
name: job.name,
enabled: job.enabled,
nextRunAt: job.nextRunAt,
}));
}
export function mapApprovalsGetToSummaries(input: ApprovalsGetResponse): ApprovalSummary[] {
if (input.json) {
return mapApprovalsJsonToSummaries(input.json);
}
return mapApprovalsTextToSummaries(input.rawText);
}
function mapApprovalsJsonToSummaries(json: Record<string, unknown>): ApprovalSummary[] {
const approvals: ApprovalSummary[] = [];
approvals.push(...mapApprovalArray(asArray(json.pending), "pending", "pending"));
approvals.push(...mapApprovalArray(asArray(json.requests), "pending", "requests"));
approvals.push(...mapApprovalArray(asArray(json.approvals), "unknown", "approvals"));
const file = asObject(json.file);
if (!file) return dedupeById(approvals);
approvals.push(...mapApprovalArray(asArray(file.pending), "pending", "file-pending"));
approvals.push(...mapApprovalArray(asArray(file.requests), "pending", "file-requests"));
approvals.push(...mapApprovalArray(asArray(file.approvals), "unknown", "file-approvals"));
approvals.push(...mapApprovalArray(asArray(file.allowlist), "approved", "file-allowlist"));
const defaults = asObject(file.defaults);
if (defaults) {
let idx = 0;
for (const [key, value] of Object.entries(defaults)) {
idx += 1;
const status = toApprovalState(asString(value), asString(value), "unknown");
approvals.push({
approvalId: `default:${key}:${idx}`,
status,
decision: asString(value),
reason: "defaults rule",
});
}
}
const agents = asObject(file.agents);
if (agents) {
for (const [agentId, config] of Object.entries(agents)) {
const cfg = asObject(config);
if (!cfg) continue;
approvals.push(
...mapApprovalArray(asArray(cfg.pending), "pending", `agent:${agentId}:pending`, agentId),
);
approvals.push(
...mapApprovalArray(asArray(cfg.requests), "pending", `agent:${agentId}:requests`, agentId),
);
approvals.push(
...mapApprovalArray(asArray(cfg.approvals), "unknown", `agent:${agentId}:approvals`, agentId),
);
approvals.push(
...mapApprovalArray(
asArray(cfg.allowlist),
"approved",
`agent:${agentId}:allowlist`,
agentId,
),
);
}
}
return dedupeById(approvals);
}
function mapApprovalsTextToSummaries(rawText: string): ApprovalSummary[] {
const fields = extractFieldValueTable(rawText);
const pendingCount = toInt(fields.pending) ?? 0;
const allowlistCount = toInt(fields.allowlist) ?? 0;
const approvals: ApprovalSummary[] = [];
for (let i = 0; i < pendingCount; i += 1) {
approvals.push({
approvalId: `pending:${i + 1}`,
status: "pending",
reason: "from approvals text snapshot",
});
}
if (allowlistCount > 0) {
approvals.push({
approvalId: "allowlist:summary",
status: "approved",
decision: "allowlist",
reason: `allowlist entries: ${allowlistCount}`,
});
}
return approvals;
}
function mapApprovalArray(
array: unknown[] | undefined,
fallbackState: ApprovalState,
prefix: string,
agentIdFromContext?: string,
): ApprovalSummary[] {
if (!array || array.length === 0) return [];
return array
.map((item, idx) => mapApprovalRecord(item, fallbackState, `${prefix}:${idx + 1}`, agentIdFromContext))
.filter((item): item is ApprovalSummary => Boolean(item));
}
function mapApprovalRecord(
record: unknown,
fallbackState: ApprovalState,
fallbackId: string,
agentIdFromContext?: string,
): ApprovalSummary | null {
const obj = asObject(record);
if (!obj) {
if (typeof record === "string") {
return {
approvalId: fallbackId,
status: fallbackState,
decision: record,
};
}
return null;
}
const statusRaw = asString(obj.status) ?? asString(obj.state);
const decisionRaw = asString(obj.decision);
const agentId = asString(obj.agentId) ?? agentIdFromContext;
return {
approvalId:
asString(obj.id) ??
asString(obj.key) ??
asString(obj.approvalId) ??
asString(obj.requestId) ??
fallbackId,
sessionKey: asString(obj.sessionKey),
agentId,
status: toApprovalState(statusRaw, decisionRaw, fallbackState),
decision: decisionRaw ?? statusRaw,
command: asString(obj.command) ?? asString(obj.cmd) ?? asString(obj.pattern) ?? asString(obj.prompt),
reason: asString(obj.reason) ?? asString(obj.summary),
requestedAt: asString(obj.requestedAt) ?? asString(obj.createdAt),
updatedAt: asString(obj.updatedAt),
};
}
function dedupeById(items: ApprovalSummary[]): ApprovalSummary[] {
const seen = new Set<string>();
const out: ApprovalSummary[] = [];
for (const item of items) {
if (seen.has(item.approvalId)) continue;
seen.add(item.approvalId);
out.push(item);
}
return out;
}
function extractFieldValueTable(text: string): Record<string, string> {
const result: Record<string, string> = {};
const lines = text.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^\s*│\s*([^│]+?)\s*│\s*([^│]+?)\s*│\s*$/);
if (!match) continue;
const field = match[1].trim().toLowerCase();
const value = match[2].trim();
if (!field || field === "field" || field === "value") continue;
result[field] = value;
}
return result;
}
function toApprovalState(
statusRaw: string | undefined,
decisionRaw: string | undefined,
fallback: ApprovalState,
): ApprovalState {
const value = `${statusRaw ?? ""} ${decisionRaw ?? ""}`.toLowerCase();
if (value.includes("pending") || value.includes("wait")) return "pending";
if (value.includes("approved") || value.includes("allow") || value.includes("granted")) return "approved";
if (value.includes("denied") || value.includes("reject")) return "denied";
return fallback;
}
function toInt(v: string | undefined): number | undefined {
if (!v) return undefined;
const match = v.match(/\d+/);
if (!match) return undefined;
const parsed = Number.parseInt(match[0], 10);
return Number.isNaN(parsed) ? undefined : parsed;
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
function asArray(v: unknown): unknown[] | undefined {
return Array.isArray(v) ? v : undefined;
}
function asObject(v: unknown): Record<string, unknown> | undefined {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
+37
View File
@@ -0,0 +1,37 @@
import type { SessionStatusSnapshot } from "../types";
export function parseSessionStatusText(sessionKey: string, raw: string): SessionStatusSnapshot {
const model = matchOne(raw, /Model:\s*([^·\n]+)/)?.trim();
const tokensIn = toInt(matchOne(raw, /Tokens:\s*(\d+)\s*in/i));
const tokensOut = toInt(matchOne(raw, /in\s*\/\s*(\d+)\s*out/i));
const cost = toFloat(
matchOne(raw, /Cost:\s*\$?\s*([0-9]+(?:\.[0-9]+)?)/i) ??
matchOne(raw, /Total\s+Cost:\s*\$?\s*([0-9]+(?:\.[0-9]+)?)/i),
);
return {
sessionKey,
model,
tokensIn,
tokensOut,
cost,
updatedAt: new Date().toISOString(),
};
}
function matchOne(text: string, re: RegExp): string | undefined {
const m = text.match(re);
return m?.[1];
}
function toInt(v: string | undefined): number | undefined {
if (!v) return undefined;
const n = Number.parseInt(v, 10);
return Number.isNaN(n) ? undefined : n;
}
function toFloat(v: string | undefined): number | undefined {
if (!v) return undefined;
const n = Number.parseFloat(v);
return Number.isNaN(n) ? undefined : n;
}
+354
View File
@@ -0,0 +1,354 @@
export type AgentRunState = "idle" | "running" | "blocked" | "waiting_approval" | "error";
export interface SessionSummary {
sessionKey: string;
label?: string;
agentId?: string;
state: AgentRunState;
lastMessageAt?: string;
}
export interface SessionStatusSnapshot {
sessionKey: string;
model?: string;
tokensIn?: number;
tokensOut?: number;
cost?: number;
updatedAt: string;
}
export interface CronJobSummary {
jobId: string;
name?: string;
enabled: boolean;
nextRunAt?: string;
}
export type ApprovalState = "pending" | "approved" | "denied" | "unknown";
export interface ApprovalSummary {
approvalId: string;
sessionKey?: string;
agentId?: string;
status: ApprovalState;
decision?: string;
command?: string;
reason?: string;
requestedAt?: string;
updatedAt?: string;
}
export type TaskState = "todo" | "in_progress" | "blocked" | "done";
export type ProjectState = "planned" | "active" | "blocked" | "done";
export type TaskArtifactType = "code" | "doc" | "link" | "other";
export interface TaskArtifact {
artifactId: string;
type: TaskArtifactType;
label: string;
location: string;
}
export interface RollbackPlan {
strategy: string;
steps: string[];
verification?: string;
}
export interface BudgetThresholds {
tokensIn?: number;
tokensOut?: number;
totalTokens?: number;
cost?: number;
warnRatio?: number;
}
export interface ProjectTask {
projectId: string;
taskId: string;
title: string;
status: TaskState;
owner: string;
dueAt?: string;
definitionOfDone: string[];
artifacts: TaskArtifact[];
rollback: RollbackPlan;
sessionKeys: string[];
budget: BudgetThresholds;
updatedAt: string;
}
export interface ProjectRecord {
projectId: string;
title: string;
status: ProjectState;
owner: string;
budget: BudgetThresholds;
updatedAt: string;
}
export interface ProjectStoreSnapshot {
projects: ProjectRecord[];
updatedAt: string;
}
export interface AgentBudgetPlan {
agentId: string;
label?: string;
thresholds: BudgetThresholds;
}
export interface TaskStoreSnapshot {
tasks: ProjectTask[];
agentBudgets: AgentBudgetPlan[];
updatedAt: string;
}
export interface TasksSummary {
projects: number;
tasks: number;
todo: number;
inProgress: number;
blocked: number;
done: number;
owners: number;
artifacts: number;
}
export interface ProjectSummary {
projectId: string;
title: string;
status: ProjectState;
owner: string;
totalTasks: number;
todo: number;
inProgress: number;
blocked: number;
done: number;
due: number;
updatedAt: string;
}
export type BudgetStatus = "ok" | "warn" | "over";
export type BudgetScope = "agent" | "project" | "task";
export interface BudgetUsageSnapshot {
tokensIn: number;
tokensOut: number;
totalTokens: number;
cost: number;
}
export interface BudgetMetricEvaluation {
metric: "tokensIn" | "tokensOut" | "totalTokens" | "cost";
used: number;
limit: number;
warnAt: number;
status: BudgetStatus;
}
export interface BudgetEvaluation {
scope: BudgetScope;
scopeId: string;
label: string;
thresholds: BudgetThresholds;
usage: BudgetUsageSnapshot;
metrics: BudgetMetricEvaluation[];
status: BudgetStatus;
}
export interface BudgetSummary {
total: number;
ok: number;
warn: number;
over: number;
evaluations: BudgetEvaluation[];
}
export interface BudgetPolicyConfig {
defaults: BudgetThresholds;
agent: Record<string, BudgetThresholds>;
project: Record<string, BudgetThresholds>;
task: Record<string, BudgetThresholds>;
}
export interface ReadModelSnapshot {
sessions: SessionSummary[];
statuses: SessionStatusSnapshot[];
cronJobs: CronJobSummary[];
approvals: ApprovalSummary[];
projects: ProjectStoreSnapshot;
projectSummaries: ProjectSummary[];
tasks: TaskStoreSnapshot;
tasksSummary: TasksSummary;
budgetSummary: BudgetSummary;
generatedAt: string;
}
export interface TaskListItem {
projectId: string;
projectTitle: string;
taskId: string;
title: string;
status: TaskState;
owner: string;
dueAt?: string;
sessionKeys: string[];
updatedAt: string;
}
export interface CommanderExceptionsSummary {
generatedAt: string;
blocked: SessionSummary[];
errors: SessionSummary[];
pendingApprovals: ApprovalSummary[];
overBudget: BudgetEvaluation[];
tasksDue: TaskListItem[];
counts: {
blocked: number;
errors: number;
pendingApprovals: number;
overBudget: number;
tasksDue: number;
};
}
export type AlertLevel = "info" | "warn" | "action-required";
export interface ExceptionFeedItem {
level: AlertLevel;
code:
| "NO_SESSIONS"
| "SESSION_BLOCKED"
| "SESSION_ERROR"
| "PENDING_APPROVAL"
| "OVER_BUDGET"
| "TASK_DUE";
source: "system" | "session" | "approval" | "budget" | "task";
sourceId: string;
message: string;
route: "timeline" | "operator-watch" | "action-queue";
occurredAt?: string;
}
export interface CommanderExceptionsFeed {
generatedAt: string;
items: ExceptionFeedItem[];
counts: {
info: number;
warn: number;
actionRequired: number;
};
}
export interface NotificationAck {
itemId: string;
ackedAt: string;
note?: string;
expiresAt?: string;
}
export interface AcksStoreSnapshot {
acks: NotificationAck[];
updatedAt: string;
}
export interface ActionQueueItem extends ExceptionFeedItem {
itemId: string;
acknowledged: boolean;
ackedAt?: string;
note?: string;
ackExpiresAt?: string;
links: ActionQueueLink[];
}
export interface ActionQueueLink {
type: "session" | "task" | "project";
id: string;
href: string;
label: string;
}
export interface NotificationCenterSnapshot {
generatedAt: string;
queue: ActionQueueItem[];
counts: {
total: number;
acked: number;
unacked: number;
};
}
export type ChecklistStatus = "pass" | "warn" | "fail";
export type ReadinessCategory = "observability" | "governance" | "collaboration" | "security";
export interface ReadinessCategoryScore {
category: ReadinessCategory;
score: number;
passed: number;
warn: number;
failed: number;
total: number;
}
export interface ReadinessScoreSnapshot {
overall: number;
categories: ReadinessCategoryScore[];
}
export interface DoneChecklistItem {
id: string;
category: ReadinessCategory;
title: string;
docRef: string;
status: ChecklistStatus;
detail: string;
}
export interface DoneChecklistSnapshot {
generatedAt: string;
basedOn: string[];
items: DoneChecklistItem[];
counts: {
pass: number;
warn: number;
fail: number;
};
readiness: ReadinessScoreSnapshot;
}
export interface ExportBundle {
ok: true;
schemaVersion: "phase-9";
source: "api" | "command";
requestId?: string;
exportedAt: string;
snapshotGeneratedAt: string;
sessions: SessionSummary[];
projects: ProjectStoreSnapshot;
tasks: TaskStoreSnapshot;
budgets: {
policy: BudgetPolicyConfig;
issues: string[];
summary: BudgetSummary;
};
exceptions: CommanderExceptionsSummary;
exceptionsFeed: CommanderExceptionsFeed;
}
export interface ImportDryRunSummary {
sessions: number;
projects: number;
tasks: number;
exceptions: number;
}
export interface ImportDryRunResult {
validatedAt: string;
source: string;
valid: boolean;
issues: string[];
warnings: string[];
summary: ImportDryRunSummary;
}
+13194
View File
File diff suppressed because it is too large. Load diff
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
test("agent roster treats openclaw.json as the current-project source of truth", async () => {
const home = await mkdtemp(join(tmpdir(), "control-center-roster-"));
const originalHome = process.env.OPENCLAW_HOME;
const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH;
try {
await mkdir(join(home, "agents", "pandas"), { recursive: true });
await mkdir(join(home, "agents", "otter"), { recursive: true });
await writeFile(
join(home, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{ id: "main", name: "main" },
{ id: "pandas", name: "pandas" },
],
},
},
null,
2,
),
"utf8",
);
process.env.OPENCLAW_HOME = home;
delete process.env.OPENCLAW_CONFIG_PATH;
const { loadBestEffortAgentRoster } = await import("../src/runtime/agent-roster");
const roster = await loadBestEffortAgentRoster();
assert.equal(roster.status, "connected");
assert.equal(roster.entries.length, 2);
assert(roster.entries.some((entry) => entry.agentId === "main"));
assert(roster.entries.some((entry) => entry.agentId === "pandas"));
assert(!roster.entries.some((entry) => entry.agentId === "otter"));
assert(roster.detail.includes("source of truth"));
} finally {
if (originalHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = originalHome;
if (originalConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
else process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
await rm(home, { recursive: true, force: true });
}
});
test("agent roster stays connected from runtime when openclaw.json is missing", async () => {
const home = await mkdtemp(join(tmpdir(), "control-center-roster-runtime-"));
const originalHome = process.env.OPENCLAW_HOME;
const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH;
try {
await mkdir(join(home, "agents", "monkey"), { recursive: true });
await mkdir(join(home, "agents", "tiger"), { recursive: true });
process.env.OPENCLAW_HOME = home;
delete process.env.OPENCLAW_CONFIG_PATH;
const { loadBestEffortAgentRoster } = await import("../src/runtime/agent-roster");
const roster = await loadBestEffortAgentRoster();
assert.equal(roster.status, "connected");
assert.equal(roster.entries.length, 2);
assert(roster.entries.some((entry) => entry.agentId === "monkey"));
assert(roster.entries.some((entry) => entry.agentId === "tiger"));
} finally {
if (originalHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = originalHome;
if (originalConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
else process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
await rm(home, { recursive: true, force: true });
}
});
+163
View File
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import test from "node:test";
test("commander exceptions feed ordering is deterministic by severity then time", async () => {
const { commanderExceptionsFeed } = await import("../src/runtime/commander");
const snapshot = {
sessions: [
{
sessionKey: "s-error",
state: "error",
agentId: "agent-1",
label: "Error Session",
lastMessageAt: "2026-03-01T10:00:00.000Z",
},
{
sessionKey: "s-blocked",
state: "blocked",
agentId: "agent-2",
label: "Blocked Session",
lastMessageAt: "2026-03-01T11:00:00.000Z",
},
],
statuses: [],
cronJobs: [],
approvals: [
{
approvalId: "approval-b",
status: "pending",
requestedAt: "2026-03-01T09:00:00.000Z",
updatedAt: "2026-03-01T09:00:00.000Z",
},
{
approvalId: "approval-a",
status: "pending",
requestedAt: "2026-03-01T09:00:00.000Z",
updatedAt: "2026-03-01T09:00:00.000Z",
},
],
projects: {
updatedAt: "2026-03-01T08:00:00.000Z",
projects: [],
},
projectSummaries: [],
tasks: {
updatedAt: "2026-03-01T08:00:00.000Z",
agentBudgets: [],
tasks: [],
},
tasksSummary: {
projects: 0,
tasks: 0,
todo: 0,
inProgress: 0,
blocked: 0,
done: 0,
owners: 0,
artifacts: 0,
},
budgetSummary: {
total: 0,
ok: 0,
warn: 0,
over: 0,
evaluations: [],
},
generatedAt: "2026-03-01T12:00:00.000Z",
} as const;
const first = commanderExceptionsFeed(snapshot).items.map((item) => ({
level: item.level,
code: item.code,
sourceId: item.sourceId,
occurredAt: item.occurredAt,
}));
const second = commanderExceptionsFeed(snapshot).items.map((item) => ({
level: item.level,
code: item.code,
sourceId: item.sourceId,
occurredAt: item.occurredAt,
}));
assert.deepEqual(first, second);
assert.equal(first[0]?.level, "action-required");
assert.equal(first[0]?.code, "SESSION_ERROR");
assert.equal(first[1]?.code, "PENDING_APPROVAL");
assert.equal(first[1]?.sourceId, "approval-a");
assert.equal(first[2]?.sourceId, "approval-b");
});
test("stale runtime issue sessions stay out of the current action queue", async () => {
const { commanderExceptions, commanderExceptionsFeed } = await import("../src/runtime/commander");
const now = new Date();
const freshAt = new Date(now.getTime() - 30 * 60 * 1000).toISOString();
const staleAt = new Date(now.getTime() - 8 * 60 * 60 * 1000).toISOString();
const generatedAt = now.toISOString();
const snapshot = {
sessions: [
{
sessionKey: "s-error-stale",
state: "error",
agentId: "agent-1",
label: "Stale Error Session",
lastMessageAt: staleAt,
},
{
sessionKey: "s-blocked-fresh",
state: "blocked",
agentId: "agent-2",
label: "Fresh Blocked Session",
lastMessageAt: freshAt,
},
{
sessionKey: "s-error-fresh",
state: "error",
agentId: "agent-3",
label: "Fresh Error Session",
lastMessageAt: freshAt,
},
],
statuses: [],
cronJobs: [],
approvals: [],
projects: {
updatedAt: generatedAt,
projects: [],
},
projectSummaries: [],
tasks: {
updatedAt: generatedAt,
agentBudgets: [],
tasks: [],
},
tasksSummary: {
projects: 0,
tasks: 0,
todo: 0,
inProgress: 0,
blocked: 0,
done: 0,
owners: 0,
artifacts: 0,
},
budgetSummary: {
total: 0,
ok: 0,
warn: 0,
over: 0,
evaluations: [],
},
generatedAt,
} as const;
const exceptions = commanderExceptions(snapshot);
const feed = commanderExceptionsFeed(snapshot);
assert.equal(exceptions.errors.length, 1);
assert.equal(exceptions.errors[0]?.sessionKey, "s-error-fresh");
assert.equal(exceptions.blocked.length, 1);
assert.equal(exceptions.blocked[0]?.sessionKey, "s-blocked-fresh");
assert(feed.items.every((item) => item.sourceId !== "s-error-stale"));
});
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { ToolClient } from "../src/clients/tool-client";
import { buildStructuredDocHubFromSessions } from "../src/runtime/doc-hub";
import type { ReadModelSnapshot } from "../src/types";
function createEmptySnapshot(): ReadModelSnapshot {
const now = "2026-03-05T18:00:00.000Z";
return {
sessions: [],
statuses: [],
cronJobs: [],
approvals: [],
projects: { projects: [], updatedAt: now },
projectSummaries: [],
tasks: { tasks: [], agentBudgets: [], updatedAt: now },
tasksSummary: {
projects: 0,
tasks: 0,
todo: 0,
inProgress: 0,
blocked: 0,
done: 0,
owners: 0,
artifacts: 0,
},
budgetSummary: { total: 0, ok: 0, warn: 0, over: 0, evaluations: [] },
generatedAt: now,
};
}
test("doc hub ingests structured chat outputs into runtime index", async () => {
const workspace = await mkdtemp(join(tmpdir(), "doc-hub-test-"));
const indexPath = join(workspace, "doc-hub-chat.json");
const snapshot = createEmptySnapshot();
snapshot.sessions = [
{
sessionKey: "sess-alpha",
label: "Alpha",
agentId: "main",
state: "running",
lastMessageAt: "2026-03-05T18:01:00.000Z",
},
];
let historyCalls = 0;
const client: ToolClient = {
async sessionsList() {
return { sessions: [] };
},
async sessionStatus() {
return { rawText: "" };
},
async sessionsHistory() {
historyCalls += 1;
return {
json: {
history: [
{
role: "assistant",
timestamp: "2026-03-05T18:01:00.000Z",
content:
"# 今日执行计划\n\n1. 核对 cron 任务与负责人\n2. 更新预算看板\n3. 输出复盘摘要\n\n- 风险:订阅窗口超限\n- 对策:先处理高价值任务\n\n## 下一步\n- 先修复心跳接入,再补文档入库。",
},
],
},
rawText: "",
};
},
async cronList() {
return { jobs: [] };
},
async approvalsGet() {
return { rawText: "" };
},
async approvalsApprove() {
return { ok: false, action: "approve", approvalId: "na", rawText: "" };
},
async approvalsReject() {
return { ok: false, action: "reject", approvalId: "na", rawText: "" };
},
};
const result = await buildStructuredDocHubFromSessions({
snapshot,
client,
indexPath,
refreshFromSessions: true,
maxSessions: 8,
historyLimit: 40,
maxDocsPerSession: 2,
maxStoredDocs: 20,
});
assert(historyCalls >= 0);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].sourceSessionKey, "sess-alpha");
assert.match(result.items[0].title, /今日执行计划/);
assert.match(result.items[0].category, /(计划路线|会话文档|总结复盘)/);
const persistedRaw = await readFile(indexPath, "utf8");
assert(persistedRaw.includes("sess-alpha"));
assert(persistedRaw.includes("今日执行计划"));
});
+76
View File
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import path from "node:path";
import {
STAFF_AVATARS,
centerSprite,
extractSpriteRuntime,
getSpriteBounds,
renderSpritePng,
renderSpriteSvg,
} from "../scripts/export-staff-avatars";
test("embedded avatar runtime can be extracted from ui server", async () => {
const serverPath = path.join(process.cwd(), "src/ui/server.ts");
const source = await import("node:fs/promises").then((fs) => fs.readFile(serverPath, "utf8"));
const runtime = extractSpriteRuntime(source);
assert.equal(runtime.spriteSize, 44);
assert.equal(typeof runtime.spriteFactory.monkey, "function");
assert.equal(typeof runtime.spriteFactory.otter, "function");
assert.equal(runtime.spriteVisualSpanCompensation.lion, 0.92);
assert.equal(runtime.spriteVisualSpanCompensation.otter, 0.9);
});
test("staff avatar exporter covers every configured staff member", async () => {
const serverPath = path.join(process.cwd(), "src/ui/server.ts");
const source = await import("node:fs/promises").then((fs) => fs.readFile(serverPath, "utf8"));
const runtime = extractSpriteRuntime(source);
for (const item of STAFF_AVATARS) {
const sprite = runtime.spriteFactory[item.animal]();
assert.equal(sprite.length, runtime.spriteSize);
assert.ok(sprite.some((row) => row.some(Boolean)), `${item.agentId} sprite should not be empty`);
}
});
test("sprite svg export keeps crisp pixel rects", () => {
const svg = renderSpriteSvg([
["#000000", ""],
["", "#ffffff"],
]);
assert.match(svg, /shape-rendering="crispEdges"/);
assert.match(svg, /<rect x="16" y="16" width="8" height="8" fill="#000000"/);
assert.match(svg, /<rect x="24" y="24" width="8" height="8" fill="#ffffff"/);
});
test("centerSprite recenters off-axis content into a square canvas", () => {
const centered = centerSprite([
["", "", ""],
["", "#111111", ""],
["", "", "#222222"],
], 1);
const bounds = getSpriteBounds(centered);
assert.equal(centered.length, centered[0]?.length);
assert.deepEqual(bounds, {
minX: 1,
minY: 1,
maxX: 2,
maxY: 2,
width: 2,
height: 2,
span: 2,
});
});
test("sprite png export writes a valid PNG signature", () => {
const png = renderSpritePng([
["#000000", ""],
["", "#ffffff"],
], 4);
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
});
+593
View File
@@ -0,0 +1,593 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ToolClient } from "../src/clients/tool-client";
import type { ReadModelSnapshot } from "../src/types";
test("office roster fallback keeps full known agent list even without live sessions", async () => {
const { buildOfficeAgentRosterIds, buildOfficeSpaceCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
const knownAgents = ["pandas", "otter", "tiger"];
const rosterIds = buildOfficeAgentRosterIds(snapshot, [], knownAgents);
assert.equal(rosterIds.length, 3);
assert(rosterIds.includes("pandas"));
assert(rosterIds.includes("otter"));
assert(rosterIds.includes("tiger"));
const cards = buildOfficeSpaceCards(snapshot, [], knownAgents);
assert.equal(cards.length, 3);
const otter = cards.find((item) => item.agentId === "otter");
assert(otter, "Expected known roster agent to render a card.");
assert.equal(otter.status, "inactive");
assert.equal(otter.officeZone, "Standby Pods");
});
test("office cards use runtime active-session map when snapshot sessions are empty", async () => {
const { buildOfficeSpaceCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
const cards = buildOfficeSpaceCards(snapshot, [], ["codex"], new Map([["codex", 2]]));
const codex = cards.find((item) => item.agentId === "codex");
assert(codex, "Expected runtime-mapped agent card.");
assert.equal(codex.activeSessions, 2);
assert.equal(codex.status, "running");
assert.equal(codex.officeZone, "Builder Desks");
});
test("office cards ignore unknown runtime agents when a current roster is present", async () => {
const { buildOfficeSpaceCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
snapshot.sessions = [
{
sessionKey: "agent:codex:legacy",
agentId: "codex",
state: "running",
lastMessageAt: "2026-03-10T10:00:00.000Z",
},
];
const cards = buildOfficeSpaceCards(snapshot, [], ["main"], new Map([["codex", 2], ["main", 1]]));
assert.equal(cards.length, 1);
assert.equal(cards[0]?.agentId, "main");
assert.equal(cards[0]?.activeSessions, 1);
});
test("office cards do not mark agents as running from idle historical sessions alone", async () => {
const { buildOfficeSpaceCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
snapshot.sessions = [
{
sessionKey: "agent:main:main",
agentId: "main",
state: "idle",
lastMessageAt: "2026-03-09T16:27:26.457Z",
},
{
sessionKey: "agent:main:cron:job-1:run:child-1",
agentId: "main",
state: "idle",
lastMessageAt: "2026-03-09T15:15:00.020Z",
},
];
const cards = buildOfficeSpaceCards(snapshot, [], ["main"]);
const main = cards.find((item) => item.agentId === "main");
assert(main, "Expected main card.");
assert.equal(main.activeSessions, 0);
assert.equal(main.status, "idle");
assert.equal(main.officeZone, "Standby Pods");
});
test("office cards keep agents idle when they only have unfinished tasks but no live sessions", async () => {
const { buildOfficeSpaceCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
const tasks = [
{
projectId: "p-1",
projectTitle: "Project One",
taskId: "task-1",
title: "Prepare next report",
status: "todo",
owner: "main",
sessionKeys: [],
updatedAt: "2026-03-10T10:00:00.000Z",
},
];
const cards = buildOfficeSpaceCards(snapshot, tasks, ["main"]);
const main = cards.find((item) => item.agentId === "main");
assert(main, "Expected main card.");
assert.equal(main.activeSessions, 0);
assert.equal(main.status, "idle");
assert.equal(main.officeZone, "Standby Pods");
assert.deepEqual(main.focusItems, ["Prepare next report"]);
});
test("execution summaries infer cron ownership from agent cron session keys when jobs omit ownerAgentId", async () => {
const { buildExecutionAgentSummaries } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
snapshot.sessions = [
{
sessionKey: "agent:main:cron:job-1",
agentId: "main",
state: "idle",
lastMessageAt: "2026-03-09T16:27:26.457Z",
},
{
sessionKey: "agent:main:cron:job-1:run:child-1",
agentId: "main",
state: "idle",
lastMessageAt: "2026-03-09T16:28:00.000Z",
},
];
const rows = buildExecutionAgentSummaries(
snapshot,
[],
[
{
jobId: "job-1",
name: "Demo cron",
enabled: true,
owner: "Scheduler",
purpose: "Run demo cron.",
scheduleLabel: "cron * * * * *",
sourcePath: "/tmp/jobs.json",
},
],
[],
new Map(),
);
const main = rows.find((item) => item.agentId === "main");
assert(main, "Expected main execution summary.");
assert.equal(main.enabledCronJobs, 1);
assert.deepEqual(main.cronJobNames, ["Demo cron"]);
});
test("execution summaries ignore agents outside the current roster", async () => {
const { buildExecutionAgentSummaries } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
snapshot.sessions = [
{
sessionKey: "agent:codex:legacy",
agentId: "codex",
state: "running",
lastMessageAt: "2026-03-09T16:27:26.457Z",
},
];
const rows = buildExecutionAgentSummaries(
snapshot,
[],
[],
[{ agentId: "main", displayName: "main" }],
new Map([["codex", 999], ["main", 10]]),
);
assert.equal(rows.length, 1);
assert.equal(rows[0]?.agentId, "main");
});
test("staff overview recent output is per-agent and excludes user/thinking noise", async () => {
const { buildStaffOverviewCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
snapshot.sessions = [
{
sessionKey: "agent:main:main",
agentId: "main",
state: "idle",
lastMessageAt: "2026-03-09T16:30:00.000Z",
},
{
sessionKey: "agent:main:cron:job-1",
agentId: "main",
state: "idle",
lastMessageAt: "2026-03-09T16:00:00.000Z",
},
{
sessionKey: "agent:otter:main",
agentId: "otter",
state: "idle",
lastMessageAt: "2026-03-09T15:00:00.000Z",
},
];
const client: ToolClient = {
async sessionsList() { return { sessions: [] }; },
async sessionStatus() { return { rawText: "" }; },
async sessionsHistory(request) {
if (request.sessionKey === "agent:main:main") {
return {
json: {
history: [
{ role: "user", content: "继续推进当前目标:在达到 Alex Finn 流程最终验收前不要停。" },
{ role: "assistant", content: "[Mon 2026-03-09 09:01 GMT+1] OpenClaw runtime context (internal): This context is run-only." },
{ role: "assistant", content: "thinking **Summarizing discussion points**" },
],
},
rawText: "",
};
}
if (request.sessionKey === "agent:main:cron:job-1") {
return {
json: {
history: [
{ role: "assistant", content: "{\"ok\":true,\"sent\":5}" },
],
},
rawText: "",
};
}
if (request.sessionKey === "agent:otter:main") {
return {
json: {
history: [
{ role: "assistant", content: "Conversation info (untrusted metadata): ```json {\"message_id\":\"1022\"}```" },
{ role: "assistant", content: "已记录并设置提醒。" },
],
},
rawText: "",
};
}
return { rawText: "" };
},
async cronList() { return { jobs: [] }; },
async approvalsGet() { return { rawText: "" }; },
async approvalsApprove() { return { ok: false, action: "approve", approvalId: "x", rawText: "" }; },
async approvalsReject() { return { ok: false, action: "reject", approvalId: "x", reason: "no", rawText: "" }; },
};
const cards = await buildStaffOverviewCards({
snapshot,
client,
members: [
{ agentId: "main", displayName: "main", model: "gpt", workspace: "main", toolsProfile: "full" },
{ agentId: "otter", displayName: "otter", model: "gpt", workspace: "otter", toolsProfile: "minimal" },
],
officeCards: [
{
agentId: "main",
identity: { animal: "lion", title: "Commander", accent: "#ff9966", sprite: "lion" },
status: "idle",
statusLabel: "待命",
officeZone: "Standby Pods",
activeSessions: 0,
activeTasks: 0,
focusItems: [],
summary: "待命中。",
},
{
agentId: "otter",
identity: { animal: "otter", title: "Assistant", accent: "#88d5f6", sprite: "otter" },
status: "idle",
statusLabel: "待命",
officeZone: "Standby Pods",
activeSessions: 0,
activeTasks: 0,
focusItems: [],
summary: "待命中。",
},
],
executionAgentSummaries: [
{ agentId: "main", displayName: "main", activeSessions: 0, activeTasks: 0, enabledCronJobs: 1, cronJobNames: ["Demo cron"], recentTokens30d: 0 },
{ agentId: "otter", displayName: "otter", activeSessions: 0, activeTasks: 0, enabledCronJobs: 0, cronJobNames: [], recentTokens30d: 0 },
],
language: "zh",
});
const main = cards.find((item) => item.agentId === "main");
const otter = cards.find((item) => item.agentId === "otter");
assert(main);
assert(otter);
assert.equal(main.recentOutput, "最近完成一次任务,并返回结构化结果。");
assert.equal(main.currentWorkLabel, "下一项");
assert.equal(main.currentWork, "Demo cron");
assert.equal(main.scheduledLabel, "已排班");
assert.equal(otter.currentWorkLabel, "正在处理什么");
assert.equal(otter.recentOutput, "已记录并设置提醒。");
});
test("staff overview uses next-up wording when tasks exist but no live session is active", async () => {
const { buildStaffOverviewCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
const client: ToolClient = {
async sessionsList() { return { sessions: [] }; },
async sessionStatus() { return { rawText: "" }; },
async sessionsHistory() { return { rawText: "" }; },
async cronList() { return { jobs: [] }; },
async approvalsGet() { return { rawText: "" }; },
async approvalsApprove() { return { ok: false, action: "approve", approvalId: "x", rawText: "" }; },
async approvalsReject() { return { ok: false, action: "reject", approvalId: "x", reason: "no", rawText: "" }; },
};
const cards = await buildStaffOverviewCards({
snapshot,
client,
members: [
{ agentId: "main", displayName: "main", model: "gpt", workspace: "main", toolsProfile: "full" },
],
officeCards: [
{
agentId: "main",
identity: { animal: "lion", title: "Commander", accent: "#ff9966", sprite: "lion" },
status: "idle",
statusLabel: "待命",
officeZone: "Standby Pods",
activeSessions: 0,
activeTasks: 1,
focusItems: ["Prepare next report"],
summary: "当前跟进:Prepare next report",
},
],
executionAgentSummaries: [
{ agentId: "main", displayName: "main", activeSessions: 0, activeTasks: 1, enabledCronJobs: 0, cronJobNames: [], recentTokens30d: 0 },
],
language: "zh",
});
const main = cards.find((item) => item.agentId === "main");
assert(main);
assert.equal(main.statusLabel, "待命");
assert.equal(main.currentWorkLabel, "下一项");
assert.equal(main.currentWork, "Prepare next report");
});
test("staff overview does not show standby copy while status is running", async () => {
const { buildStaffOverviewCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
const client: ToolClient = {
async sessionsList() { return { sessions: [] }; },
async sessionStatus() { return { rawText: "" }; },
async sessionsHistory() { return { rawText: "" }; },
async cronList() { return { jobs: [] }; },
async approvalsGet() { return { rawText: "" }; },
async approvalsApprove() { return { ok: false, action: "approve", approvalId: "x", rawText: "" }; },
async approvalsReject() { return { ok: false, action: "reject", approvalId: "x", reason: "no", rawText: "" }; },
};
const cards = await buildStaffOverviewCards({
snapshot,
client,
members: [
{ agentId: "main", displayName: "main", model: "gpt", workspace: "main", toolsProfile: "full" },
],
officeCards: [
{
agentId: "main",
identity: { animal: "lion", title: "Commander", accent: "#ff9966", sprite: "lion" },
status: "running",
statusLabel: "执行中",
officeZone: "Builder Desks",
activeSessions: 1,
activeTasks: 0,
focusItems: [],
summary: "会话已开启,等待下一步指令。",
},
],
executionAgentSummaries: [
{ agentId: "main", displayName: "main", activeSessions: 1, activeTasks: 0, enabledCronJobs: 2, cronJobNames: ["Job A", "Job B"], recentTokens30d: 0 },
],
language: "zh",
});
const main = cards.find((item) => item.agentId === "main");
assert(main);
assert.equal(main.statusLabel, "工作中");
assert.equal(main.currentWorkLabel, "正在处理什么");
assert.equal(main.currentWork, "Job A");
assert.notEqual(main.currentWork, "正在执行排班任务");
assert.notEqual(main.currentWork, "按排班待命");
});
test("staff overview downgrades residual running sessions after explicit stop signal", async () => {
const { buildStaffOverviewCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
snapshot.sessions = [
{
sessionKey: "agent:coq:main",
agentId: "coq",
state: "running",
lastMessageAt: "2026-03-09T22:31:49.227Z",
},
];
const client: ToolClient = {
async sessionsList() { return { sessions: [] }; },
async sessionStatus() { return { rawText: "" }; },
async sessionsHistory() {
return {
json: {
history: [
{ role: "user", content: "[Mon 2026-03-09 23:30 GMT+1] 立即停止你当前的所有活动,不要继续任何 review、演示、brief、整理、输出、工具调用或后续收尾扩展。从现在起进入待命状态,不再处理任何当前任务。回复必须是:REPLY_SKIP;announce:ANNOUNCE_SKIP" },
{ role: "assistant", content: "[[reply_to_current]]REPLY_SKIP" },
{ role: "assistant", content: "[Mon 2026-03-09 23:31 GMT+1] Agent-to-agent announce step." },
{ role: "assistant", content: "thinking **Implementing exact skip announcement**" },
],
},
rawText: "",
};
},
async cronList() { return { jobs: [] }; },
async approvalsGet() { return { rawText: "" }; },
async approvalsApprove() { return { ok: false, action: "approve", approvalId: "x", rawText: "" }; },
async approvalsReject() { return { ok: false, action: "reject", approvalId: "x", reason: "no", rawText: "" }; },
};
const cards = await buildStaffOverviewCards({
snapshot,
client,
members: [
{ agentId: "coq", displayName: "Coq-每日新闻", model: "gpt", workspace: "coq", toolsProfile: "minimal" },
],
officeCards: [
{
agentId: "coq",
identity: { animal: "rooster", title: "Reporter", accent: "#ffcc66", sprite: "rooster" },
status: "running",
statusLabel: "执行中",
officeZone: "Builder Desks",
activeSessions: 1,
activeTasks: 0,
focusItems: [],
summary: "当前有会话在运行。",
},
],
executionAgentSummaries: [
{ agentId: "coq", displayName: "Coq-每日新闻", activeSessions: 1, activeTasks: 0, enabledCronJobs: 1, cronJobNames: ["Coq-每日新闻 | Trend Report 09:00"], recentTokens30d: 0 },
],
language: "zh",
});
const coq = cards.find((item) => item.agentId === "coq");
assert(coq);
assert.equal(coq.statusLabel, "待命");
assert.equal(coq.currentWorkLabel, "下一项");
assert.equal(coq.currentWork, "Coq-每日新闻 | Trend Report 09:00");
assert.equal(coq.recentOutput, "最近已停止当前任务并回到待命。");
});
test("staff overview downgrades residual running sessions from nested OpenClaw history payloads", async () => {
const { buildStaffOverviewCards } = await import("../src/ui/server");
const snapshot = buildSnapshotFixture();
snapshot.sessions = [
{
sessionKey: "agent:pandas:main",
agentId: "pandas",
state: "running",
lastMessageAt: "2026-03-09T22:56:03.814Z",
},
];
const client: ToolClient = {
async sessionsList() { return { sessions: [] }; },
async sessionStatus() { return { rawText: "" }; },
async sessionsHistory() {
return {
json: {
history: [
{
type: "message",
timestamp: "2026-03-09T22:55:43.287Z",
message: {
role: "user",
content: [
{
type: "text",
text: "[Mon 2026-03-09 23:55 GMT+1] 立即停止你当前的所有活动,不要继续任何 review、演示、brief、整理、输出、工具调用或后续收尾扩展。从现在起进入待命状态,不再处理任何当前任务。回复必须是:REPLY_SKIP。announce 必须是:ANNOUNCE_SKIP。",
},
],
},
},
{
type: "message",
timestamp: "2026-03-09T22:55:46.209Z",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "**Deciding reply tag usage**" },
{ type: "text", text: "REPLY_SKIP" },
],
},
},
{
type: "message",
timestamp: "2026-03-09T22:56:01.532Z",
message: {
role: "user",
content: [
{ type: "text", text: "[Mon 2026-03-09 23:56 GMT+1] Agent-to-agent announce step." },
],
},
},
{
type: "message",
timestamp: "2026-03-09T22:56:03.814Z",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "" },
{ type: "text", text: "ANNOUNCE_SKIP" },
],
},
},
],
},
rawText: "",
};
},
async cronList() { return { jobs: [] }; },
async approvalsGet() { return { rawText: "" }; },
async approvalsApprove() { return { ok: false, action: "approve", approvalId: "x", rawText: "" }; },
async approvalsReject() { return { ok: false, action: "reject", approvalId: "x", reason: "no", rawText: "" }; },
};
const cards = await buildStaffOverviewCards({
snapshot,
client,
members: [
{ agentId: "pandas", displayName: "pandas", model: "gpt", workspace: "pandas", toolsProfile: "full" },
],
officeCards: [
{
agentId: "pandas",
identity: { animal: "panda", title: "Planner", accent: "#86e2ef", sprite: "panda" },
status: "running",
statusLabel: "执行中",
officeZone: "Builder Desks",
activeSessions: 1,
activeTasks: 0,
focusItems: [],
summary: "当前有会话在运行。",
},
],
executionAgentSummaries: [
{ agentId: "pandas", displayName: "pandas", activeSessions: 1, activeTasks: 0, enabledCronJobs: 0, cronJobNames: [], recentTokens30d: 0 },
],
language: "zh",
});
const pandas = cards.find((item) => item.agentId === "pandas");
assert(pandas);
assert.equal(pandas.statusLabel, "待命");
assert.equal(pandas.currentWorkLabel, "正在处理什么");
assert.equal(pandas.currentWork, "当前无实时任务");
assert.equal(pandas.recentOutput, "最近已停止当前任务并回到待命。");
});
function buildSnapshotFixture(): ReadModelSnapshot {
const now = new Date().toISOString();
return {
sessions: [],
statuses: [],
cronJobs: [],
approvals: [],
projects: {
updatedAt: now,
projects: [],
},
projectSummaries: [],
tasks: {
updatedAt: now,
agentBudgets: [],
tasks: [],
},
tasksSummary: {
projects: 0,
tasks: 0,
todo: 0,
inProgress: 0,
blocked: 0,
done: 0,
owners: 0,
artifacts: 0,
},
budgetSummary: {
total: 0,
ok: 0,
warn: 0,
over: 0,
evaluations: [],
},
generatedAt: now,
};
}
+221
View File
@@ -0,0 +1,221 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
test("office session presence parses nested session stores and second-based timestamps", async () => {
const home = await mkdtemp(join(tmpdir(), "control-center-office-presence-"));
const originalHome = process.env.OPENCLAW_HOME;
try {
const sessionsDir = join(home, "agents", "pandas", "sessions");
await mkdir(sessionsDir, { recursive: true });
const nowSeconds = Math.floor(Date.now() / 1000);
await writeFile(
join(sessionsDir, "sessions.json"),
JSON.stringify(
{
sessions: {
"agent:pandas:1": {
sessionId: "session-1",
updatedAt: nowSeconds,
state: "running",
},
"agent:pandas:2": {
sessionId: "session-2",
updatedAt: String(nowSeconds - 10),
active: true,
},
"agent:pandas:3": {
sessionId: "session-3",
updatedAt: nowSeconds,
state: "idle",
},
},
meta: {
generatedAt: nowSeconds,
},
},
null,
2,
),
"utf8",
);
process.env.OPENCLAW_HOME = home;
const { loadBestEffortOfficeSessionPresence } = await import("../src/runtime/office-session-presence");
const snapshot = await loadBestEffortOfficeSessionPresence();
assert.equal(snapshot.status, "connected");
assert.equal(snapshot.totalActiveSessions, 2);
assert.equal(snapshot.activeSessionsByAgent.get("pandas"), 2);
} finally {
if (originalHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = originalHome;
await rm(home, { recursive: true, force: true });
}
});
test("office session presence parses array-shaped stores with acp state", async () => {
const home = await mkdtemp(join(tmpdir(), "control-center-office-presence-array-"));
const originalHome = process.env.OPENCLAW_HOME;
try {
const sessionsDir = join(home, "agents", "otter", "sessions");
await mkdir(sessionsDir, { recursive: true });
const nowIso = new Date().toISOString();
await writeFile(
join(sessionsDir, "sessions.json"),
JSON.stringify(
[
{
sessionId: "session-a",
updatedAt: nowIso,
acp: {
state: "processing",
},
},
{
sessionId: "session-b",
updatedAt: nowIso,
acp: {
state: "completed",
},
},
],
null,
2,
),
"utf8",
);
process.env.OPENCLAW_HOME = home;
const { loadBestEffortOfficeSessionPresence } = await import("../src/runtime/office-session-presence");
const snapshot = await loadBestEffortOfficeSessionPresence();
assert.equal(snapshot.status, "connected");
assert.equal(snapshot.totalActiveSessions, 1);
assert.equal(snapshot.activeSessionsByAgent.get("otter"), 1);
} finally {
if (originalHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = originalHome;
await rm(home, { recursive: true, force: true });
}
});
test("office session presence auto-expands recency window when initial pass is all-zero", async () => {
const home = await mkdtemp(join(tmpdir(), "control-center-office-presence-fallback-"));
const originalHome = process.env.OPENCLAW_HOME;
try {
const sessionsDir = join(home, "agents", "main", "sessions");
await mkdir(sessionsDir, { recursive: true });
const updatedAtIso = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
await writeFile(
join(sessionsDir, "sessions.json"),
JSON.stringify(
{
sessions: {
"agent:main:1": {
sessionId: "session-1",
updatedAt: updatedAtIso,
},
},
},
null,
2,
),
"utf8",
);
process.env.OPENCLAW_HOME = home;
const { loadBestEffortOfficeSessionPresence } = await import("../src/runtime/office-session-presence");
const snapshot = await loadBestEffortOfficeSessionPresence();
assert.equal(snapshot.status, "connected");
assert.equal(snapshot.totalActiveSessions, 1);
assert.equal(snapshot.activeSessionsByAgent.get("main"), 1);
assert(snapshot.detail.includes("Window auto-expanded"), "Expected adaptive fallback detail in snapshot.");
} finally {
if (originalHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = originalHome;
await rm(home, { recursive: true, force: true });
}
});
test("office session presence filters to current configured agents when openclaw.json is present", async () => {
const home = await mkdtemp(join(tmpdir(), "control-center-office-presence-roster-"));
const originalHome = process.env.OPENCLAW_HOME;
try {
const pandasDir = join(home, "agents", "pandas", "sessions");
const legacyDir = join(home, "agents", "codex", "sessions");
await mkdir(pandasDir, { recursive: true });
await mkdir(legacyDir, { recursive: true });
const nowIso = new Date().toISOString();
await writeFile(
join(home, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{ id: "main", name: "main" },
{ id: "pandas", name: "pandas" },
],
},
},
null,
2,
),
"utf8",
);
await writeFile(
join(pandasDir, "sessions.json"),
JSON.stringify(
{
sessions: {
"agent:pandas:1": {
sessionId: "session-1",
updatedAt: nowIso,
state: "running",
},
},
},
null,
2,
),
"utf8",
);
await writeFile(
join(legacyDir, "sessions.json"),
JSON.stringify(
{
sessions: {
"agent:codex:1": {
sessionId: "session-2",
updatedAt: nowIso,
state: "running",
},
},
},
null,
2,
),
"utf8",
);
process.env.OPENCLAW_HOME = home;
const { loadBestEffortOfficeSessionPresence } = await import("../src/runtime/office-session-presence");
const snapshot = await loadBestEffortOfficeSessionPresence();
assert.equal(snapshot.status, "connected");
assert.equal(snapshot.totalActiveSessions, 1);
assert.equal(snapshot.activeSessionsByAgent.get("pandas"), 1);
assert.equal(snapshot.activeSessionsByAgent.has("codex"), false);
} finally {
if (originalHome === undefined) delete process.env.OPENCLAW_HOME;
else process.env.OPENCLAW_HOME = originalHome;
await rm(home, { recursive: true, force: true });
}
});
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mapSessionsListToSummaries } from "../src/mappers/openclaw-mappers";
test("session list mapping keeps explicit runtime issue states instead of flattening them to active/idle", () => {
const mapped = mapSessionsListToSummaries({
sessions: [
{ sessionKey: "sess-blocked", active: true, state: "blocked" },
{ sessionKey: "sess-error", active: false, state: "failed" },
{ sessionKey: "sess-wait", active: true, state: "waiting_approval" },
{ sessionKey: "sess-running", active: true, state: "busy" },
{ sessionKey: "sess-idle", active: true, state: "done" },
{ sessionKey: "sess-fallback", active: true },
],
});
const byKey = new Map(mapped.map((item) => [item.sessionKey, item.state]));
assert.equal(byKey.get("sess-blocked"), "blocked");
assert.equal(byKey.get("sess-error"), "error");
assert.equal(byKey.get("sess-wait"), "waiting_approval");
assert.equal(byKey.get("sess-running"), "running");
assert.equal(byKey.get("sess-idle"), "idle");
assert.equal(byKey.get("sess-fallback"), "running");
});
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
const ROOT = process.cwd();
const MACOS_HOME_PATH_PATTERN = /\/Users\/[^/\s]+\//;
const EMBEDDED_LOCAL_TOKEN_PATTERN = /LOCAL_API_TOKEN\s*[:=]\s*["'][^"']{8,}["']/;
const PUBLIC_FILES = [
"README.md",
"docs/ARCHITECTURE.md",
"docs/PROGRESS.md",
"docs/mission.runbook.md",
"ecosystem.config.cjs",
"mission.config.json",
"scripts/run_verifier.sh",
"scripts/mc_dod_evaluator.py",
"scripts/mc_rollback_plan.py",
"workflows/pandas_v3_autopilot.lobster",
];
test("repo includes baseline open-source release metadata", () => {
assert(existsSync(path.join(ROOT, ".gitignore")), "Expected .gitignore to exist.");
assert(existsSync(path.join(ROOT, "LICENSE")), "Expected LICENSE to exist.");
const ignore = readFileSync(path.join(ROOT, ".gitignore"), "utf8");
assert.match(ignore, /(^|\n)node_modules\/(\n|$)/);
assert.match(ignore, /(^|\n)dist\/(\n|$)/);
assert.match(ignore, /(^|\n)runtime\/(\n|$)/);
const license = readFileSync(path.join(ROOT, "LICENSE"), "utf8");
assert.match(license, /MIT License/);
const pkg = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
assert.notEqual(pkg.private, true);
assert.equal(pkg.license, "MIT");
});
test("public-facing files do not embed machine-specific paths or local token values", () => {
for (const relativePath of PUBLIC_FILES) {
const content = readFileSync(path.join(ROOT, relativePath), "utf8");
assert.equal(MACOS_HOME_PATH_PATTERN.test(content), false, `${relativePath} still contains a macOS home path.`);
assert.equal(
EMBEDDED_LOCAL_TOKEN_PATTERN.test(content),
false,
`${relativePath} still contains an embedded LOCAL_API_TOKEN value.`,
);
}
});
test("gateway URL can be overridden from env for non-local installations", () => {
const output = execFileSync(
process.execPath,
[
"--import",
"tsx",
"--eval",
"process.env.GATEWAY_URL='ws://example.invalid:9999'; const mod = require('./src/config.ts'); process.stdout.write(String(mod.GATEWAY_URL));",
],
{
cwd: ROOT,
encoding: "utf8",
},
).trim();
assert.equal(output, "ws://example.invalid:9999");
});
+240
View File
@@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { spawn } from "node:child_process";
import test from "node:test";
const PROJECT_ROOT = process.cwd();
test("periodic snapshot writes timestamped health/evidence artifacts and latest pointers", async () => {
const runtimeDir = join(PROJECT_ROOT, "runtime", `zzzz-p1-periodic-${Date.now()}`);
const evidenceDir = join(runtimeDir, "evidence");
await mkdir(evidenceDir, { recursive: true });
await writeFile(
join(evidenceDir, "latest.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
itemId: "P1-2",
completed: "Periodic snapshot baseline",
filesChanged: ["scripts/periodic-snapshot.ts"],
verification: "test",
visibleMarker: "runtime/evidence/latest.json",
},
null,
2,
)}\n`,
"utf8",
);
try {
const run = await runNodeScript(["scripts/periodic-snapshot.ts", runtimeDir], process.env);
assert.equal(run.code, 0, `periodic snapshot failed\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`);
const healthPointer = JSON.parse(await readFile(join(runtimeDir, "health", "latest.pointer.json"), "utf8")) as {
latest: string;
};
const evidencePointer = JSON.parse(
await readFile(join(runtimeDir, "evidence", "latest.pointer.json"), "utf8"),
) as {
latest: string;
};
await stat(join(runtimeDir, "health", "latest.json"));
await stat(healthPointer.latest);
await stat(evidencePointer.latest);
} finally {
await rm(runtimeDir, { recursive: true, force: true });
}
});
test("evidence reporter blocks outbound payload when evidence is incomplete", async () => {
const tempDir = join(PROJECT_ROOT, "runtime", `zzzz-p1-reporter-${Date.now()}`);
const evidencePath = join(tempDir, "evidence.json");
const outboundDir = join(tempDir, "outbound");
await mkdir(tempDir, { recursive: true });
await writeFile(evidencePath, `${JSON.stringify({ itemId: "P1-3" }, null, 2)}\n`, "utf8");
try {
const blocked = await runNodeScript(["scripts/evidence-reporter.ts", evidencePath, outboundDir], process.env);
assert.equal(blocked.code, 1);
assert(blocked.stderr.includes("REPORT_BLOCKED_EVIDENCE"));
await writeFile(
evidencePath,
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
itemId: "P1-3",
completed: "Evidence-only reporter wiring",
filesChanged: ["scripts/evidence-reporter.ts", "test/p1-control-scripts.test.ts"],
verification: "reporter test",
visibleMarker: "runtime/evidence/outbound/latest.json",
},
null,
2,
)}\n`,
"utf8",
);
const ok = await runNodeScript(["scripts/evidence-reporter.ts", evidencePath, outboundDir], process.env);
assert.equal(ok.code, 0, `evidence reporter failed\nstdout:\n${ok.stdout}\nstderr:\n${ok.stderr}`);
await stat(join(outboundDir, "latest.json"));
const payload = JSON.parse(await readFile(join(outboundDir, "latest.json"), "utf8")) as {
progress?: { itemId?: string };
};
assert.equal(payload.progress?.itemId, "P1-3");
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test("watchdog orchestrator fails on lock ownership conflict", async () => {
const tempDir = join(PROJECT_ROOT, "runtime", `zzzz-p1-watchdog-conflict-${Date.now()}`);
const lockPath = join(tempDir, "run.lock");
const evidencePath = join(tempDir, "evidence.json");
const recoveryPath = join(tempDir, "recovery.json");
const reportPath = join(tempDir, "watchdog.json");
await mkdir(tempDir, { recursive: true });
await writeFile(
evidencePath,
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
itemId: "P1-3",
completed: "ready",
filesChanged: ["x"],
verification: "ok",
visibleMarker: "ok",
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
lockPath,
`${JSON.stringify(
{
runId: "lock-1",
owner: "other-owner",
startedAt: new Date(Date.now() - 60_000).toISOString(),
updatedAt: new Date().toISOString(),
leaseUntil: new Date(Date.now() + 10 * 60_000).toISOString(),
fencingToken: 1,
},
null,
2,
)}\n`,
"utf8",
);
try {
const run = await runNodeScript(
["scripts/watchdog-orchestrator.ts", lockPath, evidencePath, recoveryPath, reportPath],
{
...process.env,
RUN_OWNER: "pandas-owner",
STALL_EVIDENCE_MAX_AGE_MS: "31536000000",
STALL_LOCK_MAX_AGE_MS: "31536000000",
},
);
assert.equal(run.code, 1);
assert(run.stderr.includes("WATCHDOG_LOCK_CONFLICT"));
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test("watchdog orchestrator renews owner lock and runs heal chain", async () => {
const tempDir = join(PROJECT_ROOT, "runtime", `zzzz-p1-watchdog-ok-${Date.now()}`);
const lockPath = join(tempDir, "run.lock");
const evidencePath = join(tempDir, "evidence.json");
const recoveryPath = join(tempDir, "recovery.json");
const reportPath = join(tempDir, "watchdog.json");
await mkdir(tempDir, { recursive: true });
await writeFile(
evidencePath,
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
itemId: "P1-3",
completed: "ready",
filesChanged: ["x"],
verification: "ok",
visibleMarker: "ok",
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
lockPath,
`${JSON.stringify(
{
runId: "lock-2",
owner: "pandas-owner",
startedAt: new Date(Date.now() - 60_000).toISOString(),
updatedAt: new Date().toISOString(),
leaseUntil: new Date(Date.now() + 10 * 60_000).toISOString(),
fencingToken: 7,
},
null,
2,
)}\n`,
"utf8",
);
try {
const run = await runNodeScript(
["scripts/watchdog-orchestrator.ts", lockPath, evidencePath, recoveryPath, reportPath],
{
...process.env,
RUN_OWNER: "pandas-owner",
STALL_EVIDENCE_MAX_AGE_MS: "31536000000",
STALL_LOCK_MAX_AGE_MS: "31536000000",
},
);
assert.equal(run.code, 0, `watchdog failed\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`);
assert(run.stdout.includes("WATCHDOG_OK"));
const report = JSON.parse(await readFile(reportPath, "utf8")) as {
outcome?: string;
lockAction?: string;
};
assert.equal(report.outcome, "healthy");
assert.equal(report.lockAction, "renew");
const lock = JSON.parse(await readFile(lockPath, "utf8")) as { owner?: string };
assert.equal(lock.owner, "pandas-owner");
await stat(recoveryPath);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
async function runNodeScript(
args: string[],
env: NodeJS.ProcessEnv,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, ["--import", "tsx", ...args.map((value) => resolve(PROJECT_ROOT, value))], {
cwd: PROJECT_ROOT,
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", rejectPromise);
child.on("close", (code) => {
resolvePromise({ code, stdout, stderr });
});
});
}
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const RUNTIME_DIR = join(process.cwd(), "runtime");
const EXPORTS_DIR = join(RUNTIME_DIR, "exports");
test("replay index includes runtime export bundles", async () => {
const { loadReplayIndex } = await import("../src/runtime/replay-index");
await mkdir(EXPORTS_DIR, { recursive: true });
const fileName = `zzzz-phase11-test-${Date.now()}.json`;
const filePath = join(EXPORTS_DIR, fileName);
const fixture = {
exportedAt: new Date().toISOString(),
snapshotGeneratedAt: new Date().toISOString(),
requestId: "phase11-replay-test",
projects: { projects: [{ projectId: "p1" }] },
tasks: { tasks: [{ taskId: "t1", projectId: "p1" }] },
sessions: [{ sessionKey: "s1" }],
exceptionsFeed: { items: [{ code: "X" }] },
};
await writeFile(filePath, `${JSON.stringify(fixture, null, 2)}\n`, "utf8");
try {
const replay = await loadReplayIndex({ exportLimit: 200 });
assert(Array.isArray(replay.exportBundles));
assert(replay.exportBundles.some((entry) => entry.fileName === fileName));
} finally {
await rm(filePath, { force: true });
}
});
+256
View File
@@ -0,0 +1,256 @@
import assert from "node:assert/strict";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
import type { CommanderExceptionsFeed } from "../src/types";
const RUNTIME_DIR = join(process.cwd(), "runtime");
const EXPORTS_DIR = join(RUNTIME_DIR, "exports");
const TIMELINE_LOG_PATH = join(RUNTIME_DIR, "timeline.log");
test("replay index applies optional from/to filters", async () => {
const { loadReplayIndex } = await import("../src/runtime/replay-index");
await mkdir(EXPORTS_DIR, { recursive: true });
const now = Date.now();
const outsideAt = new Date(now - 6 * 60 * 60 * 1000).toISOString();
const insideAt = new Date(now - 10 * 60 * 1000).toISOString();
const from = new Date(now - 30 * 60 * 1000).toISOString();
const to = new Date(now + 60 * 1000).toISOString();
const outsideFile = `zzzz-phase13-outside-${Date.now()}.json`;
const insideFile = `zzzz-phase13-inside-${Date.now()}.json`;
const outsidePath = join(EXPORTS_DIR, outsideFile);
const insidePath = join(EXPORTS_DIR, insideFile);
await writeFile(
outsidePath,
`${JSON.stringify({ exportedAt: outsideAt, projects: { projects: [] }, tasks: { tasks: [] }, sessions: [], exceptionsFeed: { items: [] } }, null, 2)}\n`,
"utf8",
);
await writeFile(
insidePath,
`${JSON.stringify({ exportedAt: insideAt, projects: { projects: [] }, tasks: { tasks: [] }, sessions: [], exceptionsFeed: { items: [] } }, null, 2)}\n`,
"utf8",
);
let timelineExisted = true;
let timelineBefore = "";
try {
timelineBefore = await readFile(TIMELINE_LOG_PATH, "utf8");
} catch {
timelineExisted = false;
}
const markerOutside = `phase13-outside-${Date.now()}`;
const markerInside = `phase13-inside-${Date.now()}`;
await writeFile(
TIMELINE_LOG_PATH,
`${timelineBefore}${timelineBefore.endsWith("\n") || timelineBefore === "" ? "" : "\n"}${outsideAt} | ${markerOutside}\n${insideAt} | ${markerInside}\n`,
"utf8",
);
try {
const replay = await loadReplayIndex({
timelineLimit: 400,
digestLimit: 200,
exportLimit: 200,
from,
to,
});
assert.equal(replay.window?.from, from);
assert.equal(replay.window?.to, to);
assert(replay.exportBundles.some((entry) => entry.fileName === insideFile));
assert(!replay.exportBundles.some((entry) => entry.fileName === outsideFile));
assert(replay.timeline.entries.some((entry) => entry.summary.includes(markerInside)));
assert(!replay.timeline.entries.some((entry) => entry.summary.includes(markerOutside)));
assert.equal(replay.stats.timeline.total, replay.timeline.totalLines);
assert.equal(replay.stats.timeline.returned, replay.timeline.entries.length);
assert.equal(replay.stats.exportBundles.returned, replay.exportBundles.length);
assert(replay.stats.timeline.filteredOut >= 1);
assert(replay.stats.exportBundles.filteredOut >= 1);
assert(replay.stats.timeline.latencyMs >= 0);
assert(replay.stats.digests.latencyMs >= 0);
assert(replay.stats.exportSnapshots.latencyMs >= 0);
assert(replay.stats.exportBundles.latencyMs >= 0);
assert(replay.stats.timeline.latencyBucketsMs.p50 >= 0);
assert(replay.stats.timeline.latencyBucketsMs.p95 >= replay.stats.timeline.latencyBucketsMs.p50);
assert(replay.stats.digests.latencyBucketsMs.p50 >= 0);
assert(replay.stats.digests.latencyBucketsMs.p95 >= replay.stats.digests.latencyBucketsMs.p50);
assert(replay.stats.exportSnapshots.latencyBucketsMs.p50 >= 0);
assert(
replay.stats.exportSnapshots.latencyBucketsMs.p95 >=
replay.stats.exportSnapshots.latencyBucketsMs.p50,
);
assert(replay.stats.exportBundles.latencyBucketsMs.p50 >= 0);
assert(
replay.stats.exportBundles.latencyBucketsMs.p95 >= replay.stats.exportBundles.latencyBucketsMs.p50,
);
assert(replay.stats.total.latencyBucketsMs.p50 >= 0);
assert(replay.stats.total.latencyBucketsMs.p95 >= replay.stats.total.latencyBucketsMs.p50);
assert(replay.stats.timeline.totalSizeBytes >= replay.stats.timeline.returnedSizeBytes);
assert(replay.stats.digests.totalSizeBytes >= replay.stats.digests.returnedSizeBytes);
assert(replay.stats.exportSnapshots.totalSizeBytes >= replay.stats.exportSnapshots.returnedSizeBytes);
assert(replay.stats.exportBundles.totalSizeBytes >= replay.stats.exportBundles.returnedSizeBytes);
assert.equal(
replay.stats.total.returned,
replay.stats.timeline.returned +
replay.stats.digests.returned +
replay.stats.exportSnapshots.returned +
replay.stats.exportBundles.returned,
);
assert.equal(
replay.stats.total.returnedSizeBytes,
replay.stats.timeline.returnedSizeBytes +
replay.stats.digests.returnedSizeBytes +
replay.stats.exportSnapshots.returnedSizeBytes +
replay.stats.exportBundles.returnedSizeBytes,
);
assert.equal(
replay.stats.total.totalSizeBytes,
replay.stats.timeline.totalSizeBytes +
replay.stats.digests.totalSizeBytes +
replay.stats.exportSnapshots.totalSizeBytes +
replay.stats.exportBundles.totalSizeBytes,
);
} finally {
await rm(outsidePath, { force: true });
await rm(insidePath, { force: true });
if (timelineExisted) {
await writeFile(TIMELINE_LOG_PATH, timelineBefore, "utf8");
} else {
await rm(TIMELINE_LOG_PATH, { force: true });
}
}
});
test("action queue acknowledgements support ttl and expire as inactive", async () => {
const { ACKS_PATH, acknowledgeActionQueueItem, actionQueueItemId, buildNotificationCenter } =
await import("../src/runtime/notification-center");
const feed: CommanderExceptionsFeed = {
generatedAt: new Date().toISOString(),
items: [
{
level: "action-required",
code: "TASK_DUE",
source: "task",
sourceId: "task-1",
message: "task due now",
route: "action-queue",
occurredAt: new Date().toISOString(),
},
],
counts: {
info: 0,
warn: 0,
actionRequired: 1,
},
};
const itemId = actionQueueItemId(feed.items[0]);
const center = buildNotificationCenter(feed, {
acks: [],
updatedAt: new Date().toISOString(),
});
let acksExisted = true;
let acksBefore = "";
try {
acksBefore = await readFile(ACKS_PATH, "utf8");
} catch {
acksExisted = false;
}
try {
const result = await acknowledgeActionQueueItem({ itemId, ttlMinutes: 5 }, center);
assert.equal(result.ack.itemId, itemId);
assert.equal(typeof result.ack.expiresAt, "string");
assert(Date.parse(result.ack.expiresAt ?? "") > Date.parse(result.ack.ackedAt));
} finally {
if (acksExisted) {
await writeFile(ACKS_PATH, acksBefore, "utf8");
} else {
await rm(ACKS_PATH, { force: true });
}
}
const futureAckCenter = buildNotificationCenter(feed, {
acks: [
{
itemId,
ackedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
],
updatedAt: new Date().toISOString(),
});
assert.equal(futureAckCenter.queue[0]?.acknowledged, true);
assert.equal(typeof futureAckCenter.queue[0]?.ackExpiresAt, "string");
const expiredAckCenter = buildNotificationCenter(feed, {
acks: [
{
itemId,
ackedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() - 60_000).toISOString(),
},
],
updatedAt: new Date().toISOString(),
});
assert.equal(expiredAckCenter.queue[0]?.acknowledged, false);
});
test("stale ack prune preview returns counts only and does not mutate ack store", async () => {
const { ACKS_PATH, previewStaleAcksPrune } = await import("../src/runtime/notification-center");
let acksExisted = true;
let acksBefore = "";
try {
acksBefore = await readFile(ACKS_PATH, "utf8");
} catch {
acksExisted = false;
}
const nowMs = Date.now();
const expiredItemId = `phase15-preview-expired-${nowMs}`;
const activeItemId = `phase15-preview-active-${nowMs}`;
const fixture = {
acks: [
{
itemId: expiredItemId,
ackedAt: new Date(nowMs - 2 * 60_000).toISOString(),
expiresAt: new Date(nowMs - 60_000).toISOString(),
},
{
itemId: activeItemId,
ackedAt: new Date(nowMs - 2 * 60_000).toISOString(),
expiresAt: new Date(nowMs + 5 * 60_000).toISOString(),
},
],
updatedAt: new Date(nowMs).toISOString(),
};
await writeFile(ACKS_PATH, `${JSON.stringify(fixture, null, 2)}\n`, "utf8");
try {
const preview = await previewStaleAcksPrune({ nowMs });
assert.equal(preview.dryRun, true);
assert.equal(preview.before, 2);
assert.equal(preview.removed, 1);
assert.equal(preview.after, 1);
assert.equal(Object.prototype.hasOwnProperty.call(preview, "removedItemIds"), false);
const afterRaw = await readFile(ACKS_PATH, "utf8");
const after = JSON.parse(afterRaw) as { acks?: Array<{ itemId?: string }> };
const itemIds = (after.acks ?? []).map((item) => item.itemId).filter(Boolean);
assert(itemIds.includes(expiredItemId), "Preview must not mutate runtime/acks.json.");
assert(itemIds.includes(activeItemId));
} finally {
if (acksExisted) {
await writeFile(ACKS_PATH, acksBefore, "utf8");
} else {
await rm(ACKS_PATH, { force: true });
}
}
});
+314
View File
@@ -0,0 +1,314 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { readFile, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const PROJECT_ROOT = process.cwd();
const RUNTIME_DIR = join(PROJECT_ROOT, "runtime");
const OPERATION_AUDIT_LOG = join(RUNTIME_DIR, "operation-audit.log");
const ACKS_PATH = join(RUNTIME_DIR, "acks.json");
const TEST_LOCAL_TOKEN = "phase10-test-token";
test("done-checklist route logic builds readiness payload", async () => {
const [
{ buildDoneChecklist },
{ validateExportBundleDryRun },
{ evaluateLocalTokenGate },
{ evaluateImportMutationGuard },
{ buildApiDocs },
{ LOCAL_TOKEN_AUTH_REQUIRED },
] =
await Promise.all([
import("../src/runtime/done-checklist"),
import("../src/runtime/import-dry-run"),
import("../src/runtime/local-token-auth"),
import("../src/runtime/import-live"),
import("../src/runtime/api-docs"),
import("../src/config"),
]);
const snapshot = await loadSnapshotFixture();
const checklist = await buildDoneChecklist(snapshot);
assert.equal(typeof checklist.readiness.overall, "number");
assert(Array.isArray(checklist.items));
assert(checklist.items.length > 0);
const routeCoverageItem = checklist.items.find((item) => item.id === "obs_route_coverage");
assert(routeCoverageItem, "Expected obs_route_coverage checklist item.");
assert(routeCoverageItem.detail.includes("/api/action-queue/acks/prune-preview"));
const uiBindEnvItem = checklist.items.find((item) => item.id === "obs_ui_bind_env_only");
assert(uiBindEnvItem, "Expected obs_ui_bind_env_only checklist item.");
assert.equal(uiBindEnvItem.status, "pass");
assert(uiBindEnvItem.detail.includes("environment-only"));
const actionQueueSignalItem = checklist.items.find((item) => item.id === "collab_action_queue_signal");
assert(actionQueueSignalItem, "Expected collab_action_queue_signal checklist item.");
assert.equal(actionQueueSignalItem.status, "pass");
const localTokenGateItem = checklist.items.find((item) => item.id === "sec_local_token_gate");
assert(localTokenGateItem, "Expected sec_local_token_gate checklist item.");
assert.equal(localTokenGateItem.status, LOCAL_TOKEN_AUTH_REQUIRED ? "pass" : "warn");
const docs = buildApiDocs();
assert(docs.routes.some((route) => route.path === "/api/commander/exceptions"));
assert(docs.routes.some((route) => route.path === "/api/action-queue"));
assert(docs.routes.some((route) => route.path === "/api/action-queue/acks/prune-preview"));
assert(docs.routes.some((route) => route.path === "/api/tasks/heartbeat"));
assert(docs.routes.some((route) => route.path === "/api/usage-cost"));
// Route-level guard behavior used by /api/import/dry-run
const blocked = evaluateLocalTokenGate({
gateRequired: true,
configuredToken: TEST_LOCAL_TOKEN,
routeLabel: "/api/import/live",
});
assert.equal(blocked.ok, false);
assert.equal(blocked.statusCode, 401);
const allowed = evaluateLocalTokenGate({
gateRequired: true,
configuredToken: TEST_LOCAL_TOKEN,
providedToken: TEST_LOCAL_TOKEN,
routeLabel: "/api/import/live",
});
assert.equal(allowed.ok, true);
assert.equal(allowed.statusCode, 200);
const importGateBlocked = evaluateImportMutationGuard({
mutationEnabled: false,
mutationDryRunDefault: false,
readonlyMode: false,
routeLabel: "/api/import/live",
});
assert.equal(importGateBlocked.ok, false);
assert.equal(importGateBlocked.statusCode, 403);
const importGateDryRun = evaluateImportMutationGuard({
mutationEnabled: true,
mutationDryRunDefault: false,
readonlyMode: true,
routeLabel: "/api/import/live",
requestedDryRun: true,
});
assert.equal(importGateDryRun.ok, true);
assert.equal(importGateDryRun.mode, "dry_run");
const validBundle = {
ok: true,
schemaVersion: "phase-9",
source: "api",
exportedAt: "2026-03-01T12:00:00.000Z",
snapshotGeneratedAt: "2026-03-01T12:00:00.000Z",
sessions: [{ sessionKey: "s-1", state: "running" }],
projects: {
updatedAt: "2026-03-01T12:00:00.000Z",
projects: [{ projectId: "p-1", title: "P1", status: "active", owner: "owner", updatedAt: "2026-03-01T12:00:00.000Z" }],
},
tasks: {
updatedAt: "2026-03-01T12:00:00.000Z",
agentBudgets: [],
tasks: [{ projectId: "p-1", taskId: "t-1", status: "todo", title: "T1", owner: "owner", definitionOfDone: [], artifacts: [], rollback: { strategy: "none", steps: [] }, sessionKeys: [], budget: {}, updatedAt: "2026-03-01T12:00:00.000Z" }],
},
budgets: {
policy: {},
summary: {},
issues: [],
},
exceptions: { counts: {} },
exceptionsFeed: { items: [], counts: {} },
};
const validation = validateExportBundleDryRun(validBundle, "payload.bundle");
assert.equal(validation.valid, true);
assert.equal(validation.issues.length, 0);
});
test("backup export command path writes bundle and operation audit entry", async () => {
const run = await runCommand("npm", ["run", "command:backup-export"], {
cwd: PROJECT_ROOT,
env: {
...process.env,
READONLY_MODE: "true",
APPROVAL_ACTIONS_ENABLED: "false",
APPROVAL_ACTIONS_DRY_RUN: "true",
LOCAL_TOKEN_AUTH_REQUIRED: "true",
LOCAL_API_TOKEN: TEST_LOCAL_TOKEN,
},
});
assert.equal(
run.code,
0,
`backup-export command failed\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
);
const auditEntries = await readOperationAuditEntries();
const commandEntry = [...auditEntries]
.reverse()
.find((entry) => entry.action === "backup_export" && entry.source === "command");
assert(commandEntry, "Expected backup_export command audit entry");
assert.equal(commandEntry.ok, true);
const metadata = asRecord(commandEntry.metadata);
assert.equal(typeof metadata?.path, "string");
const writtenPath = metadata?.path as string;
assert(writtenPath.includes("/runtime/exports/"));
assert(await fileExists(writtenPath), "Expected backup export file path from audit metadata to exist.");
});
test("acks-prune command removes expired records and writes audit entry", async () => {
let acksExisted = true;
let acksBefore = "";
try {
acksBefore = await readFile(ACKS_PATH, "utf8");
} catch {
acksExisted = false;
}
const now = Date.now();
const expiredItemId = `phase14-expired-${now}`;
const activeItemId = `phase14-active-${now}`;
const fixture = {
acks: [
{
itemId: expiredItemId,
ackedAt: new Date(now - 2 * 60_000).toISOString(),
expiresAt: new Date(now - 60_000).toISOString(),
},
{
itemId: activeItemId,
ackedAt: new Date(now - 2 * 60_000).toISOString(),
expiresAt: new Date(now + 5 * 60_000).toISOString(),
},
],
updatedAt: new Date(now).toISOString(),
};
await writeFile(ACKS_PATH, `${JSON.stringify(fixture, null, 2)}\n`, "utf8");
try {
const run = await runCommand("npm", ["run", "command:acks-prune"], {
cwd: PROJECT_ROOT,
env: {
...process.env,
READONLY_MODE: "true",
APPROVAL_ACTIONS_ENABLED: "false",
APPROVAL_ACTIONS_DRY_RUN: "true",
LOCAL_TOKEN_AUTH_REQUIRED: "true",
LOCAL_API_TOKEN: TEST_LOCAL_TOKEN,
},
});
assert.equal(
run.code,
0,
`acks-prune command failed\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
);
const raw = await readFile(ACKS_PATH, "utf8");
const parsed = JSON.parse(raw) as { acks?: Array<{ itemId?: string }> };
const itemIds = (parsed.acks ?? []).map((item) => item.itemId).filter(Boolean);
assert(!itemIds.includes(expiredItemId), "Expected expired ack to be pruned.");
assert(itemIds.includes(activeItemId), "Expected active ack to remain.");
const auditEntries = await readOperationAuditEntries();
const pruneEntry = [...auditEntries]
.reverse()
.find((entry) => entry.action === "ack_prune" && entry.source === "command");
assert(pruneEntry, "Expected ack_prune command audit entry");
assert.equal(pruneEntry.ok, true);
const metadata = asRecord(pruneEntry.metadata);
assert.equal(typeof metadata?.removed, "number");
assert((metadata?.removed as number) >= 1);
} finally {
if (acksExisted) {
await writeFile(ACKS_PATH, acksBefore, "utf8");
} else {
await rm(ACKS_PATH, { force: true });
}
}
});
async function loadSnapshotFixture(): Promise<Record<string, unknown>> {
const snapshotPath = join(RUNTIME_DIR, "last-snapshot.json");
try {
const raw = await readFile(snapshotPath, "utf8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
const now = new Date().toISOString();
return {
sessions: [],
statuses: [],
cronJobs: [],
approvals: [],
projects: { projects: [], updatedAt: now },
projectSummaries: [],
tasks: { tasks: [], agentBudgets: [], updatedAt: now },
tasksSummary: {
projects: 0,
tasks: 0,
todo: 0,
inProgress: 0,
blocked: 0,
done: 0,
owners: 0,
artifacts: 0,
},
budgetSummary: { total: 0, ok: 0, warn: 0, over: 0, evaluations: [] },
generatedAt: now,
};
}
}
async function readOperationAuditEntries(): Promise<Array<Record<string, unknown>>> {
try {
const raw = await readFile(OPERATION_AUDIT_LOG, "utf8");
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "")
.map((line) => JSON.parse(line) as Record<string, unknown>);
} catch {
return [];
}
}
async function runCommand(
cmd: string,
args: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
},
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => {
resolve({ code, stdout, stderr });
});
});
}
function asRecord(input: unknown): Record<string, unknown> | undefined {
return input !== null && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined;
}
async function fileExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
+261
View File
@@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ToolClient } from "../src/clients/tool-client";
import {
getSessionConversationDetail,
listSessionConversations,
} from "../src/runtime/session-conversations";
import type { SessionsHistoryRequest, SessionsHistoryResponse } from "../src/contracts/openclaw-tools";
import type { ReadModelSnapshot } from "../src/types";
class FakeToolClient implements ToolClient {
constructor(private readonly historyBySession = new Map<string, SessionsHistoryResponse>()) {}
async sessionsList() {
return { sessions: [] };
}
async sessionStatus() {
return { rawText: "" };
}
async sessionsHistory(request: SessionsHistoryRequest): Promise<SessionsHistoryResponse> {
return this.historyBySession.get(request.sessionKey) ?? { rawText: "" };
}
async cronList() {
return { jobs: [] };
}
async approvalsGet() {
return { rawText: "" };
}
async approvalsApprove() {
return { ok: false, action: "approve" as const, approvalId: "n/a", rawText: "" };
}
async approvalsReject() {
return { ok: false, action: "reject" as const, approvalId: "n/a", rawText: "" };
}
}
function makeSnapshot(sessionKeys: Array<{ sessionKey: string; agentId?: string; state?: "idle" | "running" | "blocked" | "waiting_approval" | "error"; lastMessageAt?: string }>): ReadModelSnapshot {
return {
sessions: sessionKeys.map((item) => ({
sessionKey: item.sessionKey,
agentId: item.agentId,
state: item.state ?? "idle",
lastMessageAt: item.lastMessageAt,
})),
statuses: [],
cronJobs: [],
approvals: [],
projects: { projects: [], updatedAt: "2026-03-07T10:00:00.000Z" },
projectSummaries: [],
tasks: { tasks: [], agentBudgets: [], updatedAt: "2026-03-07T10:00:00.000Z" },
tasksSummary: {
projects: 0,
tasks: 0,
todo: 0,
inProgress: 0,
blocked: 0,
done: 0,
owners: 0,
artifacts: 0,
},
budgetSummary: { total: 0, ok: 0, warn: 0, over: 0, evaluations: [] },
generatedAt: "2026-03-07T10:00:00.000Z",
};
}
test("run child session infers execution chain from session key when history is unavailable", async () => {
const snapshot = makeSnapshot([
{
sessionKey: "agent:main:cron:job-1:run:child-1",
agentId: "main",
state: "running",
lastMessageAt: "2026-03-07T10:01:00.000Z",
},
]);
const result = await listSessionConversations({
snapshot,
client: new FakeToolClient(),
filters: {},
page: 1,
pageSize: 10,
historyLimit: 10,
});
assert.equal(result.items.length, 1);
assert.equal(result.items[0].executionChain?.source, "session_key");
assert.equal(result.items[0].executionChain?.parentSessionKey, "agent:main:cron:job-1");
assert.equal(result.items[0].executionChain?.childSessionKey, "agent:main:cron:job-1:run:child-1");
assert.equal(result.items[0].executionChain?.accepted, true);
assert.equal(result.items[0].executionChain?.spawned, true);
assert.equal(result.items[0].executionChain?.stage, "running");
});
test("history execution events are parsed and preferred over session-key inference", async () => {
const sessionKey = "agent:main:cron:job-2";
const childSessionKey = "agent:main:cron:job-2:run:child-2";
const snapshot = makeSnapshot([
{
sessionKey,
agentId: "main",
state: "idle",
lastMessageAt: "2026-03-07T10:02:00.000Z",
},
]);
const client = new FakeToolClient(
new Map([
[
sessionKey,
{
json: {
history: [
{
event: "accepted",
timestamp: "2026-03-07T10:02:10.000Z",
message: "accepted parent session",
},
{
event: "sessions_spawn",
timestamp: "2026-03-07T10:02:20.000Z",
parentSessionKey: sessionKey,
childSessionKey,
message: "spawned child session",
},
],
},
rawText: "",
},
],
]),
);
const detail = await getSessionConversationDetail({
snapshot,
client,
sessionKey,
historyLimit: 20,
});
assert(detail);
assert.equal(detail.executionChain?.source, "history");
assert.equal(detail.executionChain?.accepted, true);
assert.equal(detail.executionChain?.spawned, true);
assert.equal(detail.executionChain?.parentSessionKey, sessionKey);
assert.equal(detail.executionChain?.childSessionKey, childSessionKey);
assert.equal(detail.executionChain?.acceptedAt, "2026-03-07T10:02:10.000Z");
assert.equal(detail.executionChain?.spawnedAt, "2026-03-07T10:02:20.000Z");
assert.deepEqual(detail.history.map((item) => item.kind), ["accepted", "spawn"]);
});
test("raw text history fallback still extracts accepted and spawn events", async () => {
const sessionKey = "agent:main:cron:job-3";
const childSessionKey = "agent:main:cron:job-3:run:child-3";
const snapshot = makeSnapshot([
{
sessionKey,
agentId: "main",
state: "idle",
},
]);
const client = new FakeToolClient(
new Map([
[
sessionKey,
{
rawText: [
"accepted request for timed job",
`sessions_spawn parent=${sessionKey} child=${childSessionKey}`,
].join("\n"),
},
],
]),
);
const detail = await getSessionConversationDetail({
snapshot,
client,
sessionKey,
historyLimit: 20,
});
assert(detail);
assert.deepEqual(detail.history.map((item) => item.kind), ["accepted", "spawn"]);
assert.equal(detail.executionChain?.source, "history");
assert.equal(detail.executionChain?.childSessionKey, childSessionKey);
});
test("nested message envelope preserves user and assistant roles", async () => {
const sessionKey = "agent:coq:main";
const snapshot = makeSnapshot([
{
sessionKey,
agentId: "coq",
state: "running",
lastMessageAt: "2026-03-09T22:56:10.656Z",
},
]);
const client = new FakeToolClient(
new Map([
[
sessionKey,
{
json: {
history: [
{
type: "message",
timestamp: "2026-03-09T22:55:46.585Z",
message: {
role: "user",
content: [
{
type: "text",
text: "[Mon 2026-03-09 23:55 GMT+1] 立即停止你当前的所有活动,不要继续任何 review、演示、brief、整理、输出、工具调用或后续收尾扩展。从现在起进入待命状态,不再处理任何当前任务。回复必须是:REPLY_SKIP。announce 必须是:ANNOUNCE_SKIP。",
},
],
},
},
{
type: "message",
timestamp: "2026-03-09T22:55:51.944Z",
message: {
role: "assistant",
content: [
{
type: "thinking",
thinking: "**Using direct reply only**",
},
{
type: "text",
text: "REPLY_SKIP",
},
],
},
},
],
},
rawText: "",
},
],
]),
);
const detail = await getSessionConversationDetail({
snapshot,
client,
sessionKey,
historyLimit: 20,
});
assert(detail);
assert.equal(detail.history.length, 2);
assert.equal(detail.history[0].role, "user");
assert.match(detail.history[0].content, /立即停止你当前的所有活动/);
assert.equal(detail.history[1].role, "assistant");
assert.match(detail.history[1].content, /REPLY_SKIP/);
});
+200
View File
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import test from "node:test";
import type { ProjectTask, TaskStoreSnapshot } from "../src/types";
import { TASKS_PATH } from "../src/runtime/task-store";
import {
TASK_HEARTBEAT_LOG_PATH,
readTaskHeartbeatRuns,
runTaskHeartbeat,
selectHeartbeatTasks,
} from "../src/runtime/task-heartbeat";
test("selectHeartbeatTasks picks assigned todo tasks by due/age priority", () => {
const store: TaskStoreSnapshot = {
tasks: [
buildTask({
projectId: "p1",
taskId: "late-unassigned",
owner: "unassigned",
dueAt: "2026-03-01T10:00:00.000Z",
}),
buildTask({
projectId: "p1",
taskId: "first",
owner: "alex",
dueAt: "2026-03-01T10:00:00.000Z",
updatedAt: "2026-02-28T10:00:00.000Z",
}),
buildTask({
projectId: "p1",
taskId: "second",
owner: "sam",
dueAt: "2026-03-03T10:00:00.000Z",
updatedAt: "2026-02-27T10:00:00.000Z",
}),
buildTask({
projectId: "p1",
taskId: "already-running",
owner: "sam",
status: "in_progress",
}),
buildTask({
projectId: "p1",
taskId: "third-no-due",
owner: "zoe",
}),
],
agentBudgets: [],
updatedAt: "2026-03-01T00:00:00.000Z",
};
const selected = selectHeartbeatTasks(store, 2);
assert.equal(selected.length, 2);
assert.equal(selected[0].taskId, "first");
assert.equal(selected[1].taskId, "second");
});
test("runTaskHeartbeat dry-run records selection without mutating task store", async () => {
const tasksBefore = await readOptionalFile(TASKS_PATH);
const logBefore = await readOptionalFile(TASK_HEARTBEAT_LOG_PATH);
await writeTaskStoreFixture([
buildTask({ projectId: "p1", taskId: "todo-1", owner: "alex" }),
buildTask({ projectId: "p1", taskId: "todo-2", owner: "sam" }),
]);
try {
const result = await runTaskHeartbeat({
gate: {
enabled: true,
dryRun: true,
maxTasksPerRun: 1,
localTokenAuthRequired: true,
localTokenConfigured: false,
},
});
assert.equal(result.ok, true);
assert.equal(result.mode, "dry_run");
assert.equal(result.selected, 1);
assert.equal(result.executed, 0);
const raw = await readFile(TASKS_PATH, "utf8");
const parsed = JSON.parse(raw) as TaskStoreSnapshot;
const statuses = parsed.tasks.map((task) => task.status);
assert.deepEqual(statuses, ["todo", "todo"]);
} finally {
await restoreOptionalFile(TASKS_PATH, tasksBefore);
await restoreOptionalFile(TASK_HEARTBEAT_LOG_PATH, logBefore);
}
});
test("runTaskHeartbeat live mode moves selected assigned todo tasks to in_progress", async () => {
const tasksBefore = await readOptionalFile(TASKS_PATH);
const logBefore = await readOptionalFile(TASK_HEARTBEAT_LOG_PATH);
await writeTaskStoreFixture([
buildTask({
projectId: "p-live",
taskId: "due-fast",
owner: "alex",
dueAt: "2026-03-01T09:00:00.000Z",
updatedAt: "2026-02-25T00:00:00.000Z",
}),
buildTask({ projectId: "p-live", taskId: "unassigned", owner: "unassigned" }),
buildTask({
projectId: "p-live",
taskId: "todo-second",
owner: "sam",
dueAt: "2026-03-05T09:00:00.000Z",
updatedAt: "2026-02-26T00:00:00.000Z",
}),
buildTask({ projectId: "p-live", taskId: "already-running", owner: "taylor", status: "in_progress" }),
]);
try {
const result = await runTaskHeartbeat({
gate: {
enabled: true,
dryRun: false,
maxTasksPerRun: 2,
localTokenAuthRequired: true,
localTokenConfigured: true,
},
});
assert.equal(result.ok, true);
assert.equal(result.mode, "live");
assert.equal(result.selected, 2);
assert.equal(result.executed, 2);
const raw = await readFile(TASKS_PATH, "utf8");
const parsed = JSON.parse(raw) as TaskStoreSnapshot;
const byTaskId = new Map(parsed.tasks.map((task) => [task.taskId, task.status]));
assert.equal(byTaskId.get("due-fast"), "in_progress");
assert.equal(byTaskId.get("todo-second"), "in_progress");
assert.equal(byTaskId.get("unassigned"), "todo");
const runs = await readTaskHeartbeatRuns(5);
assert(runs.count >= 1);
assert.equal(runs.runs[0].mode, "live");
} finally {
await restoreOptionalFile(TASKS_PATH, tasksBefore);
await restoreOptionalFile(TASK_HEARTBEAT_LOG_PATH, logBefore);
}
});
function buildTask(input: {
projectId: string;
taskId: string;
owner: string;
status?: "todo" | "in_progress" | "blocked" | "done";
dueAt?: string;
updatedAt?: string;
}): ProjectTask {
return {
projectId: input.projectId,
taskId: input.taskId,
title: input.taskId,
status: input.status ?? "todo",
owner: input.owner,
dueAt: input.dueAt,
definitionOfDone: [],
artifacts: [],
rollback: {
strategy: "manual",
steps: [],
},
sessionKeys: [],
budget: {},
updatedAt: input.updatedAt ?? "2026-03-01T00:00:00.000Z",
};
}
async function writeTaskStoreFixture(tasks: ProjectTask[]): Promise<void> {
const store: TaskStoreSnapshot = {
tasks,
agentBudgets: [],
updatedAt: "2026-03-01T00:00:00.000Z",
};
await mkdir(dirname(TASKS_PATH), { recursive: true });
await writeFile(TASKS_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
}
async function readOptionalFile(path: string): Promise<string | undefined> {
try {
return await readFile(path, "utf8");
} catch {
return undefined;
}
}
async function restoreOptionalFile(path: string, content: string | undefined): Promise<void> {
if (content === undefined) {
await rm(path, { force: true });
return;
}
await writeFile(path, content, "utf8");
}
+328
View File
@@ -0,0 +1,328 @@
import assert from "node:assert/strict";
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { spawn } from "node:child_process";
import test from "node:test";
const PROJECT_ROOT = process.cwd();
test("resident worker enforces single-chain lock ownership", async () => {
const runtimeDir = join(PROJECT_ROOT, "runtime", `zzzz-triplet-lock-${Date.now()}`);
const lockPath = join(runtimeDir, "run.lock");
await mkdir(runtimeDir, { recursive: true });
await writeFile(
lockPath,
`${JSON.stringify(
{
runId: "lock-1",
owner: "other-owner",
startedAt: new Date(Date.now() - 60_000).toISOString(),
updatedAt: new Date().toISOString(),
leaseUntil: new Date(Date.now() + 10 * 60_000).toISOString(),
fencingToken: 1,
},
null,
2,
)}\n`,
"utf8",
);
try {
const run = await runNodeScript(["scripts/resident-worker.ts", runtimeDir], {
...process.env,
RUN_OWNER: "pandas-owner",
WORKER_MAX_CYCLES: "1",
WORKER_STEPS: "goal-gate",
});
assert.equal(run.code, 1);
assert(run.stderr.includes("WORKER_LOCK_CONFLICT"), run.stderr);
} finally {
await rm(runtimeDir, { recursive: true, force: true });
}
});
test("resident supervisor restarts worker when heartbeat is stale", async () => {
const runtimeDir = join(PROJECT_ROOT, "runtime", `zzzz-triplet-supervisor-${Date.now()}`);
const heartbeatPath = join(runtimeDir, "health", "worker-heartbeat.json");
const recoveryPath = join(runtimeDir, "recovery", "supervisor-latest.json");
await mkdir(join(runtimeDir, "health"), { recursive: true });
await mkdir(join(runtimeDir, "evidence"), { recursive: true });
await writeFile(
heartbeatPath,
`${JSON.stringify(
{
generatedAt: new Date(Date.now() - 10 * 60_000).toISOString(),
pid: 999999,
state: "waiting",
},
null,
2,
)}\n`,
"utf8",
);
const staleTime = new Date(Date.now() - 10 * 60_000);
await utimes(heartbeatPath, staleTime, staleTime);
await writeFile(
join(runtimeDir, "goal-state.json"),
`${JSON.stringify(
{
goalId: "triplet-goal",
definitionOfDone: ["build", "test"],
phases: [{ id: "phase-1", status: "done" }],
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(runtimeDir, "evidence", "latest.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
itemId: "triplet",
completed: "worker baseline",
filesChanged: ["scripts/resident-worker.ts"],
verification: "test",
visibleMarker: "runtime/evidence/latest.json",
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(runtimeDir, "evidence", "product-dod.json"),
`${JSON.stringify(
{
fullChinese: true,
nonTechnicalCopy: true,
middleSchoolReadable: true,
globalVisibility: true,
appleNativeUI: true,
realSubscriptionConnected: true,
tiApproval: false,
notes: "triplet fixture",
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(runtimeDir, "subscription-snapshot.json"),
`${JSON.stringify(
{
planLabel: "Provider Pro",
consumed: 120,
remaining: 880,
limit: 1000,
unit: "USD",
},
null,
2,
)}\n`,
"utf8",
);
try {
const run = await runNodeScript(["scripts/resident-supervisor.ts", runtimeDir], {
...process.env,
RUN_OWNER: "pandas-owner",
SUPERVISOR_ONCE: "true",
SUPERVISOR_RESTART_MODE: "sync",
SUPERVISOR_HEARTBEAT_MAX_AGE_MS: "1000",
SUPERVISOR_MAX_RESTARTS: "2",
WORKER_STEPS: "goal-gate",
WORKER_INTERVAL_MS: "1",
WORKER_MAX_CYCLES: "3",
});
assert.equal(run.code, 0, `supervisor failed\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`);
const report = JSON.parse(await readFile(recoveryPath, "utf8")) as {
status?: string;
actions?: string[];
retries?: number;
};
assert.equal(report.status, "restarted");
assert.equal(report.retries, 1);
assert((report.actions ?? []).includes("restart-worker"));
} finally {
await rm(runtimeDir, { recursive: true, force: true });
}
});
test("dod check emits machine-readable pass/fail status", async () => {
const runtimeDir = join(PROJECT_ROOT, "runtime", `zzzz-triplet-dod-${Date.now()}`);
const dodPath = join(runtimeDir, "evidence", "dod-status.json");
await mkdir(join(runtimeDir, "evidence"), { recursive: true });
await writeFile(
join(runtimeDir, "goal-state.json"),
`${JSON.stringify(
{
goalId: "triplet-goal",
definitionOfDone: [],
phases: [{ id: "phase-1", status: "done" }],
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(runtimeDir, "evidence", "latest.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
itemId: "triplet",
completed: "baseline",
filesChanged: ["scripts/dod-check.ts"],
verification: "test",
visibleMarker: "runtime/evidence/latest.json",
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(runtimeDir, "evidence", "product-dod.json"),
`${JSON.stringify(
{
fullChinese: true,
nonTechnicalCopy: true,
middleSchoolReadable: true,
globalVisibility: true,
appleNativeUI: false,
realSubscriptionConnected: false,
tiApproval: false,
notes: "triplet fixture",
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(runtimeDir, "evidence", "worker-progress.json"),
`${JSON.stringify(
{
cycle: 1,
lastStep: {
id: "build",
exitCode: 1,
},
},
null,
2,
)}\n`,
"utf8",
);
try {
const failed = await runNodeScript(["scripts/dod-check.ts", runtimeDir], process.env);
assert.equal(failed.code, 1);
const failStatus = JSON.parse(await readFile(dodPath, "utf8")) as {
passed?: boolean;
checks?: Array<{ id?: string; passed?: boolean }>;
};
assert.equal(failStatus.passed, false);
assert.equal(failStatus.checks?.find((check) => check.id === "worker-last-step")?.passed, false);
await writeFile(
join(runtimeDir, "evidence", "worker-progress.json"),
`${JSON.stringify(
{
cycle: 2,
lastStep: {
id: "build",
exitCode: 0,
},
},
null,
2,
)}\n`,
"utf8",
);
const stillFailed = await runNodeScript(["scripts/dod-check.ts", runtimeDir], process.env);
assert.equal(stillFailed.code, 1);
const productGateStatus = JSON.parse(await readFile(dodPath, "utf8")) as {
passed?: boolean;
checks?: Array<{ id?: string; passed?: boolean }>;
};
assert.equal(productGateStatus.passed, false);
assert.equal(productGateStatus.checks?.find((check) => check.id === "product-apple-native-ui")?.passed, false);
assert.equal(
productGateStatus.checks?.find((check) => check.id === "product-real-subscription-connected")?.passed,
false,
);
await writeFile(
join(runtimeDir, "evidence", "product-dod.json"),
`${JSON.stringify(
{
fullChinese: true,
nonTechnicalCopy: true,
middleSchoolReadable: true,
globalVisibility: true,
appleNativeUI: true,
realSubscriptionConnected: true,
tiApproval: false,
notes: "triplet fixture",
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(runtimeDir, "subscription-snapshot.json"),
`${JSON.stringify(
{
planLabel: "Provider Pro",
consumed: 120,
remaining: 880,
limit: 1000,
unit: "USD",
},
null,
2,
)}\n`,
"utf8",
);
const passed = await runNodeScript(["scripts/dod-check.ts", runtimeDir], process.env);
assert.equal(passed.code, 0, `dod pass failed\nstdout:\n${passed.stdout}\nstderr:\n${passed.stderr}`);
const passStatus = JSON.parse(await readFile(dodPath, "utf8")) as { passed?: boolean };
assert.equal(passStatus.passed, true);
} finally {
await rm(runtimeDir, { recursive: true, force: true });
}
});
async function runNodeScript(
args: string[],
env: NodeJS.ProcessEnv,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, ["--import", "tsx", ...args.map((value) => resolve(PROJECT_ROOT, value))], {
cwd: PROJECT_ROOT,
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", rejectPromise);
child.on("close", (code) => {
resolvePromise({ code, stdout, stderr });
});
});
}
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile, rm, writeFile } from "node:fs/promises";
import {
UI_PREFERENCES_PATH,
defaultUiPreferences,
loadUiPreferences,
saveUiPreferences,
} from "../src/runtime/ui-preferences";
test("ui language preference persists after save and reload", async () => {
const original = await readMaybe(UI_PREFERENCES_PATH);
try {
await saveUiPreferences({
...defaultUiPreferences(),
language: "zh",
updatedAt: new Date().toISOString(),
});
const loaded = await loadUiPreferences();
assert.equal(loaded.preferences.language, "zh");
} finally {
if (original === undefined) {
await rm(UI_PREFERENCES_PATH, { force: true });
} else {
await writeFile(UI_PREFERENCES_PATH, original, "utf8");
}
}
});
async function readMaybe(path: string): Promise<string | undefined> {
try {
return await readFile(path, "utf8");
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
) {
return undefined;
}
throw error;
}
}
+703
View File
@@ -0,0 +1,703 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
import type { AuditTimelineSnapshot } from "../src/runtime/audit-timeline";
import type { SessionConversationDetailResult } from "../src/runtime/session-conversations";
import type { ReadModelSnapshot } from "../src/types";
test("session drilldown page renders without network and escapes content", async () => {
const { renderSessionDrilldownPageForSmoke } = await import("../src/ui/server");
const detail: SessionConversationDetailResult = {
generatedAt: "2026-03-03T09:00:00.000Z",
session: {
sessionKey: "sess-1",
label: "Primary Session",
agentId: "agent-alpha",
state: "running",
lastMessageAt: "2026-03-03T08:59:00.000Z",
},
status: {
sessionKey: "sess-1",
model: "gpt-test",
tokensIn: 10,
tokensOut: 20,
cost: 0.01,
updatedAt: "2026-03-03T08:59:30.000Z",
},
latestSnippet: "latest",
latestRole: "assistant",
latestKind: "message",
latestToolName: undefined,
latestHistoryAt: "2026-03-03T08:59:00.000Z",
historyCount: 2,
historyError: undefined,
executionChain: {
accepted: true,
spawned: true,
acceptedAt: "2026-03-03T08:58:10.000Z",
spawnedAt: "2026-03-03T08:58:20.000Z",
parentSessionKey: "sess-parent",
childSessionKey: "sess-1",
stage: "running",
source: "history",
inferred: false,
detail: "accepted=yes | spawned=yes | parent=sess-parent | child=sess-1",
},
history: [
{
kind: "accepted",
role: "system",
content: "accepted request",
timestamp: "2026-03-03T08:58:10.000Z",
},
{
kind: "message",
role: "user",
content: "render this <script>alert(1)</script>",
timestamp: "2026-03-03T08:58:00.000Z",
},
{
kind: "tool_event",
role: "tool",
content: "tool output ok",
timestamp: "2026-03-03T08:59:00.000Z",
toolName: "openclaw.approvals.get",
toolStatus: "ok",
},
],
};
const html = renderSessionDrilldownPageForSmoke(detail);
assert(html.includes("Session Drilldown"));
assert(html.includes("Execution Chain"));
assert(html.includes("Latest Messages / Tool Events"));
assert(html.includes("/api/sessions/sess-1?historyLimit=120"));
assert(html.includes("parent=sess-parent child=sess-1"));
assert(html.includes("Accepted"));
assert(html.includes("Spawned"));
assert(html.includes("&lt;script&gt;alert(1)&lt;/script&gt;"));
assert(!html.includes("<script>alert(1)</script>"));
const zh = renderSessionDrilldownPageForSmoke(detail, "zh");
assert(zh.includes("会话详情"));
assert(zh.includes("执行链"));
assert(zh.includes("最近消息 / 工具事件"));
assert(zh.includes("返回总览"));
});
test("audit timeline page renders without network and keeps severity selection", async () => {
const { renderAuditPageForSmoke } = await import("../src/ui/server");
const timeline: AuditTimelineSnapshot = {
generatedAt: "2026-03-03T09:10:00.000Z",
counts: {
info: 0,
warn: 1,
"action-required": 0,
error: 0,
},
events: [
{
timestamp: "2026-03-03T09:09:00.000Z",
severity: "warn",
source: "monitor",
message: "timeline <unsafe> marker",
},
],
};
const html = renderAuditPageForSmoke(timeline, "warn");
assert(html.includes("<title>OpenClaw Control Center Audit Timeline</title>"));
assert(html.includes("<h1>Audit Timeline</h1>"));
assert(html.includes('value="warn" selected'));
assert(html.includes("timeline &lt;unsafe&gt; marker"));
assert(!html.includes("timeline <unsafe> marker"));
});
test("dashboard section navigation renders required tabs with active state", async () => {
const { renderDashboardSectionNavForSmoke } = await import("../src/ui/server");
const en = renderDashboardSectionNavForSmoke("team", "en");
assert(en.includes("Overview"));
assert(en.includes("Staff"));
assert(en.includes("Memory"));
assert(en.includes("Documents"));
assert(en.includes("Usage"));
assert(en.includes("Tasks"));
assert(en.includes("Settings"));
assert(en.includes('aria-current="page"'));
assert(en.includes("/?section=team"));
assert(en.indexOf("Overview") < en.indexOf("Usage"));
assert(en.indexOf("Usage") < en.indexOf("Staff"));
assert(!en.includes("Executors"));
assert(!en.includes("Calendar"));
assert(!en.includes("Attention"));
assert(!en.includes("History"));
const zh = renderDashboardSectionNavForSmoke("team", "zh");
assert(zh.includes("总览"));
assert(zh.includes("员工"));
assert(zh.includes("记忆"));
assert(zh.includes("文档"));
assert(zh.includes("用量"));
assert(zh.includes("任务"));
assert(zh.includes("设置"));
assert(zh.indexOf("总览") < zh.indexOf("用量"));
assert(zh.indexOf("用量") < zh.indexOf("员工"));
assert(!zh.includes("智能体"));
assert(!zh.includes("日历"));
assert(!zh.includes("待处理"));
assert(!zh.includes("历史"));
});
test("legacy mission-control routes resolve to dashboard sections", async () => {
const { resolveLegacyDashboardSectionForSmoke, resolveDashboardSection } = await import("../src/ui/server");
assert.equal(resolveLegacyDashboardSectionForSmoke("/calendar"), "projects-tasks");
assert.equal(resolveLegacyDashboardSectionForSmoke("/heartbeat"), "overview");
assert.equal(resolveLegacyDashboardSectionForSmoke("/tools"), "settings");
assert.equal(resolveLegacyDashboardSectionForSmoke("/not-a-route"), undefined);
assert.equal(resolveDashboardSection(new URLSearchParams("section=alerts")), "overview");
assert.equal(resolveDashboardSection(new URLSearchParams("section=replay-audit")), "overview");
});
test("tasks section includes merged schedule and board content", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
assert(source.includes('<section class="card" id="calendar-board">'));
assert(source.includes('id="task-execution-chain"'));
assert(source.includes('id="task-timeline"'));
assert(source.includes('t("Today and next schedule", "今日与下一批排程")'));
assert(source.includes('<section class="task-hub-shell" id="task-hub">'));
assert(source.includes('id="task-decision-center"'));
assert(source.includes('<section class="card" id="cron-execution-board">'));
assert(source.includes('<section class="card" id="task-lane">'));
assert(source.includes('t("Execution chain", "执行链")'));
assert(source.includes('Accepted and spawned child sessions'));
assert(source.includes('if (options.section === "calendar") sectionBody = projectsSection;'));
});
test("global visibility card keeps plain-language EN/ZH copy for four key signals", async () => {
const { renderGlobalVisibilityCardForSmoke } = await import("../src/ui/server");
const en = renderGlobalVisibilityCardForSmoke("en");
assert(en.includes("Global Visibility"));
assert(en.includes("One place to see timed jobs, heartbeat, current tasks, and tool calls."));
assert(en.includes("Timed jobs:"));
assert(en.includes("Heartbeat checks:"));
assert(en.includes("Current tasks:"));
assert(en.includes("Tool calls:"));
assert(en.includes("Timed jobs are on."));
assert(en.includes("Heartbeat is on."));
assert(en.includes("Active timed jobs: 1."));
assert(en.includes("Active heartbeat checks: 1."));
assert(en.includes('/?compact=1&amp;section=overview&amp;lang=en&amp;quick=all#cron-health'));
assert(en.includes('/?compact=1&amp;section=overview&amp;lang=en&amp;quick=all#heartbeat-health'));
assert(en.includes('/?compact=1&amp;section=projects-tasks&amp;lang=en&amp;quick=all#task-lane'));
assert(en.includes('/?compact=1&amp;section=overview&amp;lang=en&amp;quick=all#tool-activity'));
assert(!en.includes('href="/cron"'));
assert(!en.includes('href="/sessions"'));
const zh = renderGlobalVisibilityCardForSmoke("zh");
assert(zh.includes("全局总览"));
assert(zh.includes("一眼看四件事:定时任务、任务心跳、当前任务、工具调用。"));
assert(zh.includes("定时任务:"));
assert(zh.includes("任务心跳:"));
assert(zh.includes("当前任务:"));
assert(zh.includes("工具调用:"));
assert(zh.includes("定时任务正在运行。"));
assert(zh.includes("任务心跳已开启。"));
assert(zh.includes("已开启定时任务:1 个。"));
assert(zh.includes("已开启任务心跳:1 个。"));
assert(!zh.includes("Global Visibility"));
assert(!zh.includes("Schedule checks (cron):"));
assert(!zh.includes("Heartbeat checks:"));
assert(!zh.includes("Tasks in progress:"));
assert(zh.includes('/?compact=1&amp;section=overview&amp;lang=zh&amp;quick=all#cron-health'));
assert(zh.includes('/?compact=1&amp;section=overview&amp;lang=zh&amp;quick=all#heartbeat-health'));
assert(zh.includes('/?compact=1&amp;section=projects-tasks&amp;lang=zh&amp;quick=all#task-lane'));
assert(zh.includes('/?compact=1&amp;section=overview&amp;lang=zh&amp;quick=all#tool-activity'));
assert(!zh.includes('href="/cron"'));
assert(!zh.includes('href="/sessions"'));
});
test("overview and task certainty lean on runtime evidence instead of manual due or blocked fields", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
const commanderSource = await readFile("src/runtime/commander.ts", "utf8");
assert(source.includes('const pendingDecisionCount = actionQueue.counts.unacked;'));
assert(source.includes("const sessionErrorCount = exceptions.errors.length;"));
assert(source.includes('const sessionBlockedCount = exceptions.blocked.filter((session) => session.state === "blocked").length;'));
assert(source.includes('const stalledRunningSessionCount = countStalledRunningSessions('));
assert(source.includes('t("Review queue", "审阅队列")'));
assert(source.includes('t("Runtime issues", "运行异常")'));
assert(source.includes('t("Stalled runs", "停滞执行")'));
assert(source.includes('pickUiText(input.language, "No execution session is linked yet.", "还没有关联执行会话。")'));
assert(source.includes('pickUiText(input.language, "A linked session is blocked.", "有会话已经进入阻塞状态。")'));
assert(!source.includes('const pendingDecisionCount = actionQueue.counts.unacked + pendingApprovalsCount;'));
assert(!source.includes('const sessionErrorCount = snapshot.sessions.filter((session) => session.state === "error").length;'));
assert(!source.includes('if (task.dueAt) score += 8;'));
assert(!source.includes('if (task.status === "blocked") score -= 18;'));
assert(!source.includes('if (overdue) score -= 18;'));
assert(commanderSource.includes("const CURRENT_RUNTIME_ISSUE_WINDOW_MS = 6 * 60 * 60 * 1000;"));
assert(commanderSource.includes("isFreshRuntimeIssueSession(s.lastMessageAt, nowMs)"));
});
test("execution chain cards keep raw JSON out of visible titles and summaries", async () => {
const { renderTaskExecutionChainCardsForSmoke } = await import("../src/ui/server");
const source = await readFile("src/ui/server.ts", "utf8");
const zh = renderTaskExecutionChainCardsForSmoke("zh");
assert(zh.includes("Main · Cron 隔离执行"));
assert(zh.includes("失败 · 错误 locked"));
assert(zh.includes("成功 · 查询 30 · 成功 2 · 入选 2 · 发送 2"));
assert(zh.includes("已接单 · 已派发 · 会话键推断 · 推断值"));
assert(zh.includes('class="execution-chain-context"'));
assert(zh.includes('class="execution-chain-flow"'));
assert(zh.includes('class="execution-chain-summary"'));
assert(!zh.includes('<strong>{&quot;ok&quot;:true'));
assert(!zh.includes('<strong>{&quot;ok&quot;:false'));
assert(!zh.includes('&quot;attemptedQueries&quot;:30'));
assert(!zh.includes('&quot;error&quot;:&quot;locked&quot;'));
assert(!zh.includes("accepted=yes | spawned=yes"));
assert(source.includes("grid-template-columns: repeat(auto-fit, minmax(min(100%, 520px), 1fr));"));
assert(source.includes(".execution-chain-context {"));
assert(source.includes(".execution-chain-flow {"));
assert(source.includes(".execution-chain-summary {"));
});
test("dashboard keeps global visibility as overview-only block", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
const usageSource = await readFile("src/runtime/usage-cost.ts", "utf8");
assert(source.includes('data-ui-polish="apple-native-v3"'));
assert(source.includes("const globalVisibilityCard = renderGlobalVisibilityCard(globalVisibilityModel, options.language);"));
assert(source.includes("const globalVisibilityQuickRows = ["));
assert(source.includes("const sidebarSignalRows ="));
assert(
source.includes(
"const globalVisibilityBlock = options.section === \"overview\" ? globalVisibilityCard : \"\";",
),
);
assert(source.includes('<div class="content-stack">${globalVisibilityBlock}${sectionBody}</div>'));
assert(source.includes("heartbeat: enabledHeartbeatCount"));
assert(source.includes("const toolCallsCount = input.toolCallsCount ?? (await countRecentToolCalls(snapshot, toolClient));"));
assert(source.includes("if (typeof item.toolEventCount === \"number\") return sum + item.toolEventCount;"));
assert(source.includes("const scheduleSignalText = scheduleRow?.currentAction ?? noSignalText;"));
assert(source.includes("<small>${escapeHtml(scheduleSignalText)}</small>"));
assert(source.includes('const language: UiLanguage = hasExplicitLanguage ? resolvedLanguage : "zh";'));
assert(!source.includes("const recentToolCallsCount = sessionPreview.items.filter((item) => item.latestKind === \"tool_event\").length;"));
assert(source.includes("const languageToggle = renderLanguageToggle(filters, options);"));
assert(source.includes("${languageToggle}"));
assert(source.includes('<section class="overview-v3-shell" id="overview-decision-home">'));
assert(source.includes('id="overview-decision-center"'));
assert(source.includes('id="overview-busy-staff"'));
assert(source.includes('id="overview-runtime-checkpoint"'));
assert(source.includes('t("Isolated execution", "隔离执行")'));
assert(source.includes('Accepted and spawned child sessions'));
assert(source.includes("${sidebarSignalRows}"));
assert(source.includes("Open current tasks"));
assert(source.includes("查看当前任务"));
assert(source.includes("Open follow-up items"));
assert(source.includes("查看待处理"));
assert(source.includes("formatSeconds(job.dueInSeconds, options.language)"));
assert(!source.includes("formatSeconds(job.dueInSeconds))"));
assert(source.includes("const spriteBoundsCache = new Map();"));
assert(source.includes("const computeSpriteBounds = (sprite) => {"));
assert(source.includes("Recommended data connections"));
assert(!source.includes("const informationCertaintyCard = renderInformationCertaintyCard(informationCertainty, options.language);"));
assert(!source.includes("const taskCertaintySection = renderTaskCertaintySection(taskCertaintyCards, options.language);"));
assert(!source.includes("${informationCertaintyCard}"));
assert(!source.includes("${taskCertaintySection}"));
assert(source.includes('const usageCostMode: UsageCostMode = "full";'));
assert(source.includes("loadCachedUsageCost(snapshot, usageCostMode)"));
assert(source.includes("loadCachedOfficeSessionPresence()"));
assert(source.includes("loadCachedTaskEvidenceSessions("));
assert(source.includes("const taskSignalItems = mergeSessionConversationItems(taskEvidenceItems, sessionPreview.items);"));
assert(source.includes("const liveSessionCount = officePresence.totalActiveSessions;"));
assert(source.includes("buildTaskDetailHref(task.taskId, input.language)"));
assert(source.includes('const language = resolveUiLanguage(url.searchParams, "zh");'));
assert(source.includes('buildUsageCostSnapshot(snapshot, mode)'));
assert(source.includes('const needsSessionPreview = activeSection === "projects-tasks" || activeSection === "overview";'));
assert(source.includes("const allApprovals = [...(snapshot.approvals ?? [])].sort(compareApprovals);"));
assert(source.includes('const pendingApprovalsCount = allApprovals.filter((item) => item.status === "pending").length;'));
assert(source.includes("replayPreview.stats.timeline.total"));
assert(source.includes('t("Replay activity", "活动回放")'));
assert(source.includes('t("Approval requests", "审批请求")'));
assert(source.includes('route: "/?section=projects-tasks&quick=attention#task-lane"'));
assert(source.includes('route: "/audit"'));
assert(source.includes('route: "/digest/latest"'));
assert(source.includes("void primeUiRenderCaches(toolClient);"));
assert(source.includes("const sourceStamp = await readReadModelSourceStamp();"));
assert(source.includes("const sessions = mapSessionsListToSummaries(live);"));
assert(!source.includes('state: item.active ? "running" : "idle"'));
assert(usageSource.includes("const USAGE_SOURCE_CACHE_TTL_MS = 10_000;"));
assert(usageSource.includes("loadCachedRuntimeUsageData()"));
assert(usageSource.includes("loadCachedSubscriptionUsage()"));
assert(source.includes('t("See four signals in overview", "在总览查看四项信号")'));
assert(source.includes('t("Data source not connected", "数据源未连接")'));
assert(source.includes('t("Recent usage", "近期用量")'));
assert(!source.includes("task.sessionKeys.slice(0, 6)"));
assert(source.includes("确定性判断"));
});
test("heartbeat API routes are implemented in UI server", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
assert(source.includes('if (method === "GET" && path === "/api/tasks/heartbeat")'));
assert(source.includes('if (method === "POST" && path === "/api/tasks/heartbeat")'));
assert(source.includes("runTaskHeartbeat({ gate })"));
});
test("usage dashboard includes token type share and cron token share sections", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
assert(source.includes("AI 用量构成(全部会话)"));
assert(source.includes("定时任务用量占比"));
assert(source.includes("定时任务内各智能体占比"));
assert(source.includes("usage_view"));
assert(source.includes("今天"));
assert(source.includes("累计"));
assert(source.includes("定时任务、Discord、Telegram、内部会话"));
assert(source.includes("renderTokenShareRows("));
assert(source.includes("usageCost.breakdownToday"));
assert(source.includes("selectedUsageBreakdown.bySessionType"));
assert(source.includes("selectedUsageBreakdown.byCronJob"));
assert(source.includes("selectedUsageBreakdown.byCronAgent"));
});
test("memory and workspace sections expose editable file workbenches", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
assert(source.includes("/api/files"));
assert(source.includes("/api/files/content"));
assert(source.includes("scope must be one of: memory, workspace"));
assert(source.includes('title: t("Memory file workbench", "记忆文件工作台")'));
assert(source.includes('Main ${escapeHtml(t("memories", "记忆"))}'));
assert(source.includes('${escapeHtml(t("Available views", "可切换查看"))}'));
assert(source.includes('const agentProfileFiles = ["MEMORY.md"];'));
assert(!source.includes('const agentProfileFiles = ["MEMORY.md", "USER.md", "SOUL.md", "IDENTITY.md"];'));
assert(source.includes("listMemoryFacetOptions()"));
assert(source.includes("listWorkspaceFacetOptions()"));
assert(source.includes("facetOptions: memoryFacetOptions"));
assert(source.includes("facetOptions: workspaceFacetOptions"));
assert(source.includes("defaultFacetKey: \"main\""));
assert(source.includes("defaultFacetKey: \"main\""));
assert(source.includes("includeAllFacet: false"));
assert(source.includes("data-default-facet"));
assert(source.includes(".file-nav-item[hidden]"));
assert(source.includes("item.style.display = visible ? \"\" : \"none\";"));
assert(source.includes("currentGroup"));
assert(source.includes("可在左侧选择具体文件"));
assert(source.includes("data-file-facet"));
assert(source.includes("const normalizeFacetKey = (value) => String(value || 'all').trim().toLowerCase() || 'all';"));
assert(source.includes(".segment-switch {"));
assert(source.includes("display: inline-flex;"));
assert(source.includes("flex-wrap: wrap;"));
assert(source.includes(".segment-item {"));
assert(source.includes("appearance: none;"));
assert(source.includes("border: none;"));
assert(source.includes("background: transparent;"));
assert(source.includes("min-height: 40px;"));
assert(source.includes(".file-facet-switch .segment-item {"));
assert(source.includes(".file-facet-switch .segment-item.active {"));
assert(source.includes("文档工作台"));
assert(source.includes("文档概览"));
assert(source.includes('t("Main documents", "Main 文档")'));
assert(source.includes("核心 Markdown"));
assert(source.includes("不再按会话历史展示文档"));
assert(source.includes("resolveEditableAgentScopesFromConfig("));
assert(source.includes("loadEditableAgentScopesFromConfig()"));
assert(source.includes("loadEditableAgentScopesFromWorkspaceDirs()"));
assert(source.includes("data-quota-reset-at"));
assert(source.includes("renderQuotaResetScript()"));
assert(source.includes("new Intl.DateTimeFormat(undefined"));
assert(source.includes("OPENCLAW_WORKSPACE_ROOT"));
assert(source.includes("SHARED_DOCUMENT_FILE_CANDIDATES"));
assert(source.includes("AGENT_DOCUMENT_FILE_CANDIDATES"));
assert(source.includes("保存后会直接写回源文件"));
assert(source.includes("renderFileWorkbenchScript()"));
assert(source.includes('t("Staff overview", "员工总览")'));
assert(source.includes('t("The default view shows only name, role, current status, current work, recent output, and whether each person is on the schedule."'));
assert(source.includes("async function resolveStaffRoleLabel("));
assert(source.includes('return pickUiText(language, "YouTube to article writing", "YouTube 视频转长文");'));
assert(source.includes('return pickUiText(language, "High-value content creation", "高价值内容创作");'));
assert(source.includes('return pickUiText(language, "Control Center delivery", "控制中心开发与交付");'));
assert(source.includes('return pickUiText(language, "Daily news and trend briefings", "每日情报与趋势简报");'));
assert(source.includes('return pickUiText(language, "Personal assistance and reminders", "私人助理与提醒");'));
assert(source.includes('return pickUiText(language, "Security and updates", "安全和更新");'));
assert(source.includes('return pickUiText(language, "Role not defined in workspace", "工作区未写明职责");'));
assert(source.includes('function staffStatusLabel('));
assert(source.includes('pickUiText(language, "Status", "当前状态")'));
assert(source.includes('pickUiText(language, "Working on", "正在处理什么")'));
assert(source.includes('pickUiText(language, "Recent output", "最近产出")'));
assert(source.includes('pickUiText(language, "In schedule", "是否在排班里")'));
assert(source.includes('const staffOverviewCards = needsTeamSnapshot'));
assert(source.includes(".staff-brief-grid {\n margin-top: 12px;\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));"));
assert(source.includes('<canvas class="agent-pixel-canvas" width="256" height="256"></canvas>'));
assert(source.includes("querySelectorAll('.agent-avatar, .staff-avatar')"));
assert(source.includes('data-animal="${escapeHtml(card.identity.animal)}"'));
assert(source.includes('t("Shared staff mission", "员工共同目标")'));
assert(source.includes('t("Staff system details", "员工配置明细")'));
assert(source.includes("renderOfficeCards("));
assert(source.includes("no network polling and no extra token usage"));
assert(source.includes("window.requestAnimationFrame(step);"));
assert(source.includes("headers[\"cache-control\"] = \"no-store, no-cache, must-revalidate, max-age=0\";"));
assert(source.includes("headers.pragma = \"no-cache\";"));
assert(source.includes("headers.expires = \"0\";"));
assert(!source.includes('return pickUiText(language, "Fast execution", "快速推进");'));
assert(!source.includes('return pickUiText(language, "Planning and organization", "排程与整理");'));
assert(!source.includes("Workspace 文件工作台"));
assert(!source.includes("聊天输出结构化入库("));
assert(!source.includes("办公室 2D 实况"));
assert(!source.includes("完整智能体名录("));
assert(!source.includes("office-scene-stage"));
assert(!source.includes("zone-watercooler"));
});
test("editable agent scopes follow configured agents before workspace folders", async () => {
const {
resolveEditableAgentScopesFromConfigForSmoke,
resolveEditableAgentScopesWithFallbackForSmoke,
} = await import("../src/ui/server");
const scopes = resolveEditableAgentScopesFromConfigForSmoke({
agents: {
list: [
{ id: "pandas", workspace: "/tmp/pandas" },
{ id: "tiger", workspace: "/tmp/tiger" },
],
},
});
assert.deepEqual(
scopes.map((item) => item.facetKey),
["main", "pandas", "tiger"],
);
assert.equal(scopes[0]?.facetLabel, "Main");
assert(!scopes.some((item) => item.facetKey === "dolphin"));
assert(!scopes.some((item) => item.facetKey === "mission-ops"));
const guardedScopes = resolveEditableAgentScopesWithFallbackForSmoke({
configText: "{not-json",
workspaceAgentIds: ["dolphin", "mission-ops", "pandas"],
});
assert.deepEqual(
guardedScopes.map((item) => item.facetKey),
["main"],
);
});
test("search helpers keep total matches separate from returned rows", async () => {
const { buildDashboardSearchResultForSmoke } = await import("../src/ui/server");
const snapshot: ReadModelSnapshot = {
sessions: [
{
sessionKey: "sess-alpha-1",
label: "Alpha One",
agentId: "panda",
state: "running",
lastMessageAt: "2026-03-10T10:00:00.000Z",
},
{
sessionKey: "sess-alpha-2",
label: "Alpha Two",
agentId: "tiger",
state: "idle",
lastMessageAt: "2026-03-10T09:00:00.000Z",
},
],
statuses: [],
cronJobs: [],
approvals: [],
projects: {
updatedAt: "2026-03-10T10:00:00.000Z",
projects: [
{
projectId: "proj-alpha",
title: "Alpha rollout",
status: "active",
owner: "panda",
updatedAt: "2026-03-10T10:00:00.000Z",
},
],
},
projectSummaries: [],
tasks: {
updatedAt: "2026-03-10T10:00:00.000Z",
agentBudgets: [],
tasks: [
{
projectId: "proj-alpha",
taskId: "task-alpha-1",
title: "Alpha first",
status: "todo",
owner: "panda",
definitionOfDone: [],
artifacts: [],
rollback: { strategy: "none", steps: [] },
sessionKeys: ["sess-alpha-1"],
budget: {},
updatedAt: "2026-03-10T10:00:00.000Z",
},
{
projectId: "proj-alpha",
taskId: "task-alpha-2",
title: "Alpha second",
status: "blocked",
owner: "tiger",
definitionOfDone: [],
artifacts: [],
rollback: { strategy: "none", steps: [] },
sessionKeys: ["sess-alpha-2"],
budget: {},
updatedAt: "2026-03-10T10:01:00.000Z",
},
],
},
tasksSummary: {
projects: 1,
tasks: 2,
todo: 1,
inProgress: 0,
blocked: 1,
done: 0,
owners: 2,
artifacts: 0,
},
budgetSummary: { total: 0, ok: 0, warn: 0, over: 0, evaluations: [] },
generatedAt: "2026-03-10T10:05:00.000Z",
};
const taskResult = buildDashboardSearchResultForSmoke(snapshot, { scope: "tasks", q: "alpha", limit: 1 });
assert(taskResult);
assert.equal(taskResult.count, 2);
assert.equal(taskResult.returned, 1);
const sessionResult = buildDashboardSearchResultForSmoke(snapshot, {
scope: "sessions",
q: "alpha",
limit: 1,
});
assert(sessionResult);
assert.equal(sessionResult.count, 2);
assert.equal(sessionResult.returned, 1);
});
test("search APIs advertise total match counts and bounded returned rows", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
const apiDocsSource = await readFile("src/runtime/api-docs.ts", "utf8");
assert(source.includes("count: matches.length,"));
assert(source.includes("returned: tasks.length,"));
assert(source.includes("returned: projects.length,"));
assert(source.includes("returned: sessions.length,"));
assert(source.includes("returned: items.length,"));
assert(source.includes('const snapshot = await readReadModelSnapshotWithLiveSessions(toolClient);'));
assert(source.includes("function buildBoundedSearchResult<T>(items: T[], limit: number): {"));
assert(apiDocsSource.includes('count: "number (total matches before limit)"'));
assert(apiDocsSource.includes('returned: "number (items returned in this response)"'));
assert(apiDocsSource.includes('count: "number (total matches before limit, including live-merged sessions)"'));
});
test("import live input turns invalid file paths into validation errors", async () => {
const { resolveImportInputForSmoke } = await import("../src/runtime/import-live");
const result = await resolveImportInputForSmoke({ fileName: "../outside.json" });
assert.equal(result.ok, false);
assert.equal(result.validation.valid, false);
assert.match(result.validation.issues[0] ?? "", /outside runtime exports directory/i);
});
test("session links stay on the session detail UI and docs index accepts language", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
assert(source.includes('function buildSessionDetailHref(sessionKey: string, language: UiLanguage): string {'));
assert(source.includes('const language = resolveUiLanguage(url.searchParams, "zh");'));
assert(source.includes('const html = renderSessionDrilldownPage(detail, language);'));
assert(source.includes('assertAllowedQueryParams(url.searchParams, ["lang"], true);'));
assert(source.includes('Open document workbench'));
assert(source.includes('Back to control center'));
assert(!source.includes('href="/sessions/${encodeURIComponent(item.sessionKey)}"'));
assert(source.includes('Available views", "可切换查看"))}${escapeHtml(options.language === "en" ? ": " : ":")}'));
assert(source.includes('function joinDisplayList(items: string[], language: UiLanguage): string {'));
});
test("navigation script does not add artificial leave delay", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
assert(source.includes("window.location.href = href;"));
assert(!source.includes("window.setTimeout(() => {\n window.location.href = href;\n }, 120);"));
});
test("agent animal identity mapping is semantic-first and deterministic", async () => {
const { deriveAgentAnimalIdentity } = await import("../src/ui/server");
const codex = deriveAgentAnimalIdentity("codex");
assert.equal(codex.animal, "robot");
const panda = deriveAgentAnimalIdentity("pandas-control");
assert.equal(panda.animal, "panda");
const leader = deriveAgentAnimalIdentity("main");
assert.equal(leader.animal, "lion");
const otter = deriveAgentAnimalIdentity("otter");
assert.equal(otter.animal, "otter");
const rooster = deriveAgentAnimalIdentity("coq");
assert.equal(rooster.animal, "rooster");
const tiger = deriveAgentAnimalIdentity("tiger");
assert.equal(tiger.animal, "tiger");
const fallbackA = deriveAgentAnimalIdentity("zxq-agent-42");
const fallbackB = deriveAgentAnimalIdentity("zxq-agent-42");
assert.equal(fallbackA.animal, fallbackB.animal);
assert.equal(typeof fallbackA.title, "string");
assert.notEqual(fallbackA.title, "");
});
test("subscription card renders explicit unavailable states for missing fields", async () => {
const { renderSubscriptionStatusCardForSmoke } = await import("../src/ui/server");
const html = renderSubscriptionStatusCardForSmoke({
status: "partial",
planLabel: "Pro Monthly",
unit: "USD",
detail: "Partial billing fields available.",
connectHint: "Provide subscription snapshot path.",
});
assert(html.includes("Pro Monthly"));
assert(html.includes("Used Unavailable: subscription data is missing &quot;consumed&quot;"));
assert(html.includes("Remaining Unavailable: subscription data is missing &quot;remaining&quot;"));
assert(html.includes("Unavailable: subscription data is missing &quot;limit&quot;"));
assert(html.includes("Cycle"));
assert(html.includes("Not provided"));
});
test("subscription card normalizes near-week minute labels before rendering", async () => {
const { renderSubscriptionStatusCardForSmoke } = await import("../src/ui/server");
const html = renderSubscriptionStatusCardForSmoke({
status: "connected",
planLabel: "Codex Live",
unit: "%",
detail: "Live Codex rate limits.",
connectHint: "",
consumed: 2,
remaining: 98,
limit: 100,
usagePercent: 2,
primaryWindowLabel: "300m",
primaryUsedPercent: 2,
primaryRemainingPercent: 98,
primaryResetAt: "2026-03-11T11:30:05.000Z",
secondaryWindowLabel: "10081m",
secondaryUsedPercent: 1,
secondaryRemainingPercent: 99,
secondaryResetAt: "2026-03-18T06:31:05.000Z",
});
assert(html.includes(">5h<"));
assert(html.includes(">Week<"));
assert(!html.includes(">300m<"));
assert(!html.includes(">10081m<"));
});
+532
View File
@@ -0,0 +1,532 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ReadModelSnapshot } from "../src/types";
test("usage-cost snapshot marks disconnected sources with explicit placeholders", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const snapshot = buildSnapshotFixture();
const usage = computeUsageCostSnapshot(snapshot, [], []);
const today = usage.periods.find((item) => item.key === "today");
assert(today, "Expected today usage period.");
assert.equal(today.requestCountStatus, "not_connected");
assert.equal(usage.contextWindows[0]?.thresholdState, "not_connected");
assert.equal(usage.budget.status, "not_connected");
assert(usage.connectors.todos.some((item) => item.id === "request_counter"));
assert(usage.connectors.todos.some((item) => item.id === "context_catalog"));
});
test("usage-cost snapshot computes context percent and burn-rate status when sources exist", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const snapshot = buildSnapshotFixture({
model: "gpt-4o",
tokensIn: 64000,
tokensOut: 6000,
cost: 30,
costLimit: 100,
});
const todayIso = new Date().toISOString().slice(0, 10);
const usage = computeUsageCostSnapshot(
snapshot,
[
{
date: todayIso,
usage: {
statuses: 1,
totalTokensIn: 64000,
totalTokensOut: 6000,
totalCost: 90,
},
},
],
[{ match: "gpt-4o", contextWindowTokens: 128000, provider: "OpenAI" }],
);
assert.equal(usage.contextWindows[0]?.dataStatus, "connected");
assert.equal(Math.round(usage.contextWindows[0]?.usagePercent ?? 0), 55);
assert.equal(usage.budget.status, "warn");
assert.equal(usage.connectors.modelContextCatalog, "connected");
assert.equal(usage.periods.find((item) => item.key === "today")?.sourceStatus, "connected");
});
test("usage-cost snapshot uses runtime session events for real requests, trends, and breakdown rows", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const snapshot = buildSnapshotFixture({
model: "gpt-5.3-codex",
tokensIn: 64000,
tokensOut: 6000,
cost: 10,
costLimit: 50,
});
const now = Date.now();
const usage = computeUsageCostSnapshot(snapshot, [], [], {
sourceStatus: "connected",
sessionContexts: [
{
sessionKey: "s-1",
sessionId: "sid-1",
agentId: "pandas",
model: "gpt-5.3-codex",
provider: "OpenAI",
contextWindowTokens: 100000,
},
],
events: [
runtimeEvent(now, 1000, 1),
runtimeEvent(now - 2 * 60 * 60 * 1000, 2000, 2),
runtimeEvent(now - 1 * 24 * 60 * 60 * 1000, 4000, 3),
runtimeEvent(now - 8 * 24 * 60 * 60 * 1000, 5000, 4),
],
});
const today = usage.periods.find((item) => item.key === "today");
const seven = usage.periods.find((item) => item.key === "7d");
const thirty = usage.periods.find((item) => item.key === "30d");
assert(today && seven && thirty);
assert.equal(today.tokens, 3000);
assert.equal(today.requestCount, 2);
assert.equal(today.requestCountStatus, "connected");
assert.equal(seven.tokens, 7000);
assert.equal(seven.requestCount, 3);
assert.equal(thirty.tokens, 12000);
assert.equal(thirty.requestCount, 4);
assert.equal(usage.contextWindows[0]?.dataStatus, "connected");
assert.equal(Math.round(usage.contextWindows[0]?.usagePercent ?? 0), 70);
assert.equal(usage.breakdown.byAgent[0]?.label, "pandas");
assert.equal(usage.breakdown.byAgent[0]?.requests, 4);
assert.equal(usage.breakdown.byTask[0]?.label, "p1/t1 · pandas · session");
assert.equal(usage.breakdown.byTask[0]?.tokens, 12000);
assert.equal(usage.breakdown.byTask[0]?.requests, 4);
assert.equal(usage.breakdownToday.byAgent[0]?.label, "pandas");
assert.equal(usage.breakdownToday.byAgent[0]?.tokens, 3000);
assert.equal(usage.breakdownToday.byAgent[0]?.requests, 2);
assert.equal(usage.breakdownToday.byTask[0]?.tokens, 3000);
assert.equal(usage.breakdownToday.bySessionType[0]?.label, "Main/内部会话");
assert.equal(usage.connectors.requestCounts, "connected");
assert(!usage.connectors.todos.some((item) => item.id === "request_counter"));
assert(!usage.connectors.todos.some((item) => item.id === "context_catalog"));
});
test("usage-cost snapshot surfaces connected subscription consumption and remaining contract", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const usage = computeUsageCostSnapshot(buildSnapshotFixture(), [], [], undefined, {
status: "connected",
planLabel: "Pro Monthly",
consumed: 120,
remaining: 80,
limit: 200,
usagePercent: 60,
unit: "USD",
cycleStart: "2026-03-01",
cycleEnd: "2026-03-31",
sourcePath: "/tmp/subscription.json",
detail: "Used 120 USD. Remaining 80 USD.",
connectHint: "Provide one of: /tmp/subscription.json",
reasonCode: "provider_connected",
});
assert.equal(usage.subscription.status, "connected");
assert.equal(usage.subscription.remaining, 80);
assert.equal(usage.subscription.cycleEnd, "2026-03-31");
assert.equal(usage.connectors.subscriptionUsage, "connected");
assert(!usage.connectors.todos.some((item) => item.id === "subscription_usage"));
});
test("usage-cost snapshot surfaces unavailable subscription with explicit connector todo", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const usage = computeUsageCostSnapshot(buildSnapshotFixture(), [], [], undefined, {
status: "not_connected",
planLabel: "Not connected",
unit: "USD",
detail: "Subscription usage source is not connected.",
connectHint: "Provide one of: /tmp/subscription.json",
});
assert.equal(usage.subscription.status, "not_connected");
assert.equal(usage.connectors.subscriptionUsage, "not_connected");
assert(usage.connectors.todos.some((item) => item.id === "subscription_usage"));
assert(usage.connectors.todos.some((item) => item.detail.includes("/tmp/subscription.json")));
});
test("usage-cost snapshot backfills subscription consumed from runtime usage when provider snapshot is missing", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const now = Date.now();
const usage = computeUsageCostSnapshot(
buildSnapshotFixture({
cost: 0,
}),
[],
[],
{
sourceStatus: "connected",
sessionContexts: [
{
sessionKey: "s-1",
sessionId: "sid-1",
agentId: "pandas",
},
],
events: [runtimeEvent(now, 1000, 1), runtimeEvent(now - 30 * 60 * 1000, 1200, 2)],
},
);
assert.equal(usage.subscription.status, "not_connected");
assert.equal(usage.subscription.consumed, 3);
assert.equal(usage.subscription.remaining, undefined);
assert.equal(usage.subscription.limit, undefined);
assert(usage.subscription.detail.includes("Provider remaining/limit cannot be derived"));
assert.equal(usage.subscription.reasonCode, "runtime_backfill_only");
assert(usage.subscription.sourcePath?.includes("sessions/*.jsonl"));
assert.equal(usage.connectors.subscriptionUsage, "not_connected");
assert(
usage.connectors.todos.some(
(item) =>
item.id === "subscription_usage" &&
item.detail.includes("Provider remaining/limit cannot be derived"),
),
);
});
test("usage-cost snapshot backfills subscription limit and remaining from budget when provider snapshot is missing", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const now = Date.now();
const usage = computeUsageCostSnapshot(
buildSnapshotFixture({
cost: 0,
costLimit: 10,
}),
[],
[],
{
sourceStatus: "connected",
sessionContexts: [
{
sessionKey: "s-1",
sessionId: "sid-1",
agentId: "pandas",
},
],
events: [runtimeEvent(now, 1000, 1), runtimeEvent(now - 30 * 60 * 1000, 1200, 2)],
},
);
assert.equal(usage.subscription.status, "not_connected");
assert.equal(usage.subscription.planLabel, "Estimated budget envelope");
assert.equal(usage.subscription.consumed, 3);
assert.equal(usage.subscription.limit, 10);
assert.equal(usage.subscription.remaining, 7);
assert.equal(usage.subscription.usagePercent, 30);
assert(usage.subscription.sourcePath?.includes("budgetSummary"));
assert(usage.subscription.detail.includes("Remaining/limit are derived from configured 30d cost budget"));
assert.equal(usage.connectors.subscriptionUsage, "not_connected");
assert(usage.connectors.todos.some((item) => item.id === "subscription_usage"));
});
test("usage-cost snapshot reports session-type share and cron-job share from deduped session totals", async () => {
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
const usage = computeUsageCostSnapshot(
buildSnapshotFixture(),
[],
[],
{
sourceStatus: "connected",
sessionContexts: [
{
sessionKey: "agent:main:cron:job-a",
sessionId: "sid-1",
agentId: "main",
totalTokens: 1000,
},
{
sessionKey: "agent:main:cron:job-a:run:r-1",
sessionId: "sid-1",
agentId: "main",
totalTokens: 1000,
},
{
sessionKey: "agent:main:discord:channel:123",
sessionId: "sid-2",
agentId: "main",
totalTokens: 100,
channel: "discord",
},
{
sessionKey: "agent:coq:telegram:direct:123",
sessionId: "sid-3",
agentId: "coq",
totalTokens: 50,
channel: "telegram",
},
{
sessionKey: "agent:main:main",
sessionId: "sid-4",
agentId: "main",
totalTokens: 25,
},
],
events: [],
},
undefined,
new Map([["job-a", "job-alpha"]]),
);
const byType = usage.breakdown.bySessionType;
assert.equal(byType.find((item) => item.label === "Cron")?.tokens, 1000);
assert.equal(byType.find((item) => item.label === "Discord")?.tokens, 100);
assert.equal(byType.find((item) => item.label === "Telegram")?.tokens, 50);
assert.equal(byType.find((item) => item.label === "Main/内部会话")?.tokens, 25);
const cronTop = usage.breakdown.byCronJob[0];
assert(cronTop);
assert.equal(cronTop.tokens, 1000);
assert(cronTop.label.includes("job-alpha"));
assert(cronTop.label.includes("job-a"));
assert.equal(cronTop.sessions, 1);
assert.equal(usage.breakdown.byCronAgent[0]?.label, "main");
assert.equal(usage.breakdown.byCronAgent[0]?.tokens, 1000);
});
test("codex rate-limit parser prefers canonical codex window over model-specific window", async () => {
const { parseCodexRateLimitFromSessionLogForSmoke } = await import("../src/runtime/usage-cost");
const raw = [
JSON.stringify({
timestamp: "2026-03-06T11:38:10.057Z",
type: "event_msg",
payload: {
type: "token_count",
rate_limits: {
limit_id: "codex_bengalfox",
limit_name: "GPT-5.3-Codex-Spark",
primary: { used_percent: 0, window_minutes: 300, resets_at: 1772809458 },
secondary: { used_percent: 2, window_minutes: 10080, resets_at: 1773249479 },
plan_type: "pro",
},
},
}),
JSON.stringify({
timestamp: "2026-03-06T11:38:10.056Z",
type: "event_msg",
payload: {
type: "token_count",
rate_limits: {
limit_id: "codex",
limit_name: null,
primary: { used_percent: 2, window_minutes: 300, resets_at: 1772812807 },
secondary: { used_percent: 45, window_minutes: 10080, resets_at: 1773264817 },
plan_type: "pro",
},
},
}),
].join("\n");
const parsed = parseCodexRateLimitFromSessionLogForSmoke(raw, "/tmp/codex.jsonl", Date.parse("2026-03-06T11:40:00.000Z"));
assert(parsed, "expected parsed codex rate-limit snapshot");
assert.equal(parsed.primaryUsedPercent, 2);
assert.equal(parsed.secondaryUsedPercent, 45);
assert.equal(parsed.primaryResetAt, "2026-03-06T16:00:07.000Z");
assert.equal(parsed.secondaryResetAt, "2026-03-11T21:33:37.000Z");
assert.equal(parsed.detail, "codex");
});
test("codex rate-limit parser falls back to model-specific window when canonical codex window is absent", async () => {
const { parseCodexRateLimitFromSessionLogForSmoke } = await import("../src/runtime/usage-cost");
const raw = JSON.stringify({
timestamp: "2026-03-06T11:38:10.057Z",
type: "event_msg",
payload: {
type: "token_count",
rate_limits: {
limit_id: "codex_bengalfox",
limit_name: "GPT-5.3-Codex-Spark",
primary: { used_percent: 0, window_minutes: 300, resets_at: 1772809458 },
secondary: { used_percent: 2, window_minutes: 10080, resets_at: 1773249479 },
plan_type: "pro",
},
},
});
const parsed = parseCodexRateLimitFromSessionLogForSmoke(raw, "/tmp/codex.jsonl", Date.parse("2026-03-06T11:40:00.000Z"));
assert(parsed, "expected parsed model-specific codex rate-limit snapshot");
assert.equal(parsed.primaryUsedPercent, 0);
assert.equal(parsed.secondaryUsedPercent, 2);
assert.equal(parsed.detail, "codex_bengalfox");
});
test("codex rate-limit parser snaps near-week windows to a stable weekly label", async () => {
const { parseCodexRateLimitFromSessionLogForSmoke } = await import("../src/runtime/usage-cost");
const raw = JSON.stringify({
timestamp: "2026-03-10T23:25:35.000Z",
type: "event_msg",
payload: {
type: "token_count",
rate_limits: {
limit_id: "codex",
primary: { used_percent: 0, window_minutes: 300, resets_at: 1773228605 },
secondary: { used_percent: 1, window_minutes: 10081, resets_at: 1773815465 },
plan_type: "pro",
},
},
});
const parsed = parseCodexRateLimitFromSessionLogForSmoke(raw, "/tmp/codex.jsonl", Date.parse("2026-03-10T23:26:00.000Z"));
assert(parsed, "expected parsed codex rate-limit snapshot");
assert.equal(parsed.primaryWindowLabel, "5h");
assert.equal(parsed.secondaryWindowLabel, "7d");
});
function buildSnapshotFixture(overrides?: {
model?: string;
tokensIn?: number;
tokensOut?: number;
cost?: number;
costLimit?: number;
}): ReadModelSnapshot {
const now = new Date().toISOString();
const model = overrides?.model;
const tokensIn = overrides?.tokensIn ?? 2000;
const tokensOut = overrides?.tokensOut ?? 300;
const cost = overrides?.cost ?? 2;
const costLimit = overrides?.costLimit;
return {
sessions: [
{
sessionKey: "s-1",
agentId: "pandas",
label: "Pandas Session",
state: "running",
lastMessageAt: now,
},
],
statuses: [
{
sessionKey: "s-1",
model,
tokensIn,
tokensOut,
cost,
updatedAt: now,
},
],
cronJobs: [],
approvals: [],
projects: {
updatedAt: now,
projects: [
{
projectId: "p1",
title: "Project One",
status: "active",
owner: "pandas",
budget: {},
updatedAt: now,
},
],
},
projectSummaries: [
{
projectId: "p1",
title: "Project One",
status: "active",
owner: "pandas",
totalTasks: 1,
todo: 0,
inProgress: 1,
blocked: 0,
done: 0,
due: 0,
updatedAt: now,
},
],
tasks: {
updatedAt: now,
agentBudgets: [],
tasks: [
{
projectId: "p1",
taskId: "t1",
title: "Task One",
status: "in_progress",
owner: "pandas",
definitionOfDone: [],
artifacts: [],
rollback: {
strategy: "none",
steps: [],
},
sessionKeys: ["s-1"],
budget: {},
updatedAt: now,
},
],
},
tasksSummary: {
projects: 1,
tasks: 1,
todo: 0,
inProgress: 1,
blocked: 0,
done: 0,
owners: 1,
artifacts: 0,
},
budgetSummary: {
total: costLimit ? 1 : 0,
ok: costLimit ? 1 : 0,
warn: 0,
over: 0,
evaluations: costLimit
? [
{
scope: "agent",
scopeId: "pandas",
label: "Pandas",
thresholds: {
cost: costLimit,
},
usage: {
tokensIn,
tokensOut,
totalTokens: tokensIn + tokensOut,
cost,
},
metrics: [
{
metric: "cost",
used: cost,
limit: costLimit,
warnAt: costLimit * 0.8,
status: "ok",
},
],
status: "ok",
},
]
: [],
},
generatedAt: now,
};
}
function runtimeEvent(timestampMs: number, tokens: number, cost: number) {
const timestamp = new Date(timestampMs).toISOString();
return {
timestamp,
day: timestamp.slice(0, 10),
sessionId: "sid-1",
sessionKey: "s-1",
agentId: "pandas",
model: "gpt-5.3-codex",
provider: "OpenAI",
tokens,
cost,
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"outDir": "dist",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}