Initial public-ready standalone repository
This commit is contained in:
commit
d002386bc2
66 files changed
+28683
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`)
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
Generated
+590
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Executable
+64
@@ -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))
|
||||
Executable
+35
@@ -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))
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+69
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+30
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { OpenClawLiveClient } from "./openclaw-live-client";
|
||||
import type { ToolClient } from "./tool-client";
|
||||
|
||||
export function createToolClient(): ToolClient {
|
||||
return new OpenClawLiveClient();
|
||||
}
|
||||
@@ -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,
|
||||
)}).`,
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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.");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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("今日执行计划"));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("<script>alert(1)</script>"));
|
||||
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 <unsafe> 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&section=overview&lang=en&quick=all#cron-health'));
|
||||
assert(en.includes('/?compact=1&section=overview&lang=en&quick=all#heartbeat-health'));
|
||||
assert(en.includes('/?compact=1&section=projects-tasks&lang=en&quick=all#task-lane'));
|
||||
assert(en.includes('/?compact=1&section=overview&lang=en&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&section=overview&lang=zh&quick=all#cron-health'));
|
||||
assert(zh.includes('/?compact=1&section=overview&lang=zh&quick=all#heartbeat-health'));
|
||||
assert(zh.includes('/?compact=1&section=projects-tasks&lang=zh&quick=all#task-lane'));
|
||||
assert(zh.includes('/?compact=1&section=overview&lang=zh&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>{"ok":true'));
|
||||
assert(!zh.includes('<strong>{"ok":false'));
|
||||
assert(!zh.includes('"attemptedQueries":30'));
|
||||
assert(!zh.includes('"error":"locked"'));
|
||||
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 "consumed""));
|
||||
assert(html.includes("Remaining Unavailable: subscription data is missing "remaining""));
|
||||
assert(html.includes("Unavailable: subscription data is missing "limit""));
|
||||
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<"));
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user